diff --git a/.dockerignore b/.dockerignore index 434ec09..0af9869 100644 --- a/.dockerignore +++ b/.dockerignore @@ -16,7 +16,9 @@ frontend/node_modules/ docs-site/node_modules/ docs-site/.astro/ docs-site/dist/ +docs-site/public/demo/runtime/ docs-site/src/content/docs/ +frontend-nginx-dist/ deploy/compose/docker-compose-dev.yml data/* !data/tariffs.example.json @@ -51,6 +53,11 @@ bot/app/web/templates/subscription_webapp_admin.min.*.js.br bot/app/web/templates/subscription_webapp_admin.min.*.js.gz bot/app/web/templates/subscription_webapp_admin.*.css.br bot/app/web/templates/subscription_webapp_admin.*.css.gz +bot/app/web/templates/subscription_webapp_docs_demo.css +bot/app/web/templates/subscription_webapp_docs_demo.js +bot/app/web/templates/subscription_webapp_docs_demo.*.css +bot/app/web/templates/subscription_webapp_docs_demo.*.css.br +bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz backend/bot/app/web/templates/subscription_webapp.css backend/bot/app/web/templates/subscription_webapp.js backend/bot/app/web/templates/subscription_webapp.min.*.js @@ -67,6 +74,11 @@ backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.br backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.gz backend/bot/app/web/templates/subscription_webapp_admin.*.css.br backend/bot/app/web/templates/subscription_webapp_admin.*.css.gz +backend/bot/app/web/templates/subscription_webapp_docs_demo.css +backend/bot/app/web/templates/subscription_webapp_docs_demo.js +backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css +backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.br +backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz # Byte-compiled / optimized / DLL files **/__pycache__/ diff --git a/.env.example b/.env.example index 63920b6..0c00ca2 100644 --- a/.env.example +++ b/.env.example @@ -71,16 +71,18 @@ WEB_SERVER_PORT=8080 FRONTEND_PORT=8082 # Reverse proxy IPs/CIDRs trusted for X-Forwarded-For. -# Keep loopback for local proxy; add your proxy network if needed. -TRUSTED_PROXIES=127.0.0.1,::1 +# Keep loopback and private network ranges so payment provider IP allowlists +# see the original webhook sender behind Docker/LAN/Kubernetes proxies. +TRUSTED_PROXIES=127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7 # ─── Anonymous install telemetry (opt-out) ────────────────────────────── # Once a day the worker sends a single anonymous "heartbeat" so the project # maintainer can see how many installs are active and which versions/OSes are # used. It contains an opaque random install id and coarse facts only: -# version, OS/arch, Python version, language, enabled payment providers and a -# user-count RANGE (e.g. "51-200"). No bot token, domain, user data or any -# personal information is ever sent. Full details: docs/configuration/telemetry.md +# version, official/custom image provenance, OS/arch, Python version, language, +# enabled payment providers and a user-count RANGE (e.g. "51-200"). No bot +# token, domain, user data or any personal information is ever sent. +# Full details: docs/configuration/telemetry.md # # Set to False to disable, or toggle it any time in Admin -> System -> # "Anonymous install analytics" (applies without a restart). diff --git a/.github/workflows/_docker-build-push.yml b/.github/workflows/_docker-build-push.yml index e428d86..029b0a2 100644 --- a/.github/workflows/_docker-build-push.yml +++ b/.github/workflows/_docker-build-push.yml @@ -2,11 +2,11 @@ name: Docker build & push (reusable) # Reusable workflow that builds the three image targets defined in # deploy/docker/Dockerfile (backend, worker, frontend) and optionally pushes -# them to both ghcr.io and Docker Hub under the 3252a8/ namespace. +# them to the selected registries under the repository owner's namespace. # # Called by: # - docker-dev.yml (tag_mode: dev, push: true) on pushes to dev -# - docker-release.yml (tag_mode: release, push: true) on pushes to main +# - docker-release.yml (tag_mode: release, push: true) on release tags # - ci.yml (tag_mode: dev, push: false) on pull requests on: @@ -20,6 +20,10 @@ on: description: "Tagging strategy: 'dev' or 'release'" type: string required: true + publish_dockerhub: + description: "Include Docker Hub tags and login when pushing" + type: boolean + default: true # No permissions block here on purpose: a reusable workflow cannot request more # than its caller grants, so the token scope is set by each caller @@ -78,19 +82,36 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Log in to Docker Hub - if: inputs.push + if: inputs.push && inputs.publish_dockerhub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Resolve image namespaces + id: image_namespaces + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + run: | + github_owner="${{ github.repository_owner }}" + echo "github_owner=${github_owner,,}" >> "$GITHUB_OUTPUT" + dockerhub_owner="${github_owner,,}" + if [ "${{ inputs.push }}" = "true" ] && [ "${{ inputs.publish_dockerhub }}" = "true" ]; then + if [ -z "$DOCKERHUB_USERNAME" ]; then + echo "::error::DOCKERHUB_USERNAME secret is required for Docker Hub publishing" + exit 1 + fi + dockerhub_owner="${DOCKERHUB_USERNAME,,}" + fi + echo "dockerhub_owner=$dockerhub_owner" >> "$GITHUB_OUTPUT" + - name: Docker metadata id: meta uses: docker/metadata-action@v5 with: images: | - 3252a8/${{ matrix.image }} - ghcr.io/3252a8/${{ matrix.image }} + name=${{ steps.image_namespaces.outputs.dockerhub_owner }}/${{ matrix.image }},enable=${{ inputs.push && inputs.publish_dockerhub }} + name=ghcr.io/${{ steps.image_namespaces.outputs.github_owner }}/${{ matrix.image }},enable=true tags: | type=raw,value=dev,enable=${{ inputs.tag_mode == 'dev' }} type=raw,value=latest,enable=${{ inputs.tag_mode == 'release' }} @@ -111,6 +132,7 @@ jobs: # (the ref is the tag, not a branch) so release images stay un-suffixed. build-args: | GITHUB_REF_NAME=${{ inputs.tag_mode == 'release' && 'main' || github.ref_name }} + REMNAWAVE_MINISHOP_BUILD_PROVENANCE=${{ github.repository == '3252a8/remnawave-minishop' && 'official' || 'custom' }} cache-from: type=gha,scope=${{ matrix.target }} cache-to: type=gha,mode=max,scope=${{ matrix.target }} provenance: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 027a381..3d550d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,8 @@ name: PR checks # Runs on pull requests into main (typically from dev) and into dev (typically -# from feature/* branches): lint + format checks and a no-push image build to -# prove the Docker images still build. +# from feature/* branches): lint + format checks, a demo settings-manifest +# drift guard, and a no-push image build to prove the Docker images still build. on: pull_request: @@ -56,6 +56,30 @@ jobs: run: npm run format:check working-directory: frontend + demo-manifest: + name: Demo settings manifest in sync + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: backend/requirements.txt + + - name: Install backend deps + pytest + run: pip install -r backend/requirements.txt pytest + + # Fails if admin_settings_manifest.py changed without regenerating the + # demo snapshot. Fix: `python scripts/export_settings_manifest.py` then + # `npx --prefix frontend prettier --write \ + # src/lib/webapp/settingsManifest.generated.json`, and commit the result. + - name: Check demo settings manifest is in sync + run: python -m pytest tests/test_settings_manifest_demo_sync.py -q + build: name: Docker build uses: ./.github/workflows/_docker-build-push.yml diff --git a/.github/workflows/docker-dev.yml b/.github/workflows/docker-dev.yml index 93d3f88..9c3e3e9 100644 --- a/.github/workflows/docker-dev.yml +++ b/.github/workflows/docker-dev.yml @@ -1,7 +1,7 @@ name: Dev images # On every push to the dev branch, build all three images and push them to -# ghcr.io and Docker Hub tagged `dev` and `dev-`. +# GHCR tagged `dev`. Docker Hub dev images are published by GitLab CI. on: push: @@ -22,4 +22,5 @@ jobs: with: push: true tag_mode: dev + publish_dockerhub: false secrets: inherit diff --git a/.gitignore b/.gitignore index ade87d4..8ff0f22 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ docs-site/.astro/ docs-site/dist/ docs-site/public/demo/runtime/ docs-site/src/content/docs/ +frontend-nginx-dist/ # WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build) bot/app/web/templates/subscription_webapp.css diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..2285851 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,72 @@ +stages: + - docker + +workflow: + rules: + - if: '$CI_COMMIT_BRANCH == "dev"' + - when: never + +variables: + DOCKER_BUILDKIT: "1" + DOCKER_DRIVER: overlay2 + DOCKER_HOST: tcp://docker:2375 + DOCKER_TLS_CERTDIR: "" + GIT_DEPTH: "0" + +docker-dev: + stage: docker + image: docker:27.5.1 + services: + - name: docker:27.5.1-dind + alias: docker + interruptible: true + parallel: + matrix: + - TARGET: backend + IMAGE: remnawave-minishop-backend + - TARGET: worker + IMAGE: remnawave-minishop-worker + - TARGET: frontend + IMAGE: remnawave-minishop-frontend + before_script: + - apk add --no-cache git + - test -n "$DOCKERHUB_USERNAME" + - test -n "$DOCKERHUB_TOKEN" + - echo "$DOCKERHUB_TOKEN" | docker login --username "$DOCKERHUB_USERNAME" --password-stdin + - docker buildx create --name gitlab-builder --driver docker-container --use + - docker buildx inspect --bootstrap + script: + - git fetch origin dev --tags + - | + if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/dev)" ]; then + echo "A newer dev commit exists; skipping Docker Hub build for $IMAGE." + exit 0 + fi + - | + provenance="${REMNAWAVE_MINISHOP_BUILD_PROVENANCE:-}" + if [ -z "$provenance" ]; then + dockerhub_owner="$(printf '%s' "${DOCKERHUB_USERNAME:-}" | tr '[:upper:]' '[:lower:]')" + if [ "${CI_PROJECT_PATH:-}" = "3252a8/remnawave-minishop" ] || [ "$dockerhub_owner" = "3252a8" ]; then + provenance="official" + else + provenance="custom" + fi + fi + docker buildx build \ + --load \ + --platform linux/amd64 \ + --file deploy/docker/Dockerfile \ + --target "$TARGET" \ + --build-arg "CI_COMMIT_REF_NAME=$CI_COMMIT_REF_NAME" \ + --build-arg "REMNAWAVE_MINISHOP_BUILD_PROVENANCE=$provenance" \ + --build-arg "BUILDKIT_INLINE_CACHE=1" \ + --cache-from "type=registry,ref=$DOCKERHUB_USERNAME/$IMAGE:dev" \ + --tag "$DOCKERHUB_USERNAME/$IMAGE:dev" \ + . + - git fetch origin dev --tags + - | + if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/dev)" ]; then + echo "A newer dev commit exists; skipping Docker Hub push for $IMAGE." + exit 0 + fi + - docker push "$DOCKERHUB_USERNAME/$IMAGE:dev" diff --git a/README.md b/README.md index d1c581f..63281ad 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи - Web App / Mini App с входом через Telegram или email; - встроенные инструкции установки в Mini App: личный экран `/install` и публичная ссылка `/s/` для передачи инструкции; - пробный период, промокоды и реферальная программа; -- оплата через YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket и Telegram Stars; +- оплата через YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket, PayKilla и Telegram Stars; - тикеты поддержки в Web App и внешняя ссылка на поддержку; - раздел "Мои устройства" при включенном `MY_DEVICES_SECTION_ENABLED`. @@ -43,8 +43,8 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи - [Telegram-авторизация](docs/features/telegram-auth.md) и [вход по email](docs/features/email-login.md) - настройка BotFather/OAuth и SMTP-логина. - [Поддержка пользователей / тикеты](docs/features/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки. - [Темы Web App](docs/features/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы. -- [Миграции](docs/migrations/index.md) - готовые сценарии переноса с других ботов; сейчас описан `remnawave-tg-shop`. -- [Миграция с remnawave-tg-shop](docs/migrations/remnawave-tg-shop.md) - готовый сценарий для legacy-стека. +- [Миграции](docs/migrations/index.md) - готовые сценарии переноса с `remnawave-tg-shop` и Remnashop. +- [Миграция с remnawave-tg-shop](docs/migrations/remnawave-tg-shop.md) и [Remnashop](docs/migrations/remnashop.md) - сценарии через общий install wizard. ## Совместимость @@ -90,6 +90,7 @@ docker compose logs -f backend worker frontend - `WEBAPP_SESSION_SECRET`, `WEBHOOK_SECRET_TOKEN` - стабильные секреты; - `SUBSCRIPTION_MINI_APP_URL` - публичный HTTPS URL Mini App/frontend, например `https://app.domain.com/`; - `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave; +- `TRUSTED_PROXIES` - оставьте дефолт для Docker/Caddy/Nginx/Newt или укажите IP/CIDR своего reverse proxy, чтобы IP allowlist платежных webhook видел реального провайдера; - остальные настройки удобнее задать в Web App админке. В Remnawave Panel укажите `WEBHOOK_URL` как публичный адрес Minishop с путем `/webhook/panel`, например `https://app.example.com/webhook/panel`. Секрет вебхука задается в самой Remnawave Panel; это же значение вставьте в `PANEL_WEBHOOK_SECRET` в `.env` или в **Система -> Настройки -> Remnawave Panel** в админке. @@ -98,10 +99,12 @@ docker compose logs -f backend worker frontend Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/features/tariffs.md](docs/features/tariffs.md). -В compose-примерах `/app/data` монтируется из папки `./data` рядом с `docker-compose.yml`. Заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji: +В Docker этот файл должен быть доступен не только `backend` и `worker`, но и одноразовому сервису `migrate`: мигратор читает каталог тарифов при привязке существующих подписок к тарифу по умолчанию. В текущих compose-файлах весь `/app/data` уже смонтирован в `migrate`, `backend` и `worker`; если переносите compose вручную, сохраните одинаковый mount для всех трех сервисов. + +В compose-примерах `/app/data` монтируется из папки `./data` рядом с `docker-compose.yml`. Заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes` и кеша логотипа Web App: ```bash -mkdir -p data/themes data/webapp-logo data/webapp-emoji +mkdir -p data/themes data/webapp-logo data/tariffs touch data/locales-overrides.json chown -R 10001:10001 data chmod -R u+rwX data diff --git a/backend/bot/app/controllers/dispatcher_controller.py b/backend/bot/app/controllers/dispatcher_controller.py index 14deecf..97c112c 100644 --- a/backend/bot/app/controllers/dispatcher_controller.py +++ b/backend/bot/app/controllers/dispatcher_controller.py @@ -17,6 +17,7 @@ from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware from bot.middlewares.db_session import DBSessionMiddleware from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance from bot.middlewares.profile_sync import ProfileSyncMiddleware +from bot.middlewares.update_antiflood import UpdateAntiFloodMiddleware from config.settings import Settings @@ -38,6 +39,7 @@ def build_dispatcher( dp["i18n_instance"] = i18n_instance dp["async_session_factory"] = async_session_factory + dp.update.outer_middleware(UpdateAntiFloodMiddleware(settings=settings)) dp.update.outer_middleware(DBSessionMiddleware(async_session_factory)) dp.update.outer_middleware(I18nMiddleware(i18n=i18n_instance, settings=settings)) dp.update.outer_middleware(ProfileSyncMiddleware()) diff --git a/backend/bot/app/web/admin_api.py b/backend/bot/app/web/admin_api.py index 29e0f0a..5a2d273 100644 --- a/backend/bot/app/web/admin_api.py +++ b/backend/bot/app/web/admin_api.py @@ -9,6 +9,7 @@ from bot.app.web.admin_api_impl import ( backups as _backups, broadcast as _broadcast, common as _common, + health as _health, logs as _logs, panel as _panel, payments as _payments, @@ -28,6 +29,7 @@ _MODULES = ( _runtime, _auth, _common, + _health, _stats, _users, _payments, diff --git a/backend/bot/app/web/admin_api_impl/broadcast.py b/backend/bot/app/web/admin_api_impl/broadcast.py index b19bc0a..986988a 100644 --- a/backend/bot/app/web/admin_api_impl/broadcast.py +++ b/backend/bot/app/web/admin_api_impl/broadcast.py @@ -1,5 +1,165 @@ # ruff: noqa: F401,F403,F405,I001 from ._runtime import * # noqa: F403,F405 +from .common import _panel_user_connection_activity + +import asyncio +from collections import defaultdict + +from bot.utils.ttl_cache import AsyncTTLCache + + +BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED = "active_never_connected" +BROADCAST_TARGETS = { + "all", + "active", + "inactive", + "expired", + "never", + BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED, +} +PANEL_ACTIVITY_LOOKUP_CONCURRENCY = 10 +_ADMIN_BROADCAST_AUDIENCE_COUNT_CACHES: Dict[tuple[int, int], AsyncTTLCache] = {} + + +def _resolve_panel_service(request: web.Request) -> Any: + subscription_service = request.app.get("subscription_service") + return getattr(subscription_service, "panel_service", None) + + +async def _active_subscription_panel_uuids_by_user( + session: AsyncSession, +) -> Dict[int, List[str]]: + now = datetime.now(timezone.utc) + stmt = ( + select(Subscription.user_id, Subscription.panel_user_uuid) + .join(User, Subscription.user_id == User.user_id) + .where( + User.is_banned == False, + Subscription.is_active == True, + Subscription.end_date > now, + Subscription.panel_user_uuid.is_not(None), + Subscription.panel_user_uuid != "", + ) + .order_by(Subscription.user_id.asc(), Subscription.end_date.desc()) + ) + result = await session.execute(stmt) + + grouped: Dict[int, List[str]] = defaultdict(list) + seen: Dict[int, set[str]] = defaultdict(set) + for user_id, panel_uuid in result.all(): + user_id_int = int(user_id) + panel_uuid_str = str(panel_uuid or "").strip() + if panel_uuid_str and panel_uuid_str not in seen[user_id_int]: + grouped[user_id_int].append(panel_uuid_str) + seen[user_id_int].add(panel_uuid_str) + return dict(grouped) + + +async def _panel_connection_status(panel_service: Any, panel_uuid: str) -> str: + try: + panel_user = await panel_service.get_user_by_uuid(panel_uuid) + except Exception as exc: + logger.warning("Failed to fetch panel user activity uuid=%s: %s", panel_uuid, exc) + return "unknown" + activity = _panel_user_connection_activity(panel_user) + return str(activity.get("status") or "unknown") + + +async def _user_ids_with_active_subscription_never_connected( + session: AsyncSession, + panel_service: Any, +) -> List[int]: + panel_uuids_by_user = await _active_subscription_panel_uuids_by_user(session) + semaphore = asyncio.Semaphore(PANEL_ACTIVITY_LOOKUP_CONCURRENCY) + + async def lookup(panel_uuid: str) -> str: + async with semaphore: + return await _panel_connection_status(panel_service, panel_uuid) + + panel_uuids = list( + dict.fromkeys( + panel_uuid + for user_panel_uuids in panel_uuids_by_user.values() + for panel_uuid in user_panel_uuids + ) + ) + statuses_by_uuid = dict( + zip( + panel_uuids, + await asyncio.gather(*(lookup(uuid) for uuid in panel_uuids)), + ) + ) + + user_ids: List[int] = [] + for user_id, panel_uuids in panel_uuids_by_user.items(): + statuses = [statuses_by_uuid.get(panel_uuid, "unknown") for panel_uuid in panel_uuids] + if statuses and all(status == "never" for status in statuses): + user_ids.append(user_id) + return user_ids + + +def _admin_broadcast_audience_counts_cache(settings: Settings) -> Optional[AsyncTTLCache]: + ttl_seconds = int( + getattr(settings, "ADMIN_BROADCAST_AUDIENCE_COUNTS_CACHE_TTL_SECONDS", 30) or 0 + ) + if ttl_seconds <= 0: + return None + cache_key = (id(settings), ttl_seconds) + cache = _ADMIN_BROADCAST_AUDIENCE_COUNT_CACHES.get(cache_key) + if cache is None: + cache = AsyncTTLCache( + ttl_seconds=ttl_seconds, + settings=settings, + namespace="admin:broadcast_audience_counts", + ) + _ADMIN_BROADCAST_AUDIENCE_COUNT_CACHES[cache_key] = cache + return cache + + +async def _load_broadcast_audience_counts( + settings: Settings, + async_session_factory: sessionmaker, + panel_service: Any, +) -> Dict[str, Optional[int]]: + cache = _admin_broadcast_audience_counts_cache(settings) + if cache is None: + return await _load_broadcast_audience_counts_uncached( + async_session_factory, + panel_service, + ) + cache_key = "with-panel" if panel_service is not None else "without-panel" + return await cache.get_or_load( + cache_key, + lambda: _load_broadcast_audience_counts_uncached( + async_session_factory, + panel_service, + ), + ) + + +async def _load_broadcast_audience_counts_uncached( + async_session_factory: sessionmaker, + panel_service: Any, +) -> Dict[str, Optional[int]]: + async with async_session_factory() as session: + counts: Dict[str, Optional[int]] = { + "all": await user_dal.count_all_active_users_for_broadcast(session), + "active": await user_dal.count_users_with_active_subscription_for_broadcast(session), + "inactive": await user_dal.count_users_without_active_subscription_for_broadcast( + session + ), + "expired": await user_dal.count_users_with_expired_subscription_for_broadcast(session), + "never": await user_dal.count_users_without_any_subscription_for_broadcast(session), + BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED: None, + } + if panel_service is not None: + counts[BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED] = len( + await _user_ids_with_active_subscription_never_connected( + session, + panel_service, + ) + ) + return counts async def admin_broadcast_route(request: web.Request) -> web.Response: @@ -9,7 +169,7 @@ async def admin_broadcast_route(request: web.Request) -> web.Response: target = str(payload.get("target") or "all").strip().lower() if not text: return _error(400, "empty_text") - if target not in {"all", "active", "inactive", "expired"}: + if target not in BROADCAST_TARGETS: target = "all" queue_manager = get_queue_manager() @@ -18,12 +178,22 @@ async def admin_broadcast_route(request: web.Request) -> web.Response: async_session_factory: sessionmaker = request.app["async_session_factory"] async with async_session_factory() as session: - if target == "active": + if target == BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED: + panel_service = _resolve_panel_service(request) + if panel_service is None: + return _error(503, "panel_service_unavailable") + user_ids = await _user_ids_with_active_subscription_never_connected( + session, + panel_service, + ) + elif target == "active": user_ids = await user_dal.get_user_ids_with_active_subscription(session) elif target == "inactive": user_ids = await user_dal.get_user_ids_without_active_subscription(session) elif target == "expired": user_ids = await user_dal.get_user_ids_with_expired_subscription(session) + elif target == "never": + user_ids = await user_dal.get_user_ids_without_any_subscription(session) else: user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session) @@ -54,3 +224,19 @@ async def admin_broadcast_route(request: web.Request) -> web.Response: ) return _ok({"queued": sent, "failed": failed, "target": target}) + + +async def admin_broadcast_audience_counts_route(request: web.Request) -> web.Response: + """Return how many users each broadcast audience currently resolves to.""" + _require_admin_user_id(request) + + settings: Settings = request.app["settings"] + async_session_factory: sessionmaker = request.app["async_session_factory"] + panel_service = _resolve_panel_service(request) + counts = await _load_broadcast_audience_counts( + settings, + async_session_factory, + panel_service, + ) + + return _ok({"counts": counts}) diff --git a/backend/bot/app/web/admin_api_impl/common.py b/backend/bot/app/web/admin_api_impl/common.py index b43ac82..7d1c36b 100644 --- a/backend/bot/app/web/admin_api_impl/common.py +++ b/backend/bot/app/web/admin_api_impl/common.py @@ -22,6 +22,175 @@ async def _read_json(request: web.Request) -> Dict[str, Any]: return {} +_PANEL_LAST_CONNECTED_KEYS = ( + "onlineAt", + "online_at", + "lastSeenAt", + "last_seen_at", + "lastConnectedAt", + "last_connected_at", + "lastConnectionAt", + "last_connection_at", +) +_PANEL_CONNECTION_MARKER_KEYS = ( + *_PANEL_LAST_CONNECTED_KEYS, + "firstConnectedAt", + "first_connected_at", + "lastConnectedNodeUuid", + "last_connected_node_uuid", +) +_PANEL_CONNECTION_MARKER_OBJECT_KEYS = ("lastConnectedNode", "last_connected_node") +_PANEL_TRAFFIC_OBJECT_KEYS = ("userTraffic", "user_traffic", "traffic", "trafficStats") +_PANEL_TRAFFIC_USED_KEYS = ( + "lifetimeUsedTrafficBytes", + "lifetime_used_traffic_bytes", + "usedTrafficBytes", + "used_traffic_bytes", + "trafficUsedBytes", + "traffic_used_bytes", + "downloadBytes", + "download_bytes", + "uploadBytes", + "upload_bytes", +) + + +def _panel_user_payload(panel_user_data: Any) -> Dict[str, Any]: + if not isinstance(panel_user_data, dict): + return {} + response = panel_user_data.get("response") + if isinstance(response, dict) and not any( + key in panel_user_data + for key in ("uuid", "shortUuid", "subscriptionUrl", "userTraffic", "status") + ): + return response + return panel_user_data + + +def _coerce_panel_datetime(value: Any) -> Optional[str]: + if value is None or value is False: + return None + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, (int, float)): + if value <= 0: + return None + seconds = float(value) / 1000.0 if value > 10_000_000_000 else float(value) + try: + return datetime.fromtimestamp(seconds, tz=timezone.utc).isoformat() + except (OSError, OverflowError, ValueError): + return None + text = str(value).strip() + if not text or text.lower() in {"0", "null", "none", "never"}: + return None + if text.isdigit(): + return _coerce_panel_datetime(int(text)) + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError: + return None + return parsed.isoformat() + + +def _coerce_panel_int(value: Any) -> Optional[int]: + try: + if value is None or value == "": + return None + return int(float(value)) + except (TypeError, ValueError): + return None + + +def _panel_nested_dicts(panel_user: Dict[str, Any], keys: Tuple[str, ...]) -> List[Dict[str, Any]]: + out: List[Dict[str, Any]] = [] + for key in keys: + value = panel_user.get(key) + if isinstance(value, dict): + out.append(value) + return out + + +def _panel_user_connection_containers(panel_user: Dict[str, Any]) -> List[Dict[str, Any]]: + traffic_containers = _panel_nested_dicts(panel_user, _PANEL_TRAFFIC_OBJECT_KEYS) + marker_containers = _panel_nested_dicts( + panel_user, + _PANEL_CONNECTION_MARKER_OBJECT_KEYS, + ) + for traffic_container in traffic_containers: + marker_containers.extend( + _panel_nested_dicts(traffic_container, _PANEL_CONNECTION_MARKER_OBJECT_KEYS) + ) + return [panel_user, *traffic_containers, *marker_containers] + + +def _panel_user_last_connected_at(panel_user_data: Any) -> Optional[str]: + panel_user = _panel_user_payload(panel_user_data) + if not panel_user: + return None + for container in _panel_user_connection_containers(panel_user): + for key in _PANEL_LAST_CONNECTED_KEYS: + connected_at = _coerce_panel_datetime(container.get(key)) + if connected_at: + return connected_at + return None + + +def _panel_user_positive_traffic_bytes(panel_user: Dict[str, Any]) -> bool: + containers = [panel_user, *_panel_nested_dicts(panel_user, _PANEL_TRAFFIC_OBJECT_KEYS)] + for container in containers: + for key in _PANEL_TRAFFIC_USED_KEYS: + value = _coerce_panel_int(container.get(key)) + if value is not None and value > 0: + return True + return False + + +def _panel_user_has_connection_marker(panel_user: Dict[str, Any]) -> bool: + for container in _panel_user_connection_containers(panel_user): + for key in _PANEL_CONNECTION_MARKER_KEYS: + if key in container: + return True + for container in [panel_user, *_panel_nested_dicts(panel_user, _PANEL_TRAFFIC_OBJECT_KEYS)]: + for key in _PANEL_CONNECTION_MARKER_OBJECT_KEYS: + if key in container: + return True + return False + + +def _panel_user_has_connected_marker_value(panel_user: Dict[str, Any]) -> bool: + for container in _panel_user_connection_containers(panel_user): + for key in (*_PANEL_LAST_CONNECTED_KEYS, "firstConnectedAt", "first_connected_at"): + if _coerce_panel_datetime(container.get(key)): + return True + for key in ("lastConnectedNodeUuid", "last_connected_node_uuid"): + if str(container.get(key) or "").strip(): + return True + for container in [panel_user, *_panel_nested_dicts(panel_user, _PANEL_TRAFFIC_OBJECT_KEYS)]: + for key in _PANEL_CONNECTION_MARKER_OBJECT_KEYS: + marker = container.get(key) + if isinstance(marker, dict) and any( + str(value or "").strip() for value in marker.values() + ): + return True + if marker and not isinstance(marker, dict): + return True + return False + + +def _panel_user_connection_activity(panel_user_data: Any) -> Dict[str, Any]: + panel_user = _panel_user_payload(panel_user_data) + last_connected_at = _panel_user_last_connected_at(panel_user) + if not panel_user: + return {"status": "unknown", "last_connected_at": None} + if last_connected_at or _panel_user_positive_traffic_bytes(panel_user): + return {"status": "connected", "last_connected_at": last_connected_at} + if _panel_user_has_connected_marker_value(panel_user): + return {"status": "connected", "last_connected_at": last_connected_at} + if _panel_user_has_connection_marker(panel_user): + return {"status": "never", "last_connected_at": None} + return {"status": "unknown", "last_connected_at": None} + + def _serialize_user(user: User) -> Dict[str, Any]: return { "user_id": int(user.user_id), @@ -94,6 +263,9 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]: regular_unlimited_override = bool(getattr(sub, "regular_unlimited_override", False)) premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False)) premium_limit_bytes = _premium_limit_bytes_from_subscription(sub) + provider = sub.provider + is_trial = str(provider or "").strip().lower() == "trial" + display_label = "Trial" if is_trial else sub.tariff_key return { "subscription_id": int(sub.subscription_id), "panel_user_uuid": sub.panel_user_uuid, @@ -117,9 +289,13 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]: "regular_unlimited_override": regular_unlimited_override, "premium_unlimited_override": premium_unlimited_override, "premium_is_limited": bool(sub.premium_is_limited), + "hwid_device_limit": getattr(sub, "hwid_device_limit", None), + "extra_hwid_devices": int(getattr(sub, "extra_hwid_devices", 0) or 0), "tariff_key": sub.tariff_key, + "display_label": display_label, + "is_trial": is_trial, "auto_renew_enabled": bool(sub.auto_renew_enabled), - "provider": sub.provider, + "provider": provider, "is_throttled": bool(sub.is_throttled), } diff --git a/backend/bot/app/web/admin_api_impl/health.py b/backend/bot/app/web/admin_api_impl/health.py new file mode 100644 index 0000000..3bc2f98 --- /dev/null +++ b/backend/bot/app/web/admin_api_impl/health.py @@ -0,0 +1,19 @@ +# ruff: noqa: F401,F403,F405,I001 +from datetime import datetime, timezone + +from ._runtime import * # noqa: F403,F405 +from .auth import _require_admin_user_id +from .common import _ok +from bot.services.config_health_service import collect_config_alerts + + +async def admin_health_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + refresh = str(request.query.get("refresh", "")).strip().lower() in {"1", "true", "yes"} + alerts = await collect_config_alerts(request, refresh=refresh) + return _ok( + { + "alerts": alerts, + "checked_at": datetime.now(timezone.utc).isoformat(), + } + ) diff --git a/backend/bot/app/web/admin_api_impl/routes.py b/backend/bot/app/web/admin_api_impl/routes.py index 6b0a10c..146a011 100644 --- a/backend/bot/app/web/admin_api_impl/routes.py +++ b/backend/bot/app/web/admin_api_impl/routes.py @@ -6,6 +6,7 @@ def setup_admin_routes(app: web.Application) -> None: router = app.router router.add_get("/api/admin/me", admin_me_route) router.add_get("/api/admin/stats", admin_stats_route) + router.add_get("/api/admin/health", admin_health_route) router.add_get("/api/admin/users", admin_users_list_route) router.add_get("/api/admin/users/{user_id:-?\\d+}", admin_user_detail_route) @@ -30,6 +31,10 @@ def setup_admin_routes(app: web.Application) -> None: "/api/admin/users/{user_id:-?\\d+}/regular-traffic-override", admin_user_regular_traffic_override_route, ) + router.add_post( + "/api/admin/users/{user_id:-?\\d+}/hwid-device-limit", + admin_user_hwid_device_limit_route, + ) router.add_post( "/api/admin/users/{user_id:-?\\d+}/traffic-grant", admin_user_traffic_grant_route, @@ -57,6 +62,7 @@ def setup_admin_routes(app: web.Application) -> None: router.add_post("/api/admin/support/tickets/{id:\\d+}/read", admin_support_ticket_read_route) router.add_get("/api/admin/support/stats", admin_support_stats_route) + router.add_get("/api/admin/broadcast/audience-counts", admin_broadcast_audience_counts_route) router.add_post("/api/admin/broadcast", admin_broadcast_route) router.add_post("/api/admin/sync", admin_sync_route) diff --git a/backend/bot/app/web/admin_api_impl/themes.py b/backend/bot/app/web/admin_api_impl/themes.py index 21efbe4..7fe6be2 100644 --- a/backend/bot/app/web/admin_api_impl/themes.py +++ b/backend/bot/app/web/admin_api_impl/themes.py @@ -25,7 +25,6 @@ WEBAPP_UPLOADED_LOGO_DIR = Path(__file__).resolve().parents[5] / "data" / "webap WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo" WEBAPP_FAVICON_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-logo" / "favicons" WEBAPP_FAVICON_PATH = "/webapp-favicon" -WEBAPP_EMOJI_CACHE_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-emoji" WEBAPP_FAVICON_SIZES = (16, 32, 48, 180, 192, 512) WEBAPP_LOGO_UPLOAD_CONTENT_TYPES = { ".gif": "image/gif", @@ -136,10 +135,6 @@ def _favicon_digest(url: str) -> Optional[str]: return match.group(1) if match else None -def _emoji_to_codepoints(value: str) -> str: - return "_".join(f"{ord(char):x}" for char in str(value or "").strip()) - - def prune_unused_appearance_assets(settings: Settings) -> None: keep_logos = { filename @@ -156,15 +151,6 @@ def prune_unused_appearance_assets(settings: Settings) -> None: ] if digest } - keep_emoji_prefixes = set() - if ( - getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False) - and str(getattr(settings, "WEBAPP_LOGO_EMOJI_FONT", "") or "").strip() - == "noto-color-animated" - ): - codepoints = _emoji_to_codepoints(getattr(settings, "WEBAPP_LOGO_EMOJI", "")) - if codepoints: - keep_emoji_prefixes.add(f"{codepoints}.512.") for path in WEBAPP_UPLOADED_LOGO_DIR.glob("logo-*"): if path.is_file() and path.name not in keep_logos: @@ -184,15 +170,6 @@ def prune_unused_appearance_assets(settings: Settings) -> None: except OSError: logger.warning("Failed to remove unused webapp favicon set %s", path, exc_info=True) - for path in WEBAPP_EMOJI_CACHE_DIR.glob("*.512.*"): - if path.is_file() and not any( - path.name.startswith(prefix) for prefix in keep_emoji_prefixes - ): - try: - path.unlink() - except OSError: - logger.warning("Failed to remove unused webapp emoji asset %s", path, exc_info=True) - async def _persist_appearance_upload( request: web.Request, @@ -389,7 +366,6 @@ async def admin_appearance_logo_upload_route(request: web.Request) -> web.Respon request, { "WEBAPP_LOGO_URL": logo_url, - "WEBAPP_LOGO_USE_EMOJI": False, **( {"WEBAPP_LOGO_FAVICON_URL": favicon_payload["favicon_url"]} if favicon_payload.get("favicon_url") diff --git a/backend/bot/app/web/admin_api_impl/users.py b/backend/bot/app/web/admin_api_impl/users.py index 31587c4..8c35593 100644 --- a/backend/bot/app/web/admin_api_impl/users.py +++ b/backend/bot/app/web/admin_api_impl/users.py @@ -5,6 +5,7 @@ from .common import ( _build_admin_webapp_referral_link, _error, _ok, + _panel_user_connection_activity, _premium_traffic_list_payload, _read_json, _serialize_payment, @@ -745,6 +746,24 @@ def _user_search_condition(query: str): return or_(*conditions) +def _serialize_trial_summary(user: User, trial_subs: List[Subscription]) -> Dict[str, Any]: + first_trial_sub = trial_subs[0] if trial_subs else None + latest_trial_sub = trial_subs[-1] if trial_subs else None + first_start = getattr(first_trial_sub, "start_date", None) + latest_start = getattr(latest_trial_sub, "start_date", None) + latest_end = getattr(latest_trial_sub, "end_date", None) + reset_at = getattr(user, "trial_eligibility_reset_at", None) + return { + "used": bool(trial_subs), + "count": len(trial_subs), + "first_activated_at": first_start.isoformat() if first_start else None, + "latest_activated_at": latest_start.isoformat() if latest_start else None, + "latest_end_date": latest_end.isoformat() if latest_end else None, + "active": bool(latest_trial_sub and getattr(latest_trial_sub, "is_active", False)), + "last_reset_at": reset_at.isoformat() if reset_at else None, + } + + async def admin_user_detail_route(request: web.Request) -> web.Response: _require_admin_user_id(request) target_id = int(request.match_info["user_id"]) @@ -764,6 +783,15 @@ async def admin_user_detail_route(request: web.Request) -> web.Response: .limit(20) ) latest_subs = (await session.execute(latest_subs_stmt)).scalars().all() + trial_subs_stmt = ( + select(Subscription) + .where( + Subscription.user_id == target_id, + sa_func.lower(sa_func.coalesce(Subscription.provider, "")) == "trial", + ) + .order_by(Subscription.start_date.asc().nullslast(), Subscription.end_date.asc()) + ) + trial_subs = (await session.execute(trial_subs_stmt)).scalars().all() total_paid = await payment_dal.get_user_total_paid(session, target_id) recent_payments_stmt = ( select(Payment) @@ -809,7 +837,13 @@ async def admin_user_detail_route(request: web.Request) -> web.Response: # imports into their VPN client. May be missing if the user has never # been provisioned on the panel. subscription_url: Optional[str] = None - panel_uuid = getattr(user, "panel_user_uuid", None) + last_vpn_connected_at: Optional[str] = None + vpn_connection_status = "unknown" + panel_uuid = getattr(user, "panel_user_uuid", None) or getattr( + active_sub, + "panel_user_uuid", + None, + ) if panel_uuid: subscription_service = request.app.get("subscription_service") panel_service = getattr(subscription_service, "panel_service", None) @@ -818,9 +852,12 @@ async def admin_user_detail_route(request: web.Request) -> web.Response: panel_data = await panel_service.get_user_by_uuid(panel_uuid) if panel_data: subscription_url = panel_data.get("subscriptionUrl") or None + vpn_activity = _panel_user_connection_activity(panel_data) + vpn_connection_status = str(vpn_activity.get("status") or "unknown") + last_vpn_connected_at = vpn_activity.get("last_connected_at") except Exception as exc_panel: # pragma: no cover logger.warning( - "Failed to fetch subscriptionUrl for user %s (uuid=%s): %s", + "Failed to fetch panel details for user %s (uuid=%s): %s", target_id, panel_uuid, exc_panel, @@ -830,16 +867,20 @@ async def admin_user_detail_route(request: web.Request) -> web.Response: serialized_inviter = ( _serialize_admin_user_with_avatar(inviter, avatar_keys) if inviter is not None else None ) + trial_payload = _serialize_trial_summary(user, trial_subs) return _ok( { "user": serialized_user, "active_subscription": _serialize_subscription(active_sub) if active_sub else None, "subscriptions": [_serialize_subscription(s) for s in (latest_subs or [])], + "trial": trial_payload, "total_paid": float(total_paid), "recent_payments": [_serialize_payment(p) for p in recent_payments], "log_count": int(log_count or 0), "subscription_url": subscription_url, + "last_vpn_connected_at": last_vpn_connected_at, + "vpn_connection_status": vpn_connection_status, "referral": { "code": referral_code, "bot_link": referral_bot_link, @@ -1318,6 +1359,78 @@ async def admin_user_regular_traffic_override_route(request: web.Request) -> web return _ok({"subscription": _serialize_subscription(active)}) +async def admin_user_hwid_device_limit_route(request: web.Request) -> web.Response: + """Override the user's base HWID device limit. + + ``hwid_device_limit == 0`` means unlimited; ``NULL`` means the tariff/.env + default is used. Purchased extra devices remain tracked separately and are + added when syncing the effective panel limit. + """ + actor_id = _require_admin_user_id(request) + target_id = int(request.match_info["user_id"]) + settings: Settings = request.app["settings"] + payload = await _read_json(request) + + unlimited = bool(payload.get("unlimited")) + use_default = bool(payload.get("use_default") or payload.get("reset_to_default")) + limit_raw = payload.get("hwid_device_limit", payload.get("limit")) + + if unlimited: + hwid_device_limit: Optional[int] = 0 + elif use_default or limit_raw is None or limit_raw == "": + hwid_device_limit = None + else: + try: + hwid_device_limit = int(limit_raw) + except (TypeError, ValueError): + return _error( + 400, + "invalid_hwid_device_limit", + "hwid_device_limit must be a non-negative integer", + ) + if hwid_device_limit < 0 or hwid_device_limit > 1_000_000: + return _error( + 400, + "invalid_hwid_device_limit", + "hwid_device_limit must be an integer from 0 to 1000000", + ) + + subscription_service = request.app.get("subscription_service") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + active = await subscription_dal.get_active_subscription_by_user_id(session, target_id) + if not active: + return _error(404, "no_active_subscription") + + active.hwid_device_limit = hwid_device_limit + + effective_limit = None + if subscription_service is not None: + effective_limit = await subscription_service.sync_hwid_device_limit_to_panel( + session, target_id + ) + + await message_log_dal.create_message_log( + session, + { + "user_id": actor_id, + "event_type": "admin_hwid_device_limit_webapp", + "content": ( + f"hwid_device_limit={hwid_device_limit!r} " + f"effective_hwid_device_limit={effective_limit!r}" + ), + "is_admin_event": True, + "target_user_id": target_id, + }, + ) + await session.commit() + await session.refresh(active) + + await _invalidate_after_admin_user_mutation(settings, target_id) + return _ok({"subscription": _serialize_subscription(active)}) + + async def admin_user_traffic_grant_route(request: web.Request) -> web.Response: """Credit regular or premium traffic to a user without a payment. @@ -1414,6 +1527,8 @@ async def admin_user_extend_route(request: web.Request) -> web.Response: return _error(400, "invalid_days") if days <= 0: return _error(400, "invalid_days") + extend_hwid_devices = payload.get("extend_hwid_devices") + extend_hwid_devices = True if extend_hwid_devices is None else bool(extend_hwid_devices) subscription_service = request.app.get("subscription_service") if subscription_service is None: @@ -1426,6 +1541,7 @@ async def admin_user_extend_route(request: web.Request) -> web.Response: target_id, days, "admin_extend_subscription_webapp", + extend_hwid_devices=extend_hwid_devices, ) if not new_end: await session.rollback() @@ -1436,7 +1552,10 @@ async def admin_user_extend_route(request: web.Request) -> web.Response: { "user_id": actor_id, "event_type": "admin_extend_subscription_webapp", - "content": f"+{days}d -> {new_end.isoformat()}", + "content": ( + f"+{days}d -> {new_end.isoformat()} " + f"(hwid={'yes' if extend_hwid_devices else 'no'})" + ), "is_admin_event": True, "target_user_id": target_id, }, diff --git a/backend/bot/app/web/admin_api_impl/webapp_runtime.py b/backend/bot/app/web/admin_api_impl/webapp_runtime.py index 83a8f57..00d98e3 100644 --- a/backend/bot/app/web/admin_api_impl/webapp_runtime.py +++ b/backend/bot/app/web/admin_api_impl/webapp_runtime.py @@ -13,9 +13,6 @@ WEBAPP_APPEARANCE_SETTING_KEYS = frozenset( { "WEBAPP_TITLE", "WEBAPP_LOGO_URL", - "WEBAPP_LOGO_USE_EMOJI", - "WEBAPP_LOGO_EMOJI", - "WEBAPP_LOGO_EMOJI_FONT", "WEBAPP_FAVICON_URL", "WEBAPP_FAVICON_USE_CUSTOM", "WEBAPP_LOGO_FAVICON_URL", diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py index 9c86789..4d3c93e 100644 --- a/backend/bot/app/web/admin_settings_manifest.py +++ b/backend/bot/app/web/admin_settings_manifest.py @@ -65,7 +65,6 @@ SETTINGS_MANIFEST: List[SettingField] = [ "SUPPORT_LINK", "url", "general", "Ссылка поддержки", "Куда вести пользователей за помощью." ), SettingField("SERVER_STATUS_URL", "url", "general", "Ссылка на статус серверов"), - SettingField("TERMS_OF_SERVICE_URL", "url", "general", "Условия использования"), SettingField("PRIVACY_POLICY_URL", "url", "general", "Политика конфиденциальности"), SettingField("USER_AGREEMENT_URL", "url", "general", "Пользовательское соглашение"), SettingField("DISABLE_WELCOME_MESSAGE", "bool", "general", "Скрыть приветствие /start"), @@ -77,14 +76,20 @@ SETTINGS_MANIFEST: List[SettingField] = [ "int", "general", "ID обязательного канала", - "Telegram ID канала, в котором нужно состоять.", + ( + "Telegram ID канала для проверки подписки. Если бот видит канал, " + "ссылка кнопки будет получена автоматически." + ), ), SettingField( "REQUIRED_CHANNEL_LINK", "string", "general", "Ссылка на канал", - "Имя пользователя или invite-link.", + ( + "Необязательно: публичный @username или invite-link, " + "если ссылку нельзя получить по ID канала." + ), ), SettingField( "PANEL_API_URL", @@ -101,6 +106,42 @@ SETTINGS_MANIFEST: List[SettingField] = [ "Секретный ключ API панели.", secret=True, ), + SettingField( + "PANEL_API_TOTAL_TIMEOUT_SECONDS", + "float", + "remnawave", + "Panel API total timeout", + "Maximum total time for one Remnawave API request, in seconds.", + optional=False, + min=1, + ), + SettingField( + "PANEL_API_CONNECT_TIMEOUT_SECONDS", + "float", + "remnawave", + "Panel API connect timeout", + "Maximum time to get or open a Remnawave API connection, in seconds.", + optional=False, + min=1, + ), + SettingField( + "PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS", + "float", + "remnawave", + "Panel API socket connect timeout", + "Maximum TCP/TLS connection time for Remnawave API, in seconds.", + optional=False, + min=1, + ), + SettingField( + "PANEL_API_SOCK_READ_TIMEOUT_SECONDS", + "float", + "remnawave", + "Panel API socket read timeout", + "Maximum time to wait for response data from Remnawave API, in seconds.", + optional=False, + min=1, + ), SettingField( "PANEL_WEBHOOK_SECRET", "string", @@ -139,27 +180,7 @@ SETTINGS_MANIFEST: List[SettingField] = [ SettingField( "WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной цвет", placeholder="#00fe7a" ), - SettingField("WEBAPP_LOGO_USE_EMOJI", "bool", "appearance", "Использовать эмоджи-логотип"), SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL логотипа"), - SettingField("WEBAPP_LOGO_EMOJI", "string", "appearance", "Эмоджи-логотип", placeholder="🫥"), - SettingField( - "WEBAPP_LOGO_EMOJI_FONT", - "string", - "appearance", - "Шрифт эмоджи-логотипа", - "Выберите шрифт для отображения эмодзи-логотипа", - choices=( - ("system", "Системный (по умолчанию)"), - ("noto-color", "Noto Color Emoji"), - ("noto-color-animated", "Noto Color Emoji Animated"), - ("noto-emoji", "Noto Emoji"), - ("twemoji", "Twitter Emoji"), - ("openmoji", "OpenMoji"), - ("apple", "Apple Color Emoji (local)"), - ("segoe", "Segoe UI Emoji (local)"), - ("noto-local", "Noto Emoji (local)"), - ), - ), SettingField( "WEBAPP_FAVICON_USE_CUSTOM", "bool", @@ -327,6 +348,16 @@ SETTINGS_MANIFEST: List[SettingField] = [ "Английская версия текста на этапе оплаты.", subsection="checkout", ), + SettingField( + "PAYMENT_REQUEST_TIMEOUT_SECONDS", + "float", + "payments", + "Таймаут запроса к провайдеру", + "Максимальное общее время одного API-запроса к платёжному провайдеру, в секундах.", + optional=False, + min=1, + subsection="checkout", + ), # ─── Payment providers (toggles) ─────────────────────────────── # Common SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="common"), @@ -348,7 +379,7 @@ SETTINGS_MANIFEST: List[SettingField] = [ "string", "payments", "Порядок методов оплаты", - "Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay,heleket", + "Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay,heleket,paykilla", subsection="common", ), # ─── Trial ───────────────────────────────────────────────────── @@ -386,6 +417,18 @@ SETTINGS_MANIFEST: List[SettingField] = [ optional=False, subsection="trial", ), + SettingField( + "TRIAL_WITHOUT_TELEGRAM_ENABLED", + "bool", + "pricing", + "Триал без Telegram", + ( + "Если выключено, email-only пользователю нужно привязать Telegram для " + "активации триала. Disposable email домены всегда требуют Telegram." + ), + optional=False, + subsection="trial", + ), SettingField( "TRIAL_SQUAD_UUIDS", "string", @@ -396,12 +439,82 @@ SETTINGS_MANIFEST: List[SettingField] = [ ), # ─── Referral program ────────────────────────────────────────── SettingField( - "REFERRAL_ONE_BONUS_PER_REFEREE", "bool", "referral", "Один бонус на приглашённого" + "REFERRAL_ONE_BONUS_PER_REFEREE", + "bool", + "pricing", + "Один бонус на приглашённого", + subsection="referral", ), SettingField( - "REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0 + "REFERRAL_WELCOME_BONUS_DAYS", + "int", + "pricing", + "Приветственный бонус (дней)", + min=0, + subsection="referral", + ), + SettingField( + "REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED", + "bool", + "pricing", + "Приветственный бонус без Telegram", + ( + "Если выключено, email-only пользователю нужно привязать Telegram для получения " + "реферального приветственного бонуса. Disposable email домены всегда требуют Telegram." + ), + subsection="referral", + ), + SettingField( + "LEGACY_REFS", + "bool", + "pricing", + "Поддержка старых ref-ссылок", + subsection="referral", + ), + SettingField( + "DISPOSABLE_EMAIL_DOMAINS", + "text", + "pricing", + "Disposable email домены", + ( + "Домены по одному на строку или через запятую. Пользователи без Telegram с такими " + "email не смогут получить trial или реферальный приветственный бонус." + ), + placeholder="mailinator.com\ntemp-mail.org\nyopmail.com", + subsection="referral", + ), + SettingField( + "MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED", + "bool", + "migrations", + "Старые ref-ссылки Remnashop", + "Принимать импортированные ref-коды Remnashop вместе с текущими кодами пользователей.", + subsection="Remnashop", + ), + SettingField( + "MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED", + "bool", + "migrations", + "Старые промокоды Remnashop", + "Пробовать точное совпадение промокода перед обычной uppercase-нормализацией.", + subsection="Remnashop", + ), + SettingField( + "MIGRATION_REMNASHOP_IMPORTED_AT", + "string", + "migrations", + "Последний импорт Remnashop", + "Заполняется скриптом импорта. Можно очистить, если отметка больше не нужна.", + subsection="Remnashop", + ), + SettingField( + "MIGRATION_REMNASHOP_NOTES", + "text", + "migrations", + "Заметки по миграции Remnashop", + "Внутренние заметки оператора по перенесенному инстансу.", + subsection="Remnashop", ), - SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"), # ─── Notifications ───────────────────────────────────────────── SettingField( "SUBSCRIPTION_NOTIFICATIONS_ENABLED", @@ -589,15 +702,130 @@ SETTINGS_MANIFEST: List[SettingField] = [ SettingField("USER_TRAFFIC_LIMIT_GB", "float", "devices", "Лимит трафика пользователя (ГБ)"), SettingField("USER_TRAFFIC_STRATEGY", "string", "devices", "Стратегия сброса трафика"), # ─── System ──────────────────────────────────────────────────── + SettingField( + "TELEGRAM_DROP_NON_PRIVATE_UPDATES", + "bool", + "system", + "Drop non-private Telegram updates", + "Drops group/channel messages and callbacks before DB-backed middleware runs.", + subsection="telegram_antiflood", + ), + SettingField( + "TELEGRAM_ANTIFLOOD_ENABLED", + "bool", + "system", + "Telegram anti-flood enabled", + "Enables soft per-user limits for extreme Telegram update floods.", + subsection="telegram_antiflood", + ), + SettingField( + "TELEGRAM_ANTIFLOOD_WINDOW_SECONDS", + "int", + "system", + "Anti-flood window", + "Rolling window, in seconds, used by all Telegram anti-flood buckets.", + min=1, + subsection="telegram_antiflood", + ), + SettingField( + "TELEGRAM_ANTIFLOOD_MAX_UPDATES_PER_WINDOW", + "int", + "system", + "All updates limit", + "Maximum total Telegram updates from one actor during the window. 0 disables this bucket.", + min=0, + subsection="telegram_antiflood", + ), + SettingField( + "TELEGRAM_ANTIFLOOD_MESSAGE_MAX_PER_WINDOW", + "int", + "system", + "Messages limit", + "Maximum message updates from one actor during the window. 0 disables this bucket.", + min=0, + subsection="telegram_antiflood", + ), + SettingField( + "TELEGRAM_ANTIFLOOD_CALLBACK_MAX_PER_WINDOW", + "int", + "system", + "Button callbacks limit", + "Maximum callback-query updates from one actor during the window. 0 disables this bucket.", + min=0, + subsection="telegram_antiflood", + ), + SettingField( + "TELEGRAM_ANTIFLOOD_INLINE_MAX_PER_WINDOW", + "int", + "system", + "Inline queries limit", + "Maximum inline-query updates from one actor during the window. 0 disables this bucket.", + min=0, + subsection="telegram_antiflood", + ), + SettingField( + "TELEGRAM_ANTIFLOOD_START_MAX_PER_WINDOW", + "int", + "system", + "/start limit", + "Maximum /start messages from one actor during the window. 0 disables this bucket.", + min=0, + subsection="telegram_antiflood", + ), + SettingField( + "TELEGRAM_ANTIFLOOD_EXPENSIVE_CALLBACK_MAX_PER_WINDOW", + "int", + "system", + "Expensive callbacks limit", + ( + "Maximum payment, trial, promo and account-changing callbacks from one actor " + "during the window. 0 disables this bucket." + ), + min=0, + subsection="telegram_antiflood", + ), + SettingField( + "TELEGRAM_ACTION_COOLDOWN_ENABLED", + "bool", + "system", + "Action cooldowns enabled", + "Deduplicates repeated payment and trial button presses from the same user.", + subsection="telegram_antiflood", + ), + SettingField( + "TELEGRAM_PAYMENT_CALLBACK_COOLDOWN_SECONDS", + "int", + "system", + "Payment callback cooldown", + ( + "Seconds to suppress an exact repeated payment callback from the same user. " + "0 disables this cooldown." + ), + min=0, + subsection="telegram_antiflood", + ), + SettingField( + "TELEGRAM_TRIAL_CALLBACK_COOLDOWN_SECONDS", + "int", + "system", + "Trial callback cooldown", + ( + "Seconds to suppress an exact repeated trial activation callback from the same user. " + "0 disables this cooldown." + ), + min=0, + subsection="telegram_antiflood", + ), SettingField( "TELEMETRY_ENABLED", "bool", "system", "Анонимная статистика установки", - "Раз в сутки отправляет обезличенный сигнал: версия, ОС, локаль и число " - "пользователей в виде диапазона. Без персональных данных, токенов и " - "доменов. Помогает понять число активных установок и какие версии " - "используются. Можно отключить здесь без перезапуска.", + "Раз в сутки отправляет обезличенный сигнал: версия, маркер образа " + "official/custom, ОС, локаль и число пользователей в виде диапазона. Без персональных " + "данных, токенов и доменов. Помогает понять число активных установок, какие " + "версии используются и долю изменённых сборок. Можно отключить здесь без " + "перезапуска.", ), ] @@ -734,6 +962,7 @@ def manifest_payload() -> List[dict]: "devices": 10, "subscription_guides": 10, "system": 12, + "migrations": 13, } exclusive_map = { key: opposite diff --git a/backend/bot/app/web/templates/open_app_gateway.html b/backend/bot/app/web/templates/open_app_gateway.html index 287e051..04fd848 100644 --- a/backend/bot/app/web/templates/open_app_gateway.html +++ b/backend/bot/app/web/templates/open_app_gateway.html @@ -8,11 +8,18 @@ " + ) + + def _favicon_head_markup(favicon_url: str) -> str: href = str(favicon_url or "").strip() if not href: diff --git a/backend/bot/app/web/webapp/auth.py b/backend/bot/app/web/webapp/auth.py index f1bfbe5..136bbd2 100644 --- a/backend/bot/app/web/webapp/auth.py +++ b/backend/bot/app/web/webapp/auth.py @@ -713,6 +713,7 @@ async def email_auth_verify_route(request: web.Request) -> web.Response: session, referral_param, current_user_id=None, + settings=settings, ) db_user, _ = await user_dal.create_email_user( session, @@ -821,6 +822,7 @@ async def email_auth_magic_route(request: web.Request) -> web.Response: session, referral_param, current_user_id=None, + settings=settings, ) db_user, _ = await user_dal.create_email_user( session, @@ -1010,13 +1012,53 @@ async def _request_email_code( def _telegram_id_for_user(user: User) -> Optional[int]: - if user.telegram_id: - return int(user.telegram_id) - if user.user_id and int(user.user_id) > 0: - return int(user.user_id) + telegram_id = getattr(user, "telegram_id", None) + if telegram_id: + return int(telegram_id) + user_id = getattr(user, "user_id", None) + if user_id and int(user_id) > 0: + return int(user_id) return None +def _user_has_linked_telegram(user: User) -> bool: + return bool(getattr(user, "telegram_id", None)) + + +def _email_only_telegram_required_reason( + settings: Settings, + user: User, + *, + without_telegram_enabled_attr: str, +) -> Optional[str]: + if _user_has_linked_telegram(user): + return None + if is_disposable_email(getattr(user, "email", None), settings): + return "disposable_email" + if not bool(getattr(settings, without_telegram_enabled_attr, True)): + return "telegram_required" + return None + + +def _trial_telegram_required_reason(settings: Settings, user: User) -> Optional[str]: + return _email_only_telegram_required_reason( + settings, + user, + without_telegram_enabled_attr="TRIAL_WITHOUT_TELEGRAM_ENABLED", + ) + + +def _referral_welcome_telegram_required_reason( + settings: Settings, + user: User, +) -> Optional[str]: + return _email_only_telegram_required_reason( + settings, + user, + without_telegram_enabled_attr="REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED", + ) + + def _panel_description_for_user(user: User) -> str: return panel_description_from_profile( user.username, @@ -1292,17 +1334,35 @@ async def _link_telegram_to_user( return current_user -def _normalize_referral_param(raw: Optional[str]) -> Optional[str]: +def _remnashop_referral_compat_enabled(settings: Optional[Settings]) -> bool: + if settings is None: + return False + return bool(getattr(settings, "MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED", False)) + + +def _strip_referral_param_prefix( + raw: Optional[str], + *, + preserve_current_u_prefix: bool, +) -> str: value = (raw or "").strip() if not value: - return None + return "" value_lower = value.lower() - if value_lower.startswith("ref_u"): + if value_lower.startswith("ref_u") and not preserve_current_u_prefix: value = value[5:] elif value_lower.startswith("ref_"): value = value[4:] - elif value and value[0].lower() == "u" and len(value) == 10: + return value + + +def _normalize_referral_param(raw: Optional[str]) -> Optional[str]: + value = _strip_referral_param_prefix(raw, preserve_current_u_prefix=False) + if not value: + return None + + if value and value[0].lower() == "u" and len(value) == 10: value = value[1:] if not re.fullmatch(r"[A-Za-z0-9]{1,32}", value): @@ -1310,26 +1370,64 @@ def _normalize_referral_param(raw: Optional[str]) -> Optional[str]: return value.upper() +def _referral_param_lookup_candidates( + raw: Optional[str], + *, + remnashop_compat: bool, +) -> List[str]: + if not remnashop_compat: + normalized = _normalize_referral_param(raw) + return [normalized] if normalized else [] + + value = _strip_referral_param_prefix(raw, preserve_current_u_prefix=True) + if not value or not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", value): + return [] + + candidates = [value] + if value and value[0].lower() == "u": + candidates.append(value[1:]) + + unique: List[str] = [] + for candidate in candidates: + if candidate and candidate not in unique: + unique.append(candidate) + return unique + + async def _resolve_referrer_id( session: AsyncSession, raw_referral_param: Optional[str], *, current_user_id: Optional[int], + settings: Optional[Settings] = None, ) -> Optional[int]: - normalized = _normalize_referral_param(raw_referral_param) - if not normalized: + remnashop_compat = _remnashop_referral_compat_enabled(settings) + candidates = _referral_param_lookup_candidates( + raw_referral_param, + remnashop_compat=remnashop_compat, + ) + if not candidates: return None - ref_user = None - if normalized.isdigit(): - ref_user = await user_dal.get_user_by_id(session, int(normalized)) - if not ref_user: - ref_user = await user_dal.get_user_by_referral_code(session, normalized) - if not ref_user: - return None - if current_user_id is not None and int(ref_user.user_id) == int(current_user_id): - return None - return int(ref_user.user_id) + for normalized in candidates: + ref_user = None + if normalized.isdigit() and not remnashop_compat: + ref_user = await user_dal.get_user_by_id(session, int(normalized)) + if not ref_user: + ref_user = await user_dal.get_user_by_referral_code( + session, + normalized, + include_legacy=remnashop_compat, + ) + if not ref_user and normalized.isdigit() and remnashop_compat: + ref_user = await user_dal.get_user_by_id(session, int(normalized)) + if not ref_user: + continue + if current_user_id is not None and int(ref_user.user_id) == int(current_user_id): + continue + return int(ref_user.user_id) + + return None async def _apply_referral_to_existing_user( @@ -1345,6 +1443,7 @@ async def _apply_referral_to_existing_user( session, raw_referral_param, current_user_id=int(user.user_id), + settings=request.app["settings"], ) if not referred_by_id: return False @@ -1374,6 +1473,21 @@ async def _apply_referral_welcome_bonus_if_needed( if not raw_referral_param or not user.referred_by_id: return None + settings: Settings = request.app["settings"] + if _referral_welcome_telegram_required_reason(settings, user): + return None + + return await _grant_referral_welcome_bonus_if_eligible(request, session, user) + + +async def _grant_referral_welcome_bonus_if_eligible( + request: web.Request, + session: AsyncSession, + user: User, +) -> Optional[datetime]: + if not user.referred_by_id: + return None + settings: Settings = request.app["settings"] referral_welcome_days = max( 0, @@ -1383,6 +1497,10 @@ async def _apply_referral_welcome_bonus_if_needed( return None subscription_service: SubscriptionService = request.app["subscription_service"] + default_tariff_key = None + tariffs_config = getattr(settings, "tariffs_config", None) + if tariffs_config: + default_tariff_key = getattr(tariffs_config, "default_tariff", None) try: if await subscription_service.has_active_subscription(session, int(user.user_id)): return None @@ -1394,6 +1512,68 @@ async def _apply_referral_welcome_bonus_if_needed( int(user.user_id), referral_welcome_days, reason="referral_welcome_bonus", + tariff_key=default_tariff_key, + ) + + +def _webapp_datetime_text(value: Optional[datetime]) -> Optional[str]: + if not value: + return None + normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc) + return normalized.strftime("%d.%m.%Y %H:%M") + + +async def referral_welcome_bonus_claim_route(request: web.Request) -> web.Response: + user_id = _require_user_id(request) + rate_limit_response = await _enforce_webapp_rate_limit( + request, + user_id=user_id, + action="referral_welcome_claim", + ) + if rate_limit_response: + return rate_limit_response + + settings: Settings = request.app["settings"] + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + try: + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or db_user.is_banned: + await session.rollback() + return _json_error(403, "access_denied", "Access denied") + + reason = _referral_welcome_telegram_required_reason(settings, db_user) + if reason: + await session.rollback() + return _json_error(400, "referral_welcome_telegram_required", reason) + + end_date = await _grant_referral_welcome_bonus_if_eligible( + request, + session, + db_user, + ) + if not end_date: + await session.rollback() + return _json_error( + 400, + "referral_welcome_unavailable", + "Referral welcome bonus is not available", + ) + + await session.commit() + except Exception: + await session.rollback() + logger.exception("Referral welcome bonus claim failed") + return _json_error(500, "referral_welcome_failed", "Referral welcome bonus failed") + + await _invalidate_webapp_user_caches(settings, user_id, include_devices=True) + return web.json_response( + { + "ok": True, + "claimed": True, + "end_date": end_date.isoformat() if isinstance(end_date, datetime) else None, + "end_date_text": _webapp_datetime_text(end_date), + } ) @@ -1427,6 +1607,7 @@ async def _ensure_user_from_telegram( session, referral_param or telegram_user.get("start_param"), current_user_id=user_id, + settings=settings, ) db_user, created = await user_dal.create_user( session, diff --git a/backend/bot/app/web/webapp/billing.py b/backend/bot/app/web/webapp/billing.py index 56189bc..3106e3e 100644 --- a/backend/bot/app/web/webapp/billing.py +++ b/backend/bot/app/web/webapp/billing.py @@ -1,7 +1,18 @@ # ruff: noqa: F401,F403,F405,I001 from ._runtime import * # noqa: F403,F405 +from bot.app.web.webapp.auth import _trial_telegram_required_reason from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches +from db.dal import message_log_dal + + +_HTML_TAG_RE = re.compile(r"<[^>]+>") + + +def _plain_text_message(value: Any) -> str: + """Strip Telegram-style HTML markup from a localized message for the web app.""" + text = _HTML_TAG_RE.sub("", str(value)) + return html.unescape(text).strip() def _billing_iso_datetime(value: Optional[Any]) -> Optional[str]: @@ -68,7 +79,7 @@ async def apply_promo_route(request: web.Request) -> web.Response: ) if not success: await session.commit() - return _json_error(400, "promo_apply_failed", str(result)) + return _json_error(400, "promo_apply_failed", _plain_text_message(result)) await session.commit() end_date = result if isinstance(result, datetime) else None return web.json_response( @@ -111,10 +122,11 @@ async def create_payment_route(request: web.Request) -> web.Response: hwid_quote: Optional[Dict[str, Any]] = None requested_sale_mode = _sale_mode_base(str(payment_payload.sale_mode or "")) + if tariffs_config and requested_sale_mode == "hwid_devices_renewal": + return _json_error(400, "invalid_plan", "Device renewal is part of subscription renewal") if tariffs_config and requested_sale_mode in { "hwid_device", "hwid_devices", - "hwid_devices_renewal", }: tariff_key = str(payment_payload.tariff_key or "").strip() if not tariff_key: @@ -308,7 +320,7 @@ async def create_payment_route(request: web.Request) -> web.Response: user_id=user_id, device_count=int(payment_units), tariff_key=sale_tariff_key, - renewal=_sale_mode_base(sale_mode) == "hwid_devices_renewal", + renewal=False, currency=currency, ) if not hwid_quote: @@ -321,6 +333,25 @@ async def create_payment_route(request: web.Request) -> web.Response: else: price = float(hwid_quote["price"]) stars_price = None + elif _sale_mode_base(sale_mode) == "subscription" and bool( + payment_payload.renew_hwid_devices + ): + currency = "stars" if method == "stars" else default_currency + sale_tariff_key = _sale_mode_tariff_key(sale_mode) + if sale_tariff_key: + hwid_quote = await subscription_service.quote_hwid_device_renewal_for_subscription( + session, + user_id=user_id, + target_tariff_key=sale_tariff_key, + months=int(payment_units), + currency=currency, + ) + if hwid_quote: + if method == "stars": + stars_price = int(stars_price or 0) + int(hwid_quote["price"]) + else: + price = float(price or 0) + float(hwid_quote["price"]) + stars_price = None admin_ids = {int(item) for item in (settings.ADMIN_IDS or [])} is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids) return await _create_subscription_payment( @@ -360,6 +391,13 @@ async def activate_trial_route(request: web.Request) -> web.Response: db_user = await user_dal.get_user_by_id(session, user_id) if not db_user or db_user.is_banned: return _json_error(403, "access_denied", "Access denied") + telegram_required_reason = _trial_telegram_required_reason(settings, db_user) + if telegram_required_reason: + return _json_error( + 400, + "trial_telegram_required", + telegram_required_reason, + ) activation_result = await subscription_service.activate_trial_subscription(session, user_id) if not activation_result or not activation_result.get("activated"): @@ -395,6 +433,28 @@ async def activate_trial_route(request: web.Request) -> web.Response: except Exception: logger.exception("Failed to send WebApp trial activation notification") + try: + await message_log_dal.create_message_log_no_commit( + session, + { + "user_id": user_id, + "telegram_username": getattr(db_user, "username", None), + "telegram_first_name": getattr(db_user, "first_name", None), + "event_type": "webapp_trial_activate", + "content": ( + f"Trial activated via WebApp for user_id={user_id}; " + f"email={getattr(db_user, 'email', None) or 'N/A'}" + ), + "is_admin_event": False, + "target_user_id": user_id, + "timestamp": datetime.now(timezone.utc), + }, + ) + except Exception: + logger.exception("Failed to add WebApp trial activation audit log") + + await session.commit() + try: from db.dal import ad_dal as _ad_dal @@ -659,7 +719,6 @@ async def device_topup_options_route(request: web.Request) -> web.Response: return _json_error(400, "device_topup_unavailable", "Device top-up is not available") lang = db_user.language_code or settings.DEFAULT_LANGUAGE active = await subscription_service.get_active_subscription_details(session, user_id) - renewal_available = bool(active and active.get("device_topup_renewal_available")) extra_hwid_valid_until = active.get("extra_hwid_devices_valid_until") if active else None extra_hwid_valid_until_text = ( active.get("extra_hwid_devices_valid_until_text") if active else None @@ -681,7 +740,7 @@ async def device_topup_options_route(request: web.Request) -> web.Response: user_id=user_id, device_count=count, tariff_key=tariff.key, - renewal=renewal_available, + renewal=False, currency=default_currency, ) if count in currency_counts @@ -693,7 +752,7 @@ async def device_topup_options_route(request: web.Request) -> web.Response: user_id=user_id, device_count=count, tariff_key=tariff.key, - renewal=renewal_available, + renewal=False, currency="stars", ) if count in stars_counts @@ -701,28 +760,27 @@ async def device_topup_options_route(request: web.Request) -> web.Response: ) if not currency_quote and not stars_quote: continue - sale_mode_for_plan = "hwid_devices_renewal" if renewal_available else "hwid_devices" + quote = currency_quote or stars_quote + valid_from = quote.get("valid_from") + valid_until = quote.get("valid_until") plan = { - "id": f"{tariff.key}:hwid:{count}{':renewal' if renewal_available else ''}", + "id": f"{tariff.key}:hwid:{count}", "tariff_key": tariff.key, "tariff_name": tariff.name(lang), "billing_model": tariff.billing_model, - "sale_mode": sale_mode_for_plan, + "sale_mode": "hwid_devices", + "renewal": False, "months": count, "device_count": count, "price": float(currency_quote.get("price") if currency_quote else 0), "currency": default_currency_code, "title": f"+{count}", "subtitle": tariff.name(lang), - "valid_from": _billing_iso_datetime( - (currency_quote or stars_quote).get("valid_from") - ), - "valid_until": _billing_iso_datetime( - (currency_quote or stars_quote).get("valid_until") - ), - "proration_ratio": float( - (currency_quote or stars_quote).get("proration_ratio") or 0 - ), + "valid_from": _billing_iso_datetime(valid_from), + "valid_from_text": _billing_datetime_text(valid_from), + "valid_until": _billing_iso_datetime(valid_until), + "valid_until_text": _billing_datetime_text(valid_until), + "proration_ratio": float(quote.get("proration_ratio") or 0), } if stars_quote and int(stars_quote.get("price") or 0) > 0: plan["stars_price"] = int(stars_quote["price"]) @@ -738,10 +796,8 @@ async def device_topup_options_route(request: web.Request) -> web.Response: else int(sub.extra_hwid_devices or 0), "extra_hwid_devices_valid_until": _billing_iso_datetime(extra_hwid_valid_until), "extra_hwid_devices_valid_until_text": extra_hwid_valid_until_text, - "renewal_available": renewal_available, - "renewal_recommended_count": int(active.get("extra_hwid_devices") or 0) - if active and renewal_available - else 0, + "renewal_available": False, + "renewal_recommended_count": 0, "plans": plans, } ) @@ -907,7 +963,11 @@ async def payment_status_route(request: web.Request) -> web.Response: payment = await _refresh_yookassa_payment_status(request, session, payment) payment = await _refresh_wata_payment_status(request, session, payment) if payment.status == "succeeded": - await invalidate_webapp_user_caches(request.app["settings"], user_id) + await invalidate_webapp_user_caches( + request.app["settings"], + user_id, + include_devices=True, + ) return web.json_response( { "ok": True, @@ -992,27 +1052,60 @@ async def _create_subscription_payment( "unsupported_currency", "Payment method does not support this currency", ) - return await provider_spec.create_webapp_payment( - WebAppPaymentContext( - request=request, - session=session, - user_id=user_id, - method=method, - months=months, - price=price, - stars_price=stars_price, - currency=payment_currency, - description=description, - sale_mode=sale_mode, - traffic_gb=traffic_gb, - hwid_valid_from=hwid_quote.get("valid_from") if hwid_quote else None, - hwid_valid_until=hwid_quote.get("valid_until") if hwid_quote else None, - hwid_pricing_period_months=hwid_quote.get("pricing_period_months") - if hwid_quote - else None, - hwid_proration_ratio=hwid_quote.get("proration_ratio") if hwid_quote else None, - hwid_full_price=hwid_quote.get("full_price") if hwid_quote else None, + if not provider_spec.is_usable_for_payment_amount( + settings, + payment_currency, + price, + ): + logger.warning( + "WebApp payment method does not support amount: method=%s amount=%s currency=%s", + method, + price, + payment_currency, ) + return _json_error( + 400, + "payment_amount_below_minimum", + "Payment amount is below the provider minimum", + ) + payment_context = WebAppPaymentContext( + request=request, + session=session, + user_id=user_id, + method=method, + months=months, + price=price, + stars_price=stars_price, + currency=payment_currency, + description=description, + sale_mode=sale_mode, + traffic_gb=traffic_gb, + hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None, + hwid_valid_from=hwid_quote.get("valid_from") if hwid_quote else None, + hwid_valid_until=hwid_quote.get("valid_until") if hwid_quote else None, + hwid_pricing_period_months=hwid_quote.get("pricing_period_months") + if hwid_quote + else None, + hwid_proration_ratio=hwid_quote.get("proration_ratio") if hwid_quote else None, + hwid_full_price=hwid_quote.get("full_price") if hwid_quote else None, ) + if provider_spec.reuse_webapp_payment: + from bot.payment_providers.shared import reusable_webapp_payment_response + + try: + reusable_response = await reusable_webapp_payment_response( + payment_context, + provider_spec, + ) + except Exception: + logger.exception( + "Failed to verify reusable payment: user_id=%s provider=%s", + user_id, + provider_spec.provider_key, + ) + reusable_response = None + if reusable_response is not None: + return reusable_response + return await provider_spec.create_webapp_payment(payment_context) return _json_error(400, "payment_unavailable", "Payment method unavailable") diff --git a/backend/bot/app/web/webapp/payloads.py b/backend/bot/app/web/webapp/payloads.py index e03221e..eba2a99 100644 --- a/backend/bot/app/web/webapp/payloads.py +++ b/backend/bot/app/web/webapp/payloads.py @@ -49,6 +49,7 @@ class WebAppPaymentCreatePayload(BaseModel): device_count: Any = None tariff_key: Optional[constr(max_length=128)] = None sale_mode: Optional[constr(max_length=64)] = None + renew_hwid_devices: Optional[bool] = None description: Optional[constr(max_length=4096)] = None comment: Optional[constr(max_length=4096)] = None note: Optional[constr(max_length=4096)] = None diff --git a/backend/bot/app/web/webapp/routes.py b/backend/bot/app/web/webapp/routes.py index c9b7c13..3cec44b 100644 --- a/backend/bot/app/web/webapp/routes.py +++ b/backend/bot/app/web/webapp/routes.py @@ -46,10 +46,6 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: rf"{WEBAPP_FAVICON_PATH}/{{digest:[0-9a-f]{{16}}}}/{{filename:[A-Za-z0-9_.-]+}}", webapp_favicon_route, ) - app.router.add_get( - r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}", - webapp_animated_emoji_route, - ) app.router.add_get("/subscription_webapp.{asset_hash:[0-9a-f]{8}}.css", css_asset_route) app.router.add_get("/subscription_webapp.css", css_asset_route) app.router.add_get( @@ -89,6 +85,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: "/api/account/telegram/notifications/probe", account_telegram_notifications_probe_route, ) + app.router.add_post("/api/referral/welcome-bonus/claim", referral_welcome_bonus_claim_route) app.router.add_post("/api/promo/apply", apply_promo_route) app.router.add_post("/api/trial/activate", activate_trial_route) app.router.add_get("/api/devices", devices_route) diff --git a/backend/bot/app/web/webapp/serializers.py b/backend/bot/app/web/webapp/serializers.py index 075daed..7be1ce3 100644 --- a/backend/bot/app/web/webapp/serializers.py +++ b/backend/bot/app/web/webapp/serializers.py @@ -1,6 +1,11 @@ # ruff: noqa: F401,F403,F405,I001 from ._runtime import * # noqa: F403,F405 +from bot.app.web.webapp.auth import ( + _referral_welcome_telegram_required_reason, + _trial_telegram_required_reason, + _user_has_linked_telegram, +) from config.subscription_guides_config import subscription_guides_available from config.webapp_themes_config import public_themes_catalog_payload from bot.services.telegram_notifications import ( @@ -64,20 +69,48 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A if active and local_sub else None ) - trial_available = bool( + trial_base_available = bool( settings.TRIAL_ENABLED and settings.TRIAL_DURATION_DAYS > 0 and not await subscription_service.has_trial_blocking_subscription(session, user_id) ) + trial_telegram_required_reason = ( + _trial_telegram_required_reason(settings, db_user) if trial_base_available else None + ) + trial_available = bool(trial_base_available and not trial_telegram_required_reason) + lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE) + plans_payload = _serialize_plans( + settings, + lang, + subscription_options=cached["subscription_options"], + stars_subscription_options=cached["stars_subscription_options"], + traffic_packages=cached["traffic_packages"], + stars_traffic_packages=cached["stars_traffic_packages"], + ) + await _attach_hwid_renewal_quotes_to_plans( + session, + subscription_service, + user_id=user_id, + settings=settings, + active=active, + local_sub=local_sub, + plans=plans_payload, + ) avatar = await _ensure_cached_telegram_avatar(request, session, db_user) try: await session.commit() except Exception: await session.rollback() - lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE) admin_ids = {int(x) for x in (settings.ADMIN_IDS or [])} is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids) + telegram_linked = _user_has_linked_telegram(db_user) + referral_welcome_days = max(0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0)) + referral_welcome_telegram_required_reason = ( + _referral_welcome_telegram_required_reason(settings, db_user) + if db_user.referred_by_id and not active and referral_welcome_days > 0 + else None + ) telegram_notifications_status = normalize_telegram_notification_status( getattr(db_user, "telegram_notifications_status", None) ) @@ -94,7 +127,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A db_user.email and db_user.email_verified_at and db_user.password_hash ), "telegram_id": db_user.telegram_id, - "telegram_linked": bool(_telegram_id_for_user(db_user)), + "telegram_linked": telegram_linked, "telegram_notifications_status": telegram_notifications_status, "telegram_notifications_enabled": ( telegram_notifications_status == TELEGRAM_NOTIFICATIONS_ENABLED @@ -120,22 +153,20 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A "webapp_link": webapp_referral_link, "invited_count": referral_stats.get("invited_count", 0), "purchased_count": referral_stats.get("purchased_count", 0), - "welcome_bonus_days": max( - 0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0) + "welcome_bonus_days": referral_welcome_days, + "welcome_bonus_without_telegram_enabled": bool( + getattr(settings, "REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED", True) ), + "welcome_bonus_requires_telegram": bool( + referral_welcome_telegram_required_reason and not telegram_linked + ), + "welcome_bonus_block_reason": referral_welcome_telegram_required_reason, "one_bonus_per_referee": bool( getattr(settings, "REFERRAL_ONE_BONUS_PER_REFEREE", False) ), "bonus_details": _serialize_referral_bonus_details(settings, lang), }, - "plans": _serialize_plans( - settings, - lang, - subscription_options=cached["subscription_options"], - stars_subscription_options=cached["stars_subscription_options"], - traffic_packages=cached["traffic_packages"], - stars_traffic_packages=cached["stars_traffic_packages"], - ), + "plans": plans_payload, "payment_methods": _serialize_payment_methods( settings, request.app, @@ -164,6 +195,11 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A ), "trial_enabled": bool(settings.TRIAL_ENABLED), "trial_available": trial_available, + "trial_without_telegram_enabled": bool( + getattr(settings, "TRIAL_WITHOUT_TELEGRAM_ENABLED", True) + ), + "trial_requires_telegram": bool(trial_telegram_required_reason and not telegram_linked), + "trial_block_reason": trial_telegram_required_reason, "trial_duration_days": int(settings.TRIAL_DURATION_DAYS or 0), "trial_traffic_limit_gb": float(settings.TRIAL_TRAFFIC_LIMIT_GB or 0), "trial_traffic_strategy": getattr(settings, "TRIAL_TRAFFIC_STRATEGY", "NO_RESET"), @@ -342,11 +378,12 @@ def _serialize_subscription( and tariff.premium_topup_packages.has_any() ) can_topup_traffic = bool(can_topup_regular_traffic or can_topup_premium_traffic) - # max_devices == 0 means unlimited — top-up is pointless in that case. + max_devices = _coerce_int_or_none(active.get("max_devices")) + # max_devices == 0 or None means unlimited — top-up is pointless in that case. can_topup_devices = bool( tariff.billing_model == "period" and tariff.has_hwid_device_packages() - and _coerce_int_or_none(active.get("max_devices")) != 0 + and max_devices not in (None, 0) ) except Exception: can_topup_regular_traffic = False @@ -437,6 +474,102 @@ def _serialize_subscription( } +def _webapp_iso_datetime(value: Optional[Any]) -> Optional[str]: + if not value: + return None + if isinstance(value, datetime): + normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc) + return normalized.isoformat() + return str(value) + + +def _webapp_datetime_text(value: Optional[Any]) -> Optional[str]: + if not value: + return None + if isinstance(value, datetime): + normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc) + return normalized.strftime("%d.%m.%Y %H:%M") + return str(value) + + +async def _attach_hwid_renewal_quotes_to_plans( + session: AsyncSession, + subscription_service: SubscriptionService, + *, + user_id: int, + settings: Settings, + active: Optional[Dict[str, Any]], + local_sub: Optional[Any], + plans: List[Dict[str, Any]], +) -> None: + quote_method = getattr(subscription_service, "quote_hwid_device_renewal_for_subscription", None) + if not callable(quote_method): + return + if not active or not local_sub or not settings.tariffs_config: + return + if not active.get("end_date") or int(active.get("extra_hwid_devices") or 0) <= 0: + return + + default_currency = default_currency_key_for_settings(settings) + default_currency_code = payment_currency_code(default_currency) + for plan in plans: + if str(plan.get("sale_mode") or "subscription") != "subscription": + continue + target_tariff_key = str(plan.get("tariff_key") or "").strip() + if not target_tariff_key: + continue + try: + months = int(plan.get("months") or 0) + except (TypeError, ValueError): + continue + if months <= 0: + continue + try: + currency_quote = await quote_method( + session, + user_id=user_id, + target_tariff_key=target_tariff_key, + months=months, + currency=default_currency, + ) + stars_quote = await quote_method( + session, + user_id=user_id, + target_tariff_key=target_tariff_key, + months=months, + currency="stars", + ) + except Exception: + logger.exception( + "Failed to quote HWID renewal for plan %s/%s", + target_tariff_key, + months, + ) + continue + quote = currency_quote or stars_quote + if not quote: + continue + valid_from = quote.get("valid_from") + valid_until = quote.get("valid_until") + active_until = quote.get("active_until") + renewal = { + "available": True, + "device_count": int(quote.get("device_count") or 0), + "price": float(currency_quote.get("price") if currency_quote else 0), + "currency": default_currency_code, + "valid_from": _webapp_iso_datetime(valid_from), + "valid_from_text": _webapp_datetime_text(valid_from), + "valid_until": _webapp_iso_datetime(valid_until), + "valid_until_text": _webapp_datetime_text(valid_until), + "active_until": _webapp_iso_datetime(active_until), + "active_until_text": _webapp_datetime_text(active_until), + "pricing_period_months": int(quote.get("pricing_period_months") or months), + } + if stars_quote and int(stars_quote.get("price") or 0) > 0: + renewal["stars_price"] = int(stars_quote["price"]) + plan["hwid_renewal"] = renewal + + def _build_install_share_link( request: Optional[web.Request], settings: Settings, @@ -495,7 +628,10 @@ def _serialize_plans( else [], } if tariff.billing_model == "period": - for months in sorted(tariff.enabled_periods): + # Render periods in the configured order (enabled_periods is the + # source of truth for purchase-period ordering, matching the bot + # keyboards). Do not sort so admins can reorder via drag & drop. + for months in tariff.enabled_periods: price = tariff.period_price(int(months), default_currency) stars_price = tariff.period_price(int(months), "stars") if price is None and (stars_price is None or int(stars_price) <= 0): @@ -528,7 +664,14 @@ def _serialize_plans( tariff.traffic_packages.stars if tariff.traffic_packages else [] ) } - for traffic_gb in sorted(set(currency_packages) | set(stars_packages)): + # Preserve the configured package order (default-currency list first, + # then any Stars-only volumes) so admins can reorder via drag & drop. + # Matches the bot keyboard, which iterates the package list as-is. + ordered_gb: List[float] = [] + for traffic_gb in list(currency_packages) + list(stars_packages): + if traffic_gb not in ordered_gb: + ordered_gb.append(traffic_gb) + for traffic_gb in ordered_gb: price = currency_packages.get(traffic_gb) stars_price = stars_packages.get(traffic_gb) if price is None and (stars_price is None or int(stars_price) <= 0): @@ -800,13 +943,15 @@ def _serialize_payment_methods( and spec.is_usable_for_payment_currency(settings, payment_currency) ): presentation = resolve_provider_presentation(spec, settings, language=lang) - methods.append( - { - "id": method, - "name": presentation.webapp_label, - "icon": presentation.webapp_icon, - } - ) + payload = { + "id": method, + "name": presentation.webapp_label, + "icon": presentation.webapp_icon, + } + minimum = spec.payment_minimum(settings, payment_currency) + if minimum: + payload.update(minimum) + methods.append(payload) return methods diff --git a/backend/bot/handlers/admin/user_management.py b/backend/bot/handlers/admin/user_management.py index de463bf..74e879c 100644 --- a/backend/bot/handlers/admin/user_management.py +++ b/backend/bot/handlers/admin/user_management.py @@ -236,6 +236,10 @@ def get_user_card_keyboard( text=_(key="admin_user_traffic_grant_button"), callback_data=f"user_action:traffic_grant:{user_id}", ) + builder.button( + text=_(key="admin_user_hwid_limit_button"), + callback_data=f"user_action:hwid_limit:{user_id}", + ) # Row 4: Quick links — only for users with a real Telegram profile # (synthetic email-only users have a negative user_id with no tg profile). @@ -261,9 +265,9 @@ def get_user_card_keyboard( quick_links_count = (1 if has_self_link else 0) + (1 if has_referrer_link else 0) if quick_links_count == 0: - builder.adjust(2, 2, 2, 1, 2, 1, 2) + builder.adjust(2, 2, 2, 1, 3, 1, 2) else: - builder.adjust(2, 2, 2, 1, 2, quick_links_count, 1, 2) + builder.adjust(2, 2, 2, 1, 3, quick_links_count, 1, 2) return builder @@ -403,6 +407,26 @@ async def format_user_card( f"{_('admin_user_traffic_label')} {hcode(f'{used_display} / {limit_display}')}" ) + max_devices = subscription_details.get("max_devices") + extra_hwid_devices = int(subscription_details.get("extra_hwid_devices") or 0) + if max_devices is not None: + if int(max_devices) == 0: + devices_display = _("admin_hwid_limit_state_unlimited") + elif extra_hwid_devices > 0: + base_hwid_limit = subscription_details.get("base_hwid_device_limit") + if base_hwid_limit is None: + devices_display = _("admin_hwid_limit_state_count", count=int(max_devices)) + else: + devices_display = _( + "admin_hwid_limit_state_with_extra", + total=int(max_devices), + base=int(base_hwid_limit), + extra=extra_hwid_devices, + ) + else: + devices_display = _("admin_hwid_limit_state_count", count=int(max_devices)) + card_parts.append(f"{_('admin_user_hwid_limit_label')} {hcode(devices_display)}") + premium_unlimited = bool(subscription_details.get("premium_unlimited_override")) premium_bonus_bytes = int(subscription_details.get("premium_bonus_bytes") or 0) if premium_unlimited: @@ -706,6 +730,32 @@ async def user_action_handler( await handle_traffic_grant_prompt(callback, state, user, "regular", i18n, current_lang) elif action == "traffic_grant_premium": await handle_traffic_grant_prompt(callback, state, user, "premium", i18n, current_lang) + elif action == "hwid_limit": + await handle_hwid_limit_menu(callback, state, user, session, i18n, current_lang) + elif action == "hwid_limit_set_unlimited": + await handle_hwid_limit_apply( + callback, + user, + subscription_service, + session, + settings, + i18n, + current_lang, + hwid_device_limit=0, + ) + elif action == "hwid_limit_reset": + await handle_hwid_limit_apply( + callback, + user, + subscription_service, + session, + settings, + i18n, + current_lang, + hwid_device_limit=None, + ) + elif action == "hwid_limit_set_number": + await handle_hwid_limit_prompt(callback, state, user, i18n, current_lang) else: await callback.answer(_("admin_unknown_action"), show_alert=True) @@ -850,6 +900,162 @@ async def handle_premium_override_bonus_prompt( await callback.answer() +def _admin_hwid_limit_state_text( + get_text: Callable[..., str], + hwid_device_limit: Optional[int], + extra_hwid_devices: int = 0, +) -> str: + if hwid_device_limit is None: + return get_text("admin_hwid_limit_state_default") + base_limit = int(hwid_device_limit) + if base_limit == 0: + return get_text("admin_hwid_limit_state_unlimited") + extra = max(0, int(extra_hwid_devices or 0)) + if extra > 0: + return get_text( + "admin_hwid_limit_state_with_extra", + total=base_limit + extra, + base=base_limit, + extra=extra, + ) + return get_text("admin_hwid_limit_state_count", count=base_limit) + + +async def handle_hwid_limit_menu( + callback: types.CallbackQuery, + state: FSMContext, + user: User, + session: AsyncSession, + i18n_instance, + lang: str, +) -> None: + """Show HWID device limit override controls.""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + + active_sub = await subscription_dal.get_active_subscription_by_user_id(session, user.user_id) + if not active_sub: + await callback.answer(_("admin_hwid_limit_no_subscription"), show_alert=True) + return + + current_text = _admin_hwid_limit_state_text( + _, + getattr(active_sub, "hwid_device_limit", None), + int(getattr(active_sub, "extra_hwid_devices", 0) or 0), + ) + text = "\n".join( + [ + f"{_('admin_hwid_limit_title')}", + "", + _("admin_hwid_limit_hint"), + "", + _("admin_hwid_limit_current", current=current_text), + ] + ) + + builder = InlineKeyboardBuilder() + builder.button( + text=_("admin_hwid_limit_btn_set_number"), + callback_data=f"user_action:hwid_limit_set_number:{user.user_id}", + ) + builder.button( + text=_("admin_hwid_limit_btn_unlimited"), + callback_data=f"user_action:hwid_limit_set_unlimited:{user.user_id}", + ) + builder.button( + text=_("admin_hwid_limit_btn_reset"), + callback_data=f"user_action:hwid_limit_reset:{user.user_id}", + ) + builder.button( + text=_("admin_user_back_to_card_button"), + callback_data=f"user_action:refresh:{user.user_id}", + ) + builder.adjust(1, 1, 1, 1) + + try: + await callback.message.edit_text(text, reply_markup=builder.as_markup(), parse_mode="HTML") + except Exception: + await callback.message.answer(text, reply_markup=builder.as_markup(), parse_mode="HTML") + await state.update_data(target_user_id=user.user_id) + await callback.answer() + + +async def handle_hwid_limit_apply( + callback: types.CallbackQuery, + user: User, + subscription_service: SubscriptionService, + session: AsyncSession, + settings: Settings, + i18n_instance, + lang: str, + *, + hwid_device_limit: Optional[int], +) -> None: + """Persist a HWID device base limit override and push it to the panel.""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + + try: + active_sub = await subscription_dal.get_active_subscription_by_user_id( + session, user.user_id + ) + if not active_sub: + await callback.answer(_("admin_hwid_limit_no_subscription"), show_alert=True) + return + + active_sub.hwid_device_limit = hwid_device_limit + + effective_limit = await subscription_service.sync_hwid_device_limit_to_panel( + session, user.user_id + ) + await message_log_dal.create_message_log_no_commit( + session, + { + "user_id": callback.from_user.id if callback.from_user else user.user_id, + "event_type": "admin:hwid_device_limit", + "content": ( + f"hwid_device_limit={hwid_device_limit!r} " + f"effective_hwid_device_limit={effective_limit!r}" + ), + "is_admin_event": True, + "target_user_id": user.user_id, + "timestamp": datetime.now(timezone.utc), + }, + ) + await session.commit() + + await callback.answer(_("admin_hwid_limit_saved"), show_alert=False) + await handle_refresh_user_card( + callback, user, subscription_service, session, settings, i18n_instance, lang + ) + except Exception as exc: + logging.error( + "Failed to apply HWID device limit for user %s: %s", + user.user_id, + exc, + exc_info=True, + ) + await session.rollback() + await callback.answer(_("admin_hwid_limit_save_error"), show_alert=True) + + +async def handle_hwid_limit_prompt( + callback: types.CallbackQuery, + state: FSMContext, + user: User, + i18n_instance, + lang: str, +) -> None: + """Ask admin for an explicit HWID device limit.""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + await state.update_data(target_user_id=user.user_id) + await state.set_state(AdminStates.waiting_for_hwid_device_limit) + prompt = _("admin_hwid_limit_prompt", user_id=user.user_id) + try: + await callback.message.edit_text(prompt) + except Exception: + await callback.message.answer(prompt) + await callback.answer() + + async def handle_traffic_grant_menu( callback: types.CallbackQuery, user: User, @@ -1971,6 +2177,114 @@ async def process_premium_override_bonus_handler( await state.clear() +@router.message(AdminStates.waiting_for_hwid_device_limit, F.text) +async def process_hwid_device_limit_handler( + message: types.Message, + state: FSMContext, + settings: Settings, + i18n_data: dict, + subscription_service: SubscriptionService, + session: AsyncSession, +): + """Read explicit HWID device limit and apply it.""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + data = await state.get_data() + target_user_id = data.get("target_user_id") + if not target_user_id: + await message.answer(_("admin_hwid_limit_state_missing")) + await state.clear() + return + + raw = (message.text or "").strip() + try: + hwid_device_limit = int(raw) + if hwid_device_limit < 0 or hwid_device_limit > 1_000_000: + raise ValueError("out_of_range") + except (TypeError, ValueError): + await message.answer(_("admin_hwid_limit_invalid")) + return + + target_user = await user_dal.get_user_by_id(session, target_user_id) + if not target_user: + await message.answer(_("admin_user_not_found_action")) + await state.clear() + return + + try: + active_sub = await subscription_dal.get_active_subscription_by_user_id( + session, target_user_id + ) + if not active_sub: + await message.answer(_("admin_hwid_limit_no_subscription")) + await state.clear() + return + + active_sub.hwid_device_limit = hwid_device_limit + effective_limit = await subscription_service.sync_hwid_device_limit_to_panel( + session, target_user_id + ) + await message_log_dal.create_message_log_no_commit( + session, + { + "user_id": message.from_user.id if message.from_user else target_user_id, + "event_type": "admin:hwid_device_limit", + "content": ( + f"hwid_device_limit={hwid_device_limit!r} " + f"effective_hwid_device_limit={effective_limit!r}" + ), + "is_admin_event": True, + "target_user_id": target_user_id, + "timestamp": datetime.now(timezone.utc), + }, + ) + await session.commit() + + current_text = _admin_hwid_limit_state_text(_, hwid_device_limit) + await message.answer( + _("admin_hwid_limit_set", current=current_text, user_id=target_user_id) + ) + + referral_service = ReferralService(settings, subscription_service, message.bot, i18n) + bot_username = await _resolve_bot_username(message.bot) + user_card_text = await format_user_card( + target_user, + session, + subscription_service, + i18n, + current_lang, + referral_service, + settings=settings, + bot_username=bot_username, + ) + keyboard = get_user_card_keyboard( + target_user.user_id, i18n, current_lang, target_user.referred_by_id + ) + await _send_with_profile_link_fallback( + message.answer, + text=user_card_text, + markup=keyboard.as_markup(), + user_id=target_user.user_id, + parse_mode="HTML", + ) + except Exception as exc: + logging.error( + "Error setting HWID device limit for user %s: %s", + target_user_id, + exc, + exc_info=True, + ) + await session.rollback() + await message.answer(_("admin_hwid_limit_save_error")) + finally: + await state.clear() + + @router.message(AdminStates.waiting_for_traffic_grant_gb, F.text) async def process_traffic_grant_gb_handler( message: types.Message, diff --git a/backend/bot/handlers/user/start.py b/backend/bot/handlers/user/start.py index 44cc598..afbb4c9 100644 --- a/backend/bot/handlers/user/start.py +++ b/backend/bot/handlers/user/start.py @@ -27,6 +27,7 @@ from bot.utils.callback_answer import safe_answer_callback from bot.utils.channel_subscription import ( is_required_channel_access_error, normalize_required_channel_id, + resolve_required_channel_link, ) from bot.utils.install_links import ( append_install_share_link_text, @@ -40,6 +41,67 @@ from db.models import User router = Router(name="user_start_router") +def _remnashop_referral_compat_enabled(settings: Settings) -> bool: + return bool(getattr(settings, "MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED", False)) + + +def _referral_code_lookup_candidates( + raw_ref_value: str, + *, + remnashop_compat: bool, +) -> list[str]: + value = str(raw_ref_value or "").strip() + if not value: + return [] + + candidates = [value] + if value and value[0].lower() == "u": + stripped_current_prefix = value[1:] + if remnashop_compat: + candidates.append(stripped_current_prefix) + else: + candidates = [stripped_current_prefix] + + unique: list[str] = [] + for candidate in candidates: + candidate = candidate.strip() + if candidate and candidate not in unique: + unique.append(candidate) + return unique + + +async def _resolve_referrer_from_start_ref( + session: AsyncSession, + raw_ref_value: str, + *, + settings: Settings, + current_user_id: int, +) -> Optional[int]: + ref_user: Optional[User] = None + if raw_ref_value.isdigit() and settings.LEGACY_REFS: + potential_referrer_id = int(raw_ref_value) + if potential_referrer_id != current_user_id: + ref_user = await user_dal.get_user_by_id(session, potential_referrer_id) + + include_legacy = _remnashop_referral_compat_enabled(settings) + if not ref_user: + for code in _referral_code_lookup_candidates( + raw_ref_value, + remnashop_compat=include_legacy, + ): + ref_user = await user_dal.get_user_by_referral_code( + session, + code, + include_legacy=include_legacy, + ) + if ref_user: + break + + if ref_user and ref_user.user_id != current_user_id: + return int(ref_user.user_id) + return None + + async def should_show_trial_button( settings: Settings, subscription_service: SubscriptionService, @@ -376,11 +438,12 @@ async def ensure_required_channel_subscription( ) return True - keyboard = ( - get_channel_subscription_keyboard(current_lang, i18n, settings.REQUIRED_CHANNEL_LINK) - if i18n - else None + channel_link = await resolve_required_channel_link( + bot_instance, + required_channel_id, + settings.REQUIRED_CHANNEL_LINK, ) + keyboard = get_channel_subscription_keyboard(current_lang, i18n, channel_link) if i18n else None prompt_text = translate("channel_subscription_required") @@ -410,14 +473,10 @@ async def ensure_required_channel_subscription( @router.message(CommandStart()) +@router.message(CommandStart(magic=F.args.regexp(r"^ref_([A-Za-z0-9_-]{1,64})$").as_("ref_match"))) @router.message( - CommandStart( - magic=F.args.regexp(r"^ref_((?:[uU][A-Za-z0-9]{9})|(?:[A-Za-z0-9]{9})|\d+)$").as_( - "ref_match" - ) - ) + CommandStart(magic=F.args.regexp(r"^promo_([A-Za-z0-9_-]{1,100})$").as_("promo_match")) ) -@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"^ticket_(\d+)$").as_("ticket_match"))) @router.message(CommandStart(magic=F.args.regexp(r"^notifications$").as_("notifications_match"))) @@ -534,22 +593,12 @@ async def start_command_handler( if ref_match: raw_ref_value = ref_match.group(1) - if raw_ref_value.isdigit(): - if settings.LEGACY_REFS: - potential_referrer_id = int(raw_ref_value) - if potential_referrer_id != user_id and await user_dal.get_user_by_id( - session, potential_referrer_id - ): - referred_by_user_id = potential_referrer_id - else: - normalized_code = raw_ref_value.strip() - if normalized_code and normalized_code[0].lower() == "u": - normalized_code = normalized_code[1:] - ref_user = None - if normalized_code: - ref_user = await user_dal.get_user_by_referral_code(session, normalized_code) - if ref_user and ref_user.user_id != user_id: - referred_by_user_id = ref_user.user_id + referred_by_user_id = await _resolve_referrer_from_start_ref( + session, + raw_ref_value, + settings=settings, + current_user_id=user_id, + ) elif promo_match: promo_code_to_apply = promo_match.group(1) logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}") @@ -609,12 +658,17 @@ async def start_command_handler( ) if referred_by_user_id and referral_welcome_days > 0: try: + default_tariff_key = None + tariffs_config = getattr(settings, "tariffs_config", None) + if tariffs_config: + default_tariff_key = getattr(tariffs_config, "default_tariff", None) referral_bonus_end_date = ( await subscription_service.extend_active_subscription_days( session, user_id, referral_welcome_days, reason="referral_welcome_bonus", + tariff_key=default_tariff_key, ) ) if referral_bonus_end_date: @@ -1135,7 +1189,7 @@ async def main_action_callback_handler( _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key privacy_url = settings.PRIVACY_POLICY_URL - user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL + user_agreement_url = settings.USER_AGREEMENT_URL if not privacy_url and not user_agreement_url: await safe_answer_callback( diff --git a/backend/bot/handlers/user/subscription/core.py b/backend/bot/handlers/user/subscription/core.py index d9b3e2f..bdf5534 100644 --- a/backend/bot/handlers/user/subscription/core.py +++ b/backend/bot/handlers/user/subscription/core.py @@ -22,7 +22,7 @@ from bot.keyboards.inline.user_keyboards import ( get_tariff_packages_keyboard, get_tariff_periods_keyboard, sale_mode_with_callback_context, - subscription_options_callback, + tariff_purchase_back_callback, ) from bot.middlewares.i18n import JsonI18n from bot.services.panel_api_service import PanelApiService @@ -303,7 +303,7 @@ async def select_tariff_callback( current_lang, i18n, settings, - back_callback=subscription_options_callback(callback_context), + back_callback=tariff_purchase_back_callback(callback_context), callback_context=callback_context, ) text = _tariff_purchase_text(tariff, current_lang, i18n, settings) @@ -319,7 +319,11 @@ async def select_tariff_callback( @router.callback_query(F.data.startswith("tariff:period:")) async def select_tariff_period_callback( - callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession + callback: types.CallbackQuery, + i18n_data: dict, + settings: Settings, + session: AsyncSession, + subscription_service: SubscriptionService, ): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: JsonI18n = i18n_data.get("i18n_instance") @@ -333,7 +337,9 @@ async def select_tariff_period_callback( await callback.answer(get_text("error_try_again"), show_alert=True) return tariff_key, months_raw = parts[2], parts[3] - callback_context = parts[4] if len(parts) > 4 else None + callback_tokens = [part for part in parts[4:] if part] + callback_context = "bot" if "bot" in callback_tokens else None + renew_hwid_devices = "no_hwid" not in callback_tokens tariff = config.require(tariff_key) months = int(months_raw) default_currency = default_currency_key_for_settings(settings) @@ -343,6 +349,22 @@ async def select_tariff_period_callback( if price_rub is None: await callback.answer(get_text("error_try_again"), show_alert=True) return + hwid_renewal_quote = await subscription_service.quote_hwid_device_renewal_for_subscription( + session, + user_id=callback.from_user.id, + target_tariff_key=tariff.key, + months=months, + currency=default_currency, + ) + hwid_renewal_stars_quote = ( + await subscription_service.quote_hwid_device_renewal_for_subscription( + session, + user_id=callback.from_user.id, + target_tariff_key=tariff.key, + months=months, + currency="stars", + ) + ) markup = get_payment_method_keyboard( months, price_rub, @@ -354,6 +376,9 @@ async def select_tariff_period_callback( sale_mode=sale_mode_with_callback_context(f"subscription@{tariff.key}", callback_context), back_callback=f"tariff:select:{tariff.key}{callback_suffix_for_context(callback_context)}", user_id=callback.from_user.id, + hwid_renewal_quote=hwid_renewal_quote, + hwid_renewal_stars_quote=hwid_renewal_stars_quote, + hwid_renewal_selected=bool(renew_hwid_devices), ) await callback.message.edit_text(get_text("choose_payment_method"), reply_markup=markup) await callback.answer() @@ -577,7 +602,6 @@ async def hwid_devices_list_callback( if not packages: await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True) return - renewal_available = bool(active.get("device_topup_renewal_available")) markup = get_hwid_device_packages_keyboard( tariff, packages, @@ -585,14 +609,11 @@ async def hwid_devices_list_callback( i18n, settings, back_callback="main_action:my_devices", - renewal=renewal_available, - ) - text_key = ( - "select_hwid_device_renewal_package" if renewal_available else "select_hwid_device_package" + renewal=False, ) await callback.message.edit_text( get_text( - text_key, + "select_hwid_device_package", date=active.get("extra_hwid_devices_valid_until_text") or "", ), reply_markup=markup, @@ -640,6 +661,7 @@ async def hwid_devices_package_callback( await callback.answer(get_text("error_try_again"), show_alert=True) return sale_mode_base = "hwid_devices_renewal" if action == "renewal_package" else "hwid_devices" + renewal = action == "renewal_package" default_currency = default_currency_key_for_settings(settings) currency_code = default_payment_currency_code_for_settings(settings) currency_quote = await subscription_service.quote_hwid_device_topup( @@ -647,7 +669,7 @@ async def hwid_devices_package_callback( user_id=callback.from_user.id, device_count=count, tariff_key=tariff.key, - renewal=action == "renewal_package", + renewal=renewal, currency=default_currency, ) stars_quote = await subscription_service.quote_hwid_device_topup( @@ -655,7 +677,7 @@ async def hwid_devices_package_callback( user_id=callback.from_user.id, device_count=count, tariff_key=tariff.key, - renewal=action == "renewal_package", + renewal=renewal, currency="stars", ) if not currency_quote and not stars_quote: diff --git a/backend/bot/keyboards/inline/user_keyboards.py b/backend/bot/keyboards/inline/user_keyboards.py index 6bd5fde..41a5bf7 100644 --- a/backend/bot/keyboards/inline/user_keyboards.py +++ b/backend/bot/keyboards/inline/user_keyboards.py @@ -4,6 +4,7 @@ from aiogram.types import InlineKeyboardMarkup, WebAppInfo from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton from bot.middlewares.i18n import locale_language_options +from bot.utils.channel_subscription import normalize_required_channel_link from bot.utils.install_links import bot_install_guide_url from bot.utils.mini_app_url import subscription_mini_app_trial_url from config.settings import Settings @@ -13,6 +14,13 @@ from config.tariffs_config import ( ) BOT_MENU_CONTEXT = "bot" +HWID_RENEWAL_TOKEN = "hwid_renewal" + + +def sale_mode_tokens(sale_mode: Optional[str]) -> Tuple[str, ...]: + if not sale_mode or "|" not in sale_mode: + return () + return tuple(token.strip() for token in str(sale_mode).split("|")[1:] if token.strip()) def callback_context_from_back_callback(back_callback: Optional[str]) -> Optional[str]: @@ -23,16 +31,36 @@ def callback_context_from_back_callback(back_callback: Optional[str]) -> Optiona def sale_mode_with_callback_context(sale_mode: str, context: Optional[str]) -> str: sale_mode = sale_mode or "subscription" - if not context or "|" in sale_mode: + if not context or context in sale_mode_tokens(sale_mode): return sale_mode return f"{sale_mode}|{context}" +def sale_mode_with_token(sale_mode: str, token: str) -> str: + sale_mode = sale_mode or "subscription" + token = str(token or "").strip() + if not token or token in sale_mode_tokens(sale_mode): + return sale_mode + return f"{sale_mode}|{token}" + + +def sale_mode_without_token(sale_mode: str, token: str) -> str: + sale_mode = sale_mode or "subscription" + token = str(token or "").strip() + if not token or "|" not in sale_mode: + return sale_mode + base, *tokens = sale_mode.split("|") + kept = [item for item in tokens if item.strip() and item.strip() != token] + return "|".join([base, *kept]) + + +def sale_mode_has_token(sale_mode: Optional[str], token: str) -> bool: + return str(token or "").strip() in sale_mode_tokens(sale_mode) + + def callback_context_from_sale_mode(sale_mode: Optional[str]) -> Optional[str]: - if not sale_mode or "|" not in sale_mode: - return None - context = str(sale_mode).split("|", 1)[1].strip() - return context or None + tokens = sale_mode_tokens(sale_mode) + return BOT_MENU_CONTEXT if BOT_MENU_CONTEXT in tokens else None def callback_suffix_for_context(context: Optional[str]) -> str: @@ -43,6 +71,12 @@ def subscription_options_callback(context: Optional[str]) -> str: return "main_action:bot_subscribe" if context == BOT_MENU_CONTEXT else "main_action:subscribe" +def tariff_purchase_back_callback(context: Optional[str]) -> str: + if context == BOT_MENU_CONTEXT: + return "main_action:bot_interface" + return subscription_options_callback(context) + + def payment_methods_back_callback( value: str, sale_mode: str = "subscription", price: Optional[float] = None ) -> str: @@ -138,8 +172,7 @@ def get_main_menu_inline_keyboard( InlineKeyboardButton(text=_(key="menu_support_button"), url=settings.SUPPORT_LINK) ) - user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL - if settings.PRIVACY_POLICY_URL or user_agreement_url: + if settings.PRIVACY_POLICY_URL or settings.USER_AGREEMENT_URL: builder.row( InlineKeyboardButton(text=_(key="menu_info_button"), callback_data="main_action:info") ) @@ -206,8 +239,7 @@ def get_bot_interface_inline_keyboard( InlineKeyboardButton(text=_(key="menu_support_button"), url=settings.SUPPORT_LINK) ) - user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL - if settings.PRIVACY_POLICY_URL or user_agreement_url: + if settings.PRIVACY_POLICY_URL or settings.USER_AGREEMENT_URL: builder.row( InlineKeyboardButton( text=_(key="menu_info_button"), callback_data="main_action:bot_info" @@ -483,6 +515,9 @@ def get_payment_method_keyboard( back_callback: Optional[str] = None, user_id: Optional[int] = None, is_admin: Optional[bool] = None, + hwid_renewal_quote: Optional[Dict[str, Any]] = None, + hwid_renewal_stars_quote: Optional[Dict[str, Any]] = None, + hwid_renewal_selected: bool = True, ) -> InlineKeyboardMarkup: _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() @@ -491,12 +526,39 @@ def get_payment_method_keyboard( return str(int(val)) if float(val).is_integer() else f"{val:g}" value_str = _format_value(months) - import logging as _kbd_logging - - _kbd_logging.info( - "payment_method_keyboard build: order=%s", - settings.payment_methods_order, - ) + payment_sale_mode = sale_mode + selected_hwid_quote = hwid_renewal_quote or hwid_renewal_stars_quote + if selected_hwid_quote: + tariff_key = None + sale_mode_main = str(sale_mode or "").split("|", 1)[0] + if "@" in sale_mode_main: + tariff_key = sale_mode_main.split("@", 1)[1] + context = callback_context_from_sale_mode(sale_mode) + toggle_tokens = [f"tariff:period:{tariff_key}:{value_str}"] + if context: + toggle_tokens.append(context) + toggle_tokens.append("no_hwid" if hwid_renewal_selected else "hwid") + builder.row( + InlineKeyboardButton( + text=_( + "payment_hwid_renewal_toggle_on" + if hwid_renewal_selected + else "payment_hwid_renewal_toggle_off", + count=int(selected_hwid_quote.get("device_count") or 0), + price=( + hwid_renewal_quote.get("price") + if hwid_renewal_quote + else hwid_renewal_stars_quote.get("price") + ), + currency_symbol=currency_symbol_val, + ), + callback_data=":".join(toggle_tokens), + ) + ) + if hwid_renewal_selected: + payment_sale_mode = sale_mode_with_token(sale_mode, HWID_RENEWAL_TOKEN) + else: + payment_sale_mode = sale_mode_without_token(sale_mode, HWID_RENEWAL_TOKEN) from bot.payment_providers import get_provider_spec, provider_telegram_button_text for method in settings.payment_methods_order: @@ -504,7 +566,7 @@ def get_payment_method_keyboard( if ( not spec or not spec.callback_prefix - or not spec.is_usable_for_payment_currency(settings, currency_symbol_val) + or not spec.is_usable_for_payment(settings, currency_symbol_val, price) or not spec.is_available_to_user( settings, user_id=user_id, @@ -517,7 +579,7 @@ def get_payment_method_keyboard( value=value_str, rub_price=price, stars_price=stars_price, - sale_mode=sale_mode, + sale_mode=payment_sale_mode, ) if not callback_data: continue @@ -576,7 +638,7 @@ def get_yk_autopay_choice_keyboard( builder.row( InlineKeyboardButton( text=_(key="yookassa_autopay_pay_saved_card_button"), - callback_data=f"pay_yk_saved_list:{value_str}:{price_str}{suffix}", + callback_data=f"pay_yk_saved_list:{value_str}:{price_str}:0{suffix}", ) ) builder.row( @@ -718,10 +780,11 @@ def get_channel_subscription_keyboard( has_buttons = False - if channel_link: + channel_url = normalize_required_channel_link(channel_link) + if channel_url: builder.button( text=_(key="channel_subscription_join_button"), - url=channel_link, + url=channel_url, ) has_buttons = True diff --git a/backend/bot/main_bot.py b/backend/bot/main_bot.py index 987416d..e21a848 100644 --- a/backend/bot/main_bot.py +++ b/backend/bot/main_bot.py @@ -91,12 +91,9 @@ async def register_all_routers(dp: Dispatcher, settings: Settings): logging.info("All application routers registered.") -async def on_startup_configured(dispatcher: Dispatcher): +async def configure_telegram_webhook(dispatcher: Dispatcher) -> None: bot: Bot = dispatcher["bot_instance"] settings: Settings = dispatcher["settings"] - i18n_instance: JsonI18n = dispatcher["i18n_instance"] - - logging.info("STARTUP: on_startup_configured executing...") telegram_webhook_url_to_set = settings.WEBHOOK_BASE_URL if telegram_webhook_url_to_set: @@ -152,6 +149,14 @@ async def on_startup_configured(dispatcher: Dispatcher): ) raise SystemExit("WEBHOOK_BASE_URL is required. Polling mode is disabled.") + +async def on_startup_configured(dispatcher: Dispatcher): + bot: Bot = dispatcher["bot_instance"] + settings: Settings = dispatcher["settings"] + i18n_instance: JsonI18n = dispatcher["i18n_instance"] + + logging.info("STARTUP: on_startup_configured executing...") + if settings.SUBSCRIPTION_MINI_APP_URL: async def _configure_mini_app_menu() -> None: @@ -331,8 +336,17 @@ async def run_bot(settings_param: Settings): _yk_path, ) + async def _after_webhooks_started() -> None: + await configure_telegram_webhook(dp) + async def web_server_task(): - await build_and_start_web_app(dp, bot, settings_param, local_async_session_factory) + await build_and_start_web_app( + dp, + bot, + settings_param, + local_async_session_factory, + after_webhooks_started=_after_webhooks_started, + ) main_tasks = [asyncio.create_task(web_server_task(), name="AIOHTTPServerTask")] diff --git a/backend/bot/middlewares/action_logger_middleware.py b/backend/bot/middlewares/action_logger_middleware.py index a104944..6317fe2 100644 --- a/backend/bot/middlewares/action_logger_middleware.py +++ b/backend/bot/middlewares/action_logger_middleware.py @@ -24,6 +24,9 @@ class ActionLoggerMiddleware(BaseMiddleware): result = await handler(event, data) + if data.get("skip_action_log") or data.get("antiflood_dropped"): + return result + session: AsyncSession = data["session"] event_user: Optional[User] = data.get("event_from_user") diff --git a/backend/bot/middlewares/channel_subscription.py b/backend/bot/middlewares/channel_subscription.py index 0c760ce..f63e81f 100644 --- a/backend/bot/middlewares/channel_subscription.py +++ b/backend/bot/middlewares/channel_subscription.py @@ -11,7 +11,10 @@ from sqlalchemy.ext.asyncio import AsyncSession from bot.keyboards.inline.user_keyboards import get_channel_subscription_keyboard from bot.middlewares.i18n import JsonI18n -from bot.utils.channel_subscription import normalize_required_channel_id +from bot.utils.channel_subscription import ( + normalize_required_channel_id, + resolve_required_channel_link, +) from config.settings import Settings from db.dal import user_dal @@ -86,10 +89,14 @@ class ChannelSubscriptionMiddleware(BaseMiddleware): return i18n_instance.gettext(current_lang, key) return key + bot_instance = data.get("bot") or data.get("bot_instance") + channel_link = await resolve_required_channel_link( + bot_instance, + required_channel_id, + self.settings.REQUIRED_CHANNEL_LINK, + ) keyboard = ( - get_channel_subscription_keyboard( - current_lang, i18n_instance, self.settings.REQUIRED_CHANNEL_LINK - ) + get_channel_subscription_keyboard(current_lang, i18n_instance, channel_link) if i18n_instance else None ) diff --git a/backend/bot/middlewares/update_antiflood.py b/backend/bot/middlewares/update_antiflood.py new file mode 100644 index 0000000..8df6371 --- /dev/null +++ b/backend/bot/middlewares/update_antiflood.py @@ -0,0 +1,371 @@ +import asyncio +import hashlib +import logging +import time +from collections import defaultdict, deque +from dataclasses import dataclass +from typing import Any, Awaitable, Callable, Deque, Dict, Optional + +from aiogram import BaseMiddleware +from aiogram.types import Update + +from bot.infra.redis import get_redis, redis_key +from config.settings import Settings + +logger = logging.getLogger(__name__) + +DEFAULT_WINDOW_SECONDS = 60 +DEFAULT_MAX_UPDATES_PER_WINDOW = 180 +DEFAULT_MESSAGE_MAX_PER_WINDOW = 120 +DEFAULT_CALLBACK_MAX_PER_WINDOW = 240 +DEFAULT_INLINE_MAX_PER_WINDOW = 60 +DEFAULT_START_MAX_PER_WINDOW = 30 +DEFAULT_EXPENSIVE_CALLBACK_MAX_PER_WINDOW = 60 +DEFAULT_PAYMENT_CALLBACK_COOLDOWN_SECONDS = 20 +DEFAULT_TRIAL_CALLBACK_COOLDOWN_SECONDS = 30 + +EXPENSIVE_CALLBACK_PREFIXES = ( + "pay_", + "trial_action:confirm_activate", + "main_action:request_trial", + "main_action:apply_promo", + "main_action:bot_apply_promo", + "tariff_change:apply:", + "tariff_change:confirm_pay:", + "tariff_change:pay:", + "autorenew:confirm:", + "disconnect_device:", +) + +TRIAL_CALLBACK_PREFIXES = ( + "trial_action:confirm_activate", + "main_action:request_trial", +) + + +@dataclass(frozen=True) +class RateLimitRule: + window_seconds: int + max_events: int + + +class UpdateAntiFloodMiddleware(BaseMiddleware): + """Drop extreme update floods before DB-backed middleware runs.""" + + def __init__( + self, + settings: Settings, + *, + default_rule: Optional[RateLimitRule] = None, + action_rules: Optional[Dict[str, RateLimitRule]] = None, + ) -> None: + super().__init__() + self.settings = settings + self.default_rule = default_rule or RateLimitRule( + window_seconds=int( + getattr(settings, "TELEGRAM_ANTIFLOOD_WINDOW_SECONDS", DEFAULT_WINDOW_SECONDS) + or DEFAULT_WINDOW_SECONDS + ), + max_events=int( + getattr( + settings, + "TELEGRAM_ANTIFLOOD_MAX_UPDATES_PER_WINDOW", + DEFAULT_MAX_UPDATES_PER_WINDOW, + ) + or DEFAULT_MAX_UPDATES_PER_WINDOW + ), + ) + self.action_rules = action_rules or _default_action_rules(settings) + self._local_buckets: Dict[str, Deque[float]] = defaultdict(deque) + self._local_cooldowns: Dict[str, float] = {} + self._local_lock = asyncio.Lock() + + async def __call__( + self, + handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]], + event: Update, + data: Dict[str, Any], + ) -> Any: + if bool(getattr(self.settings, "TELEGRAM_DROP_NON_PRIVATE_UPDATES", True)): + chat_type = _message_or_callback_chat_type(event) + if chat_type is not None and chat_type != "private": + logger.info( + "Telegram update dropped outside private chat: chat_type=%s update_type=%s", + chat_type, + getattr(event, "event_type", "unknown"), + ) + _mark_dropped(data) + return None + + if not bool(getattr(self.settings, "TELEGRAM_ANTIFLOOD_ENABLED", True)): + return await handler(event, data) + + actor_key = _update_actor_key(event) + if not actor_key: + return await handler(event, data) + + action_key = _update_action_key(event) + cooldown = _update_action_cooldown(event, self.settings) + if cooldown and await self._is_cooldown_active(cooldown[0], cooldown[1]): + logger.info( + "Telegram callback dropped by action cooldown: actor=%s cooldown=%s", + actor_key, + cooldown[0], + ) + _mark_dropped(data) + await _quietly_answer_callback(event) + return None + + if await self._is_limited("updates", actor_key, self.default_rule) or ( + action_key + and action_key in self.action_rules + and await self._is_limited(action_key, actor_key, self.action_rules[action_key]) + ): + logger.warning( + "Telegram update dropped by anti-flood: actor=%s update_type=%s", + actor_key, + action_key or getattr(event, "event_type", "unknown"), + ) + _mark_dropped(data) + return None + + return await handler(event, data) + + async def _is_limited(self, bucket_name: str, actor_key: str, rule: RateLimitRule) -> bool: + if rule.window_seconds <= 0 or rule.max_events <= 0: + return False + + try: + redis = await get_redis(self.settings) + if redis is not None: + key = redis_key( + self.settings, + "rate-limit", + "telegram", + bucket_name, + actor_key, + ) + current = int(await redis.incr(key)) + if current == 1: + await redis.expire(key, rule.window_seconds) + return current > rule.max_events + except Exception as exc: + logger.warning("Redis telegram anti-flood unavailable; using local fallback: %s", exc) + + return await self._is_limited_local(f"{bucket_name}:{actor_key}", rule) + + async def _is_cooldown_active(self, cooldown_key: str, ttl_seconds: int) -> bool: + if ttl_seconds <= 0: + return False + + try: + redis = await get_redis(self.settings) + if redis is not None: + key = redis_key( + self.settings, + "cooldown", + "telegram", + cooldown_key, + ) + acquired = await redis.set(key, "1", nx=True, ex=ttl_seconds) + return not bool(acquired) + except Exception as exc: + logger.warning("Redis telegram cooldown unavailable; using local fallback: %s", exc) + + return await self._is_cooldown_active_local(cooldown_key, ttl_seconds) + + async def _is_cooldown_active_local(self, cooldown_key: str, ttl_seconds: int) -> bool: + now = time.monotonic() + async with self._local_lock: + expired = [ + key for key, expires_at in self._local_cooldowns.items() if expires_at <= now + ] + for key in expired: + self._local_cooldowns.pop(key, None) + + expires_at = self._local_cooldowns.get(cooldown_key) + if expires_at and expires_at > now: + return True + + self._local_cooldowns[cooldown_key] = now + ttl_seconds + return False + + async def _is_limited_local(self, actor_key: str, rule: RateLimitRule) -> bool: + now = time.monotonic() + cutoff = now - rule.window_seconds + async with self._local_lock: + bucket = self._local_buckets[actor_key] + while bucket and bucket[0] <= cutoff: + bucket.popleft() + bucket.append(now) + if len(bucket) > rule.max_events: + return True + if not bucket: + self._local_buckets.pop(actor_key, None) + return False + + +def _update_actor_key(update: Update) -> Optional[str]: + user_id = None + chat_id = None + + if update.message: + user_id = update.message.from_user.id if update.message.from_user else None + chat_id = update.message.chat.id if update.message.chat else None + elif update.callback_query: + user_id = update.callback_query.from_user.id if update.callback_query.from_user else None + if update.callback_query.message and update.callback_query.message.chat: + chat_id = update.callback_query.message.chat.id + elif update.inline_query: + user_id = update.inline_query.from_user.id if update.inline_query.from_user else None + + if user_id is not None: + return f"user:{int(user_id)}" + if chat_id is not None: + return f"chat:{int(chat_id)}" + return None + + +def _message_or_callback_chat_type(update: Update) -> Optional[str]: + if update.message and update.message.chat: + return str(update.message.chat.type) + if ( + update.callback_query + and update.callback_query.message + and update.callback_query.message.chat + ): + return str(update.callback_query.message.chat.type) + return None + + +def _update_action_key(update: Update) -> str: + if update.message: + text = update.message.text or "" + if text.startswith("/start"): + return "start" + return "message" + if update.callback_query: + data = update.callback_query.data or "" + if data.startswith(EXPENSIVE_CALLBACK_PREFIXES): + return "expensive_callback" + return "callback" + if update.inline_query: + return "inline" + return "updates" + + +def _update_action_cooldown(update: Update, settings: Settings) -> Optional[tuple[str, int]]: + if not bool(getattr(settings, "TELEGRAM_ACTION_COOLDOWN_ENABLED", True)): + return None + if not update.callback_query or not update.callback_query.from_user: + return None + + callback_data = update.callback_query.data or "" + if not callback_data: + return None + + user_id = int(update.callback_query.from_user.id) + data_digest = hashlib.sha256(callback_data.encode("utf-8")).hexdigest()[:24] + + if callback_data.startswith("pay_"): + ttl = int( + getattr( + settings, + "TELEGRAM_PAYMENT_CALLBACK_COOLDOWN_SECONDS", + DEFAULT_PAYMENT_CALLBACK_COOLDOWN_SECONDS, + ) + or DEFAULT_PAYMENT_CALLBACK_COOLDOWN_SECONDS + ) + return f"payment:user:{user_id}:data:{data_digest}", ttl + + if callback_data.startswith(TRIAL_CALLBACK_PREFIXES): + ttl = int( + getattr( + settings, + "TELEGRAM_TRIAL_CALLBACK_COOLDOWN_SECONDS", + DEFAULT_TRIAL_CALLBACK_COOLDOWN_SECONDS, + ) + or DEFAULT_TRIAL_CALLBACK_COOLDOWN_SECONDS + ) + return f"trial:user:{user_id}:data:{data_digest}", ttl + + return None + + +async def _quietly_answer_callback(update: Update) -> None: + callback = update.callback_query + if not callback: + return + try: + await callback.answer() + except Exception: + pass + + +def _mark_dropped(data: Dict[str, Any]) -> None: + data["antiflood_dropped"] = True + data["skip_action_log"] = True + + +def _default_action_rules(settings: Settings) -> Dict[str, RateLimitRule]: + window_seconds = int( + getattr(settings, "TELEGRAM_ANTIFLOOD_WINDOW_SECONDS", DEFAULT_WINDOW_SECONDS) + or DEFAULT_WINDOW_SECONDS + ) + return { + "message": RateLimitRule( + window_seconds, + int( + getattr( + settings, + "TELEGRAM_ANTIFLOOD_MESSAGE_MAX_PER_WINDOW", + DEFAULT_MESSAGE_MAX_PER_WINDOW, + ) + or DEFAULT_MESSAGE_MAX_PER_WINDOW + ), + ), + "callback": RateLimitRule( + window_seconds, + int( + getattr( + settings, + "TELEGRAM_ANTIFLOOD_CALLBACK_MAX_PER_WINDOW", + DEFAULT_CALLBACK_MAX_PER_WINDOW, + ) + or DEFAULT_CALLBACK_MAX_PER_WINDOW + ), + ), + "inline": RateLimitRule( + window_seconds, + int( + getattr( + settings, + "TELEGRAM_ANTIFLOOD_INLINE_MAX_PER_WINDOW", + DEFAULT_INLINE_MAX_PER_WINDOW, + ) + or DEFAULT_INLINE_MAX_PER_WINDOW + ), + ), + "start": RateLimitRule( + window_seconds, + int( + getattr( + settings, + "TELEGRAM_ANTIFLOOD_START_MAX_PER_WINDOW", + DEFAULT_START_MAX_PER_WINDOW, + ) + or DEFAULT_START_MAX_PER_WINDOW + ), + ), + "expensive_callback": RateLimitRule( + window_seconds, + int( + getattr( + settings, + "TELEGRAM_ANTIFLOOD_EXPENSIVE_CALLBACK_MAX_PER_WINDOW", + DEFAULT_EXPENSIVE_CALLBACK_MAX_PER_WINDOW, + ) + or DEFAULT_EXPENSIVE_CALLBACK_MAX_PER_WINDOW + ), + ), + } diff --git a/backend/bot/payment_providers/base.py b/backend/bot/payment_providers/base.py index 88d7f4f..7562ed1 100644 --- a/backend/bot/payment_providers/base.py +++ b/backend/bot/payment_providers/base.py @@ -114,6 +114,7 @@ class WebAppPaymentContext: sale_mode: str currency: str = "RUB" traffic_gb: Optional[float] = None + hwid_device_count: Optional[int] = None hwid_valid_from: Optional[Any] = None hwid_valid_until: Optional[Any] = None hwid_pricing_period_months: Optional[int] = None @@ -126,7 +127,10 @@ ServiceFactory = Callable[[ServiceFactoryContext], Any] WebhookPathGetter = Callable[[Any], str] WebhookRoute = Callable[[Any], Awaitable[Any]] WebAppPaymentFactory = Callable[[WebAppPaymentContext], Awaitable[Any]] +ReusableWebAppPaymentResolver = Callable[[WebAppPaymentContext, Any], Awaitable[Optional[str]]] CurrencySupportResolver = Callable[[Any], Optional[Sequence[str]]] +PaymentAmountResolver = Callable[[Any, Any, Any], bool] +PaymentMinimumResolver = Callable[[Any, Any], Optional[Mapping[str, Any]]] def normalize_payment_currency_code(value: Any, default: str = "RUB") -> str: @@ -177,6 +181,7 @@ class PaymentProviderSpec: webhook_route: Optional[WebhookRoute] = None webhook_requires_base_url: bool = False create_webapp_payment: Optional[WebAppPaymentFactory] = None + reuse_webapp_payment: Optional[ReusableWebAppPaymentResolver] = None requires_configured_service: bool = True price_source: str = "rub" emoji: str = "💳" @@ -191,6 +196,8 @@ class PaymentProviderSpec: admin_only_enabled: Optional[EnabledPredicate] = None supported_currencies: Optional[Sequence[str]] = ("RUB",) supported_currencies_resolver: Optional[CurrencySupportResolver] = None + payment_amount_resolver: Optional[PaymentAmountResolver] = None + payment_minimum_resolver: Optional[PaymentMinimumResolver] = None currency_support_note: str = "" currency_support_url: Optional[str] = None @@ -305,6 +312,30 @@ class PaymentProviderSpec: return True return self.supports_currency(source, currency) + def payment_minimum(self, source: Any, currency: Any) -> Optional[Mapping[str, Any]]: + if self.payment_minimum_resolver is None: + return None + source_for_amount = self._currency_source(source) + try: + return self.payment_minimum_resolver(source_for_amount, currency) + except Exception: + return None + + def is_usable_for_payment_amount(self, source: Any, currency: Any, amount: Any) -> bool: + if self.price_source == "stars" or self.payment_amount_resolver is None: + return True + source_for_amount = self._currency_source(source) + try: + return bool(self.payment_amount_resolver(source_for_amount, currency, amount)) + except Exception: + return True + + def is_usable_for_payment(self, source: Any, currency: Any, amount: Any) -> bool: + return self.is_usable_for_payment_currency( + source, + currency, + ) and self.is_usable_for_payment_amount(source, currency, amount) + def is_visible(self, source: Any, app: Any) -> bool: return self.is_enabled(source) and self.is_service_configured(app) diff --git a/backend/bot/payment_providers/cryptopay.py b/backend/bot/payment_providers/cryptopay.py index 94457f5..4efbd18 100644 --- a/backend/bot/payment_providers/cryptopay.py +++ b/backend/bot/payment_providers/cryptopay.py @@ -192,6 +192,7 @@ class CryptoPayService: sale_mode: str = "subscription", url_kind: str = "bot", hwid_quote: Optional[dict] = None, + hwid_device_count: Optional[int] = None, currency: Optional[str] = None, ) -> Optional[str]: if not self.configured or not self.client: @@ -210,7 +211,11 @@ class CryptoPayService: return None sale_base = sale_mode_base(sale_mode) - amounts = payment_record_amounts(months=months, sale_mode=sale_mode) + amounts = payment_record_amounts( + months=months, + sale_mode=sale_mode, + hwid_device_count=hwid_device_count, + ) try: payment_record = await payment_dal.create_payment_record( session, @@ -252,6 +257,7 @@ class CryptoPayService: "payment_db_id": str(payment_record.payment_id), "sale_mode": sale_mode, "traffic_gb": str(months) if sale_mode_is_traffic(sale_mode) else None, + "hwid_devices": amounts.purchased_hwid_devices, } ) try: @@ -513,6 +519,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: } if ctx.hwid_valid_from and ctx.hwid_valid_until else None, + hwid_device_count=ctx.hwid_device_count, ) if not url: return payment_failed() diff --git a/backend/bot/payment_providers/freekassa.py b/backend/bot/payment_providers/freekassa.py index 1d830a5..b5e3a03 100644 --- a/backend/bot/payment_providers/freekassa.py +++ b/backend/bot/payment_providers/freekassa.py @@ -53,11 +53,14 @@ from .shared import ( notify_service_unavailable, parse_payment_callback, payment_failed, + payment_record_amounts, payment_unavailable, payment_units_for_activation, post_json_request, quote_hwid_callback_parts, render_link_or_fail, + render_payment_link, + safe_callback_answer, ) _LOG = "freekassa" @@ -76,7 +79,7 @@ class FreeKassaConfig(ProviderEnvConfig): MERCHANT_ID: Optional[str] = None FIRST_SECRET: Optional[str] = None SECOND_SECRET: Optional[str] = None - PAYMENT_URL: str = Field(default="https://pay.freekassa.ru/") + PAYMENT_URL: str = Field(default="https://pay.freekassa.net/") API_KEY: Optional[str] = None PAYMENT_IP: Optional[str] = None PAYMENT_METHOD_ID: Optional[int] = None @@ -153,7 +156,7 @@ class FreeKassaService(HttpClientMixin): self.default_currency: str = default_payment_currency_code_for_settings(settings).upper() self.api_base_url: str = "https://api.fk.life/v1" - self._init_http_client(total_timeout=15) + self._init_http_client(total_timeout=lambda: self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS) self._nonce_lock = asyncio.Lock() self._last_nonce = int(time.time() * 1000) @@ -253,6 +256,62 @@ class FreeKassaService(HttpClientMixin): is_success=lambda status, data: status == 200 and (data or {}).get("type") == "success", ) + async def get_orders( + self, + *, + payment_id: int, + order_status: Optional[int] = None, + ) -> Tuple[bool, Dict[str, Any]]: + if not self.configured: + return False, {"message": "service_not_configured"} + + payload: Dict[str, Any] = { + "shopId": int(self.shop_id), + "nonce": await self._generate_nonce(), + "paymentId": str(payment_id), + } + if order_status is not None: + payload["orderStatus"] = int(order_status) + payload["signature"] = self._sign_payload(payload) + + session = await self._get_session() + return await post_json_request( + session, + f"{self.api_base_url}/orders", + body=payload, + log_prefix="FreeKassa get_orders", + is_success=lambda status, data: status == 200 and (data or {}).get("type") == "success", + ) + + async def try_reuse_pending_order(self, payment: Any) -> Optional[str]: + order_hash = str(getattr(payment, "provider_payment_id", None) or "").strip() + if not order_hash: + return None + + success, response_data = await self.get_orders( + payment_id=payment.payment_id, + order_status=0, + ) + if not success: + return None + + for order in response_data.get("orders") or []: + if not isinstance(order, dict): + continue + try: + is_new = int(order.get("status", -1)) == 0 + except (TypeError, ValueError): + continue + if not is_new: + continue + if str(order.get("merchant_order_id") or "") != str(payment.payment_id): + continue + fk_order_id = str(order.get("fk_order_id") or "").strip() + if fk_order_id: + payment_url = (self.config.PAYMENT_URL or "https://pay.freekassa.net/").rstrip("/") + return f"{payment_url}/form/{fk_order_id}/{order_hash}" + return None + async def _generate_nonce(self) -> int: async with self._nonce_lock: candidate = int(time.time() * 1000) @@ -513,6 +572,39 @@ async def pay_fk_callback_handler( hwid_quote=hwid_quote, ) + reuse_amounts = payment_record_amounts( + months=parts.months, + sale_mode=parts.sale_mode, + hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None, + ) + reusable_payment = await payment_dal.find_recent_pending_provider_payment( + session, + user_id=callback.from_user.id, + provider="freekassa", + pending_status="pending_freekassa", + amount=parts.price, + currency=currency_code, + sale_mode=parts.sale_mode, + months=reuse_amounts.months, + purchased_gb=reuse_amounts.purchased_gb, + purchased_hwid_devices=reuse_amounts.purchased_hwid_devices, + tariff_key=reuse_amounts.tariff_key, + ) + if reusable_payment is not None: + reusable_url = await freekassa_service.try_reuse_pending_order(reusable_payment) + if reusable_url: + await safe_callback_answer(callback) + await render_payment_link( + callback, + translator=translator, + current_lang=current_lang, + i18n=i18n, + parts=parts, + payment_url=reusable_url, + log_prefix=_LOG, + ) + return + try: payment_record = await payment_dal.create_payment_record(session, record_payload) await session.commit() @@ -625,6 +717,14 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: ) +async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]: + service: FreeKassaService = ctx.request.app.get("freekassa_service") + if not service or not service.configured: + return None + + return await service.try_reuse_pending_order(payment) + + _PRESENTATION_MANIFEST = tuple( ProviderManifestField( key=key, @@ -723,7 +823,7 @@ _CONFIG_MANIFEST = ( "FREEKASSA_PAYMENT_URL", "url", "Payment URL", - placeholder="https://pay.freekassa.ru/", + placeholder="https://pay.freekassa.net/", subsection="FreeKassa", attr="PAYMENT_URL", ), @@ -772,6 +872,7 @@ SPEC = PaymentProviderSpec( webhook_path=lambda source: "/webhook/freekassa", webhook_route=freekassa_webhook_route, create_webapp_payment=create_webapp_payment, + reuse_webapp_payment=reuse_webapp_payment, config_class=FreeKassaConfig, presentation_class=FreeKassaPresentation, manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST, diff --git a/backend/bot/payment_providers/heleket.py b/backend/bot/payment_providers/heleket.py index 5faa786..c830409 100644 --- a/backend/bot/payment_providers/heleket.py +++ b/backend/bot/payment_providers/heleket.py @@ -3,6 +3,7 @@ import hashlib import hmac import json import logging +import time from collections import OrderedDict from typing import Any, Dict, List, Optional, Tuple @@ -54,10 +55,12 @@ from .shared import ( notify_user_payment_failed, parse_payment_callback, payment_failed, + payment_record_amounts, payment_unavailable, payment_units_for_activation, quote_hwid_callback_parts, render_link_or_fail, + render_payment_link, ) router = Router(name="user_subscription_payments_heleket_router") @@ -243,7 +246,7 @@ class HeleketService(HttpClientMixin): self.referral_service = referral_service self._default_return_url = default_return_url - self._init_http_client(total_timeout=20) + self._init_http_client(total_timeout=lambda: self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS) if not self.configured: logging.warning( "HeleketService initialized but not fully configured. Payments disabled." @@ -372,6 +375,70 @@ class HeleketService(HttpClientMixin): logging.exception("Heleket create_payment_link: request failed.") return False, {"message": str(exc)} + async def get_payment_info(self, payment_uuid: str) -> Tuple[bool, Dict[str, Any]]: + if not self.configured: + return False, {"message": "service_not_configured"} + + payment_uuid = str(payment_uuid or "").strip() + if not payment_uuid: + return False, {"message": "missing_payment_uuid"} + + body = {"uuid": payment_uuid} + headers = { + "merchant": self.merchant_id, + "sign": _compute_signature(body, self.api_key), + "Content-Type": "application/json", + } + session = await self._get_session() + try: + async with session.post( + f"{self.base_url}/v1/payment/info", + data=_serialize_for_signature(body).encode("utf-8"), + headers=headers, + ) as response: + response_data = await response.json(content_type=None) + state = response_data.get("state") if isinstance(response_data, dict) else None + if response.status != 200 or state != 0: + logging.warning( + "Heleket get_payment_info failed: uuid=%s status=%s body=%s", + payment_uuid, + response.status, + response_data, + ) + return False, {"status": response.status, "message": response_data} + result = response_data.get("result") or {} + return isinstance(result, dict), result + except Exception as exc: + logging.exception("Heleket get_payment_info request failed: uuid=%s", payment_uuid) + return False, {"message": str(exc)} + + async def try_reuse_pending_payment(self, payment: Any) -> Optional[str]: + payment_uuid = str(getattr(payment, "provider_payment_id", None) or "").strip() + if not payment_uuid: + return None + + success, data = await self.get_payment_info(payment_uuid) + if not success or not isinstance(data, dict): + return None + status = str(data.get("payment_status") or data.get("status") or "").lower() + if status != "check" or bool(data.get("is_final")): + return None + if str(data.get("uuid") or "") != payment_uuid: + return None + if str(data.get("order_id") or "") != str(payment.payment_id): + return None + try: + expired_at = int(data.get("expired_at") or 0) + except (TypeError, ValueError): + return None + if expired_at and expired_at <= int(time.time()): + return None + return ( + str(data.get("url") or "").strip() + or str(getattr(payment, "provider_payment_url", None) or "").strip() + or None + ) + def _verify_signature(self, payload: Dict[str, Any]) -> bool: received = payload.get("sign") if not isinstance(received, str) or not received: @@ -612,6 +679,38 @@ async def pay_heleket_callback_handler( hwid_quote=hwid_quote, ) + reuse_amounts = payment_record_amounts( + months=parts.months, + sale_mode=parts.sale_mode, + hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None, + ) + reusable_payment = await payment_dal.find_recent_pending_provider_payment( + session, + user_id=callback.from_user.id, + provider="heleket", + pending_status="pending_heleket", + amount=parts.price, + currency=currency_code, + sale_mode=parts.sale_mode, + months=reuse_amounts.months, + purchased_gb=reuse_amounts.purchased_gb, + purchased_hwid_devices=reuse_amounts.purchased_hwid_devices, + tariff_key=reuse_amounts.tariff_key, + ) + if reusable_payment is not None: + reusable_url = await heleket_service.try_reuse_pending_payment(reusable_payment) + if reusable_url: + await render_payment_link( + callback, + translator=translator, + current_lang=current_lang, + i18n=i18n, + parts=parts, + payment_url=reusable_url, + log_prefix=_LOG, + ) + return + try: payment_record = await payment_dal.create_payment_record(session, record_payload) await session.commit() @@ -684,6 +783,13 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: ) +async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]: + service: HeleketService = ctx.request.app.get("heleket_service") + if not service or not service.configured: + return None + return await service.try_reuse_pending_payment(payment) + + async def heleket_webhook_route(request: web.Request) -> web.Response: service: HeleketService = request.app["heleket_service"] return await service.webhook_route(request) @@ -887,6 +993,7 @@ SPEC = PaymentProviderSpec( webhook_path=lambda source: "/webhook/heleket", webhook_route=heleket_webhook_route, create_webapp_payment=create_webapp_payment, + reuse_webapp_payment=reuse_webapp_payment, emoji="🪙", config_class=HeleketConfig, presentation_class=HeleketPresentation, diff --git a/backend/bot/payment_providers/paykilla.py b/backend/bot/payment_providers/paykilla.py new file mode 100644 index 0000000..7f11b8b --- /dev/null +++ b/backend/bot/payment_providers/paykilla.py @@ -0,0 +1,1658 @@ +import hashlib +import hmac +import json +import logging +import re +import time +from datetime import datetime, timedelta, timezone +from decimal import ROUND_CEILING, Decimal, InvalidOperation +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import urlencode +from urllib.request import urlopen + +from aiogram import Bot, F, Router, types +from aiohttp import web +from pydantic import AliasChoices, Field, field_validator +from pydantic_settings import SettingsConfigDict +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import sessionmaker + +from bot.middlewares.i18n import JsonI18n +from bot.services.referral_service import ReferralService +from bot.services.subscription_service import SubscriptionService +from bot.utils.request_security import ip_in_allowlist, request_client_ip +from config.settings import Settings +from config.tariffs_config import ( + default_currency_key_for_settings, + default_payment_currency_code_for_settings, +) +from db.dal import payment_dal + +from .base import ( + PaymentProviderSpec, + ProviderEnvConfig, + ProviderManifestField, + ServiceFactoryContext, + WebAppPaymentContext, + normalize_payment_currency_code, + parse_supported_currency_codes, + provider_env_file, + provider_runtime_enabled, +) +from .shared import ( + HttpClientMixin, + PaymentSuccessRequest, + build_payment_record_payload, + create_webapp_payment_record, + decimal_amounts_equal, + describe_payment, + finalize_successful_payment, + finalize_webapp_link_payment, + first_value, + format_decimal_amount, + lookup_payment_by_order_or_provider_id, + make_translator, + notify_callback_parse_error, + notify_payment_record_failure, + notify_service_unavailable, + notify_user_payment_failed, + parse_payment_callback, + payment_failed, + payment_unavailable, + payment_units_for_activation, + quote_hwid_callback_parts, + render_link_or_fail, +) + +router = Router(name="user_subscription_payments_paykilla_router") +_LOG = "paykilla" + +PAYKILLA_DEFAULT_PAYMENT_CURRENCIES = "USDTTRC,BTC,ETH,USDTBSC,USDTTON" +PAYKILLA_DEFAULT_INVOICE_CURRENCIES = "USD,EUR" +PAYKILLA_DEFAULT_EXCHANGE_RATE_URL = "https://open.er-api.com/v6/latest/{source}" +PAYKILLA_DEFAULT_MIN_PAYMENT_AMOUNT = 10.0 +PAYKILLA_DEFAULT_MIN_PAYMENT_CURRENCY = "USD" +PAYKILLA_DEFAULT_SUPPORTED_CURRENCIES = ( + "RUB,USD,EUR,AED,GBP,BTC,ETH,TRX,TON,USDTTRC,USDTETH,USDTBSC," + "USDCETH,USDCBSC,DAIETH,DAIBSC,BNBBSC,ETHBSC,LINKETH,LINKBSC," + "USDTTON,AAVEETH,MANAETH,SHIBETH" +) +_FIAT_CURRENCIES = {"RUB", "USD", "EUR", "AED", "GBP"} +_SUCCESS_EVENTS = {"INVOICE_PAID", "PAYMENT_COMPLETED", "PAYMENT_OVERPAID"} +_FAILED_EVENTS = { + "PAYMENT_FAILED", + "PAYMENT_UNDERPAID", + "INVOICE_EXPIRED", + "INVOICE_CANCELLED", + "PAYMENT_CANCELLED", + "COMPLIANCE_FAILED", +} +_CYRILLIC_TO_LATIN = str.maketrans( + { + "А": "A", + "Б": "B", + "В": "V", + "Г": "G", + "Д": "D", + "Е": "E", + "Ё": "E", + "Ж": "Zh", + "З": "Z", + "И": "I", + "Й": "Y", + "К": "K", + "Л": "L", + "М": "M", + "Н": "N", + "О": "O", + "П": "P", + "Р": "R", + "С": "S", + "Т": "T", + "У": "U", + "Ф": "F", + "Х": "H", + "Ц": "Ts", + "Ч": "Ch", + "Ш": "Sh", + "Щ": "Sch", + "Ъ": "", + "Ы": "Y", + "Ь": "", + "Э": "E", + "Ю": "Yu", + "Я": "Ya", + "а": "a", + "б": "b", + "в": "v", + "г": "g", + "д": "d", + "е": "e", + "ё": "e", + "ж": "zh", + "з": "z", + "и": "i", + "й": "y", + "к": "k", + "л": "l", + "м": "m", + "н": "n", + "о": "o", + "п": "p", + "р": "r", + "с": "s", + "т": "t", + "у": "u", + "ф": "f", + "х": "h", + "ц": "ts", + "ч": "ch", + "ш": "sh", + "щ": "sch", + "ъ": "", + "ы": "y", + "ь": "", + "э": "e", + "ю": "yu", + "я": "ya", + } +) +_SYNC_EXCHANGE_RATE_CACHE: Dict[tuple[str, str, str], tuple[float, Decimal]] = {} + + +class PaykillaConfig(ProviderEnvConfig): + """PayKilla V2 env vars.""" + + model_config = SettingsConfigDict( + env_file=provider_env_file(), + env_file_encoding="utf-8", + env_prefix="PAYKILLA_", + extra="ignore", + populate_by_name=True, + ) + + ENABLED: bool = Field(default=False) + API_KEY: Optional[str] = Field( + default=None, + validation_alias=AliasChoices("PAYKILLA_API_KEY", "PAYKILLA_V2_API_KEY"), + ) + SECRET_KEY: Optional[str] = Field( + default=None, + validation_alias=AliasChoices("PAYKILLA_SECRET_KEY", "PAYKILLA_V2_SECRET_KEY"), + ) + BASE_URL: str = Field( + default="https://account-api.paykilla.com", + validation_alias=AliasChoices("PAYKILLA_BASE_URL", "PAYKILLA_V2_BASE_URL"), + ) + WIDGET_URL: str = Field(default="https://gopay.paykilla.com") + CURRENCY: str = Field(default="USD") + INVOICE_CURRENCIES: str = Field(default=PAYKILLA_DEFAULT_INVOICE_CURRENCIES) + INVOICE_TYPE: Optional[str] = None + PAYMENT_CURRENCIES: str = Field(default=PAYKILLA_DEFAULT_PAYMENT_CURRENCIES) + SUPPORTED_CURRENCIES: str = Field(default=PAYKILLA_DEFAULT_SUPPORTED_CURRENCIES) + LIFETIME_SECONDS: int = Field(default=3600) + RECV_WINDOW_MS: int = Field(default=5000) + USER_PAYS_SERVICE_FEE: bool = Field(default=True) + USER_PAYS_NETWORK_FEE: bool = Field(default=True) + EXCHANGE_RATE_URL: str = Field(default=PAYKILLA_DEFAULT_EXCHANGE_RATE_URL) + EXCHANGE_RATE_CACHE_SECONDS: int = Field(default=3600) + MIN_PAYMENT_AMOUNT: float = Field(default=PAYKILLA_DEFAULT_MIN_PAYMENT_AMOUNT) + MIN_PAYMENT_CURRENCY: str = Field(default=PAYKILLA_DEFAULT_MIN_PAYMENT_CURRENCY) + VERIFY_WEBHOOK_SIGNATURE: bool = Field(default=True) + WEBHOOK_URL: Optional[str] = None + TRUSTED_IPS: str = Field(default="") + + @field_validator("LIFETIME_SECONDS", mode="before") + @classmethod + def _clamp_lifetime(cls, v): + if isinstance(v, str): + v = v.strip() + try: + value = int(v) + except (TypeError, ValueError): + return 3600 + return min(2_592_000, max(300, value)) + + @field_validator("RECV_WINDOW_MS", mode="before") + @classmethod + def _clamp_recv_window(cls, v): + if isinstance(v, str): + v = v.strip() + try: + value = int(v) + except (TypeError, ValueError): + return 5000 + return min(60_000, max(1000, value)) + + @field_validator("EXCHANGE_RATE_CACHE_SECONDS", mode="before") + @classmethod + def _clamp_exchange_rate_cache(cls, v): + if isinstance(v, str): + v = v.strip() + try: + value = int(v) + except (TypeError, ValueError): + return 3600 + return min(86_400, max(60, value)) + + @field_validator("MIN_PAYMENT_AMOUNT", mode="before") + @classmethod + def _normalize_min_payment_amount(cls, v): + if isinstance(v, str): + v = v.strip() + try: + value = Decimal(str(v)) + except (InvalidOperation, TypeError, ValueError): + return PAYKILLA_DEFAULT_MIN_PAYMENT_AMOUNT + if not value.is_finite() or value < 0: + return PAYKILLA_DEFAULT_MIN_PAYMENT_AMOUNT + return float(value) + + @field_validator("MIN_PAYMENT_CURRENCY", mode="before") + @classmethod + def _normalize_min_payment_currency(cls, v): + return normalize_payment_currency_code(v, default=PAYKILLA_DEFAULT_MIN_PAYMENT_CURRENCY) + + @field_validator( + "API_KEY", + "SECRET_KEY", + "INVOICE_TYPE", + "WEBHOOK_URL", + mode="before", + ) + @classmethod + def _strip_optional(cls, v): + if isinstance(v, str) and not v.strip(): + return None + return v + + @field_validator("INVOICE_TYPE", mode="before") + @classmethod + def _normalize_invoice_type(cls, v): + if isinstance(v, str): + value = v.strip().upper() + return value or None + return v + + @property + def webhook_path(self) -> str: + return "/webhook/paykilla" + + def full_webhook_url(self, base: Optional[str]) -> Optional[str]: + if self.WEBHOOK_URL: + return self.WEBHOOK_URL.rstrip("/") + if not base: + return None + return f"{base.rstrip('/')}{self.webhook_path}" + + @property + def trusted_ips_list(self) -> List[str]: + return [item.strip() for item in (self.TRUSTED_IPS or "").split(",") if item.strip()] + + +class PaykillaPresentation(ProviderEnvConfig): + """Admin-tunable button text/icon overrides for PayKilla.""" + + model_config = SettingsConfigDict( + env_file=provider_env_file(), + env_file_encoding="utf-8", + env_prefix="PAYMENT_PAYKILLA_", + extra="ignore", + ) + + WEBAPP_LABEL_RU: Optional[str] = None + WEBAPP_LABEL_EN: Optional[str] = None + WEBAPP_ICON: Optional[str] = None + TELEGRAM_LABEL_RU: Optional[str] = None + TELEGRAM_LABEL_EN: Optional[str] = None + TELEGRAM_EMOJI: Optional[str] = None + + +def _normalize_paykilla_text(value: Any) -> str: + text = str(value or "") + text = text.translate(_CYRILLIC_TO_LATIN) + text = re.sub(r"[-\u2010-\u2015]", " ", text) + text = text.encode("ascii", "ignore").decode("ascii") + text = re.sub(r"[^A-Za-z0-9_\s.,]", "", text) + return re.sub(r"\s+", " ", text).strip() + + +def _clean_paykilla_text(value: Any, *, fallback: str, max_length: int = 255) -> str: + text = _normalize_paykilla_text(value) + fallback_text = _normalize_paykilla_text(fallback) or "Payment" + text = re.sub(r"\s+", " ", text).strip() + if not text: + text = fallback_text + return text[:max_length].strip() or fallback_text[:max_length].strip() or "Payment" + + +def _invoice_text(title: Any, payment_db_id: int) -> str: + project_title = _clean_paykilla_text(title, fallback="Minishop") + return _clean_paykilla_text( + f"{project_title} payment {payment_db_id}", + fallback=f"Payment {payment_db_id}", + ) + + +def _payment_currencies(config: PaykillaConfig) -> List[str]: + currencies = list(parse_supported_currency_codes(config.PAYMENT_CURRENCIES)) + return currencies or list(parse_supported_currency_codes(PAYKILLA_DEFAULT_PAYMENT_CURRENCIES)) + + +def _invoice_currencies(config: PaykillaConfig) -> tuple[str, ...]: + currencies = parse_supported_currency_codes(config.INVOICE_CURRENCIES) + return currencies or parse_supported_currency_codes(PAYKILLA_DEFAULT_INVOICE_CURRENCIES) + + +def _target_invoice_currency(config: PaykillaConfig, payment_currency: str) -> str: + payment_currency = normalize_payment_currency_code(payment_currency) + invoice_currencies = _invoice_currencies(config) + if payment_currency in invoice_currencies: + return payment_currency + fallback = normalize_payment_currency_code(config.CURRENCY, default="") + if fallback and fallback in invoice_currencies: + return fallback + return invoice_currencies[0] if invoice_currencies else payment_currency + + +def _invoice_type_for(config: PaykillaConfig, currency: str) -> str: + explicit = (config.INVOICE_TYPE or "").strip().upper() + if explicit in {"FIAT_BASED", "FIXED_AMOUNT", "OPEN_AMOUNT"}: + return explicit + return "FIAT_BASED" if currency in _FIAT_CURRENCIES else "FIXED_AMOUNT" + + +def _sign_query(timestamp_ms: int, recv_window_ms: int, secret_key: str) -> Tuple[str, str]: + query = urlencode( + [ + ("timestamp", str(timestamp_ms)), + ("recvWindow", str(recv_window_ms)), + ] + ) + signature = hmac.new(secret_key.encode("utf-8"), query.encode("utf-8"), hashlib.sha256) + return query, signature.hexdigest() + + +def _webhook_signature( + *, + timestamp: str, + method: str, + url: str, + raw_body: bytes, + secret_key: str, +) -> str: + message = f"{timestamp}{method.upper()}{url}".encode("utf-8") + raw_body + return hmac.new(secret_key.encode("utf-8"), message, hashlib.sha256).hexdigest() + + +def _signature_preview(signature: str) -> str: + signature = str(signature or "") + if len(signature) <= 12: + return signature + return f"{signature[:6]}...{signature[-6:]}" + + +def _response_invoice_data(response_data: Dict[str, Any]) -> Dict[str, Any]: + data = response_data.get("data") if isinstance(response_data, dict) else None + if isinstance(data, dict) and data.get("id"): + return data + return response_data if isinstance(response_data, dict) else {} + + +def _debug_invoice_body(body: Dict[str, Any]) -> str: + return json.dumps(body, ensure_ascii=True, sort_keys=True) + + +def _decimal_from_api(value: Any) -> Optional[Decimal]: + try: + decimal_value = Decimal(str(value)) + except (InvalidOperation, TypeError, ValueError): + return None + if not decimal_value.is_finite(): + return None + return decimal_value + + +def _config_min_payment_amount(config: PaykillaConfig) -> Decimal: + amount = _decimal_from_api(getattr(config, "MIN_PAYMENT_AMOUNT", None)) + if amount is None or amount < 0: + return Decimal(str(PAYKILLA_DEFAULT_MIN_PAYMENT_AMOUNT)) + return amount + + +def _config_min_payment_currency(config: PaykillaConfig) -> str: + return normalize_payment_currency_code( + getattr(config, "MIN_PAYMENT_CURRENCY", None), + default=PAYKILLA_DEFAULT_MIN_PAYMENT_CURRENCY, + ) + + +def _exchange_rate_url_for( + config: PaykillaConfig, + source_currency: str, + target_currency: str, +) -> str: + template = getattr(config, "EXCHANGE_RATE_URL", None) or PAYKILLA_DEFAULT_EXCHANGE_RATE_URL + return template.format(source=source_currency, target=target_currency) + + +def _exchange_rate_sync( + config: PaykillaConfig, source_currency: str, target_currency: str +) -> Optional[Decimal]: + source_currency = normalize_payment_currency_code(source_currency) + target_currency = normalize_payment_currency_code(target_currency) + if source_currency == target_currency: + return Decimal("1") + + url = _exchange_rate_url_for(config, source_currency, target_currency) + cache_key = (url, source_currency, target_currency) + cache_seconds = int(getattr(config, "EXCHANGE_RATE_CACHE_SECONDS", 3600) or 3600) + now = time.time() + cached = _SYNC_EXCHANGE_RATE_CACHE.get(cache_key) + if cached and now - cached[0] < cache_seconds: + return cached[1] + + try: + with urlopen(url, timeout=5) as response: + response_data = json.loads(response.read().decode("utf-8")) + except Exception: + logging.exception( + "Paykilla exchange rate sync lookup failed (source=%s target=%s).", + source_currency, + target_currency, + ) + return None + + if not isinstance(response_data, dict) or response_data.get("result") != "success": + logging.warning( + "Paykilla exchange rate sync lookup returned unexpected body: %s", + response_data, + ) + return None + rates = response_data.get("rates") + rate = _decimal_from_api(rates.get(target_currency) if isinstance(rates, dict) else None) + if rate is None or rate <= 0: + return None + _SYNC_EXCHANGE_RATE_CACHE[cache_key] = (now, rate) + return rate + + +def _min_payment_threshold_for_currency( + config: PaykillaConfig, payment_currency: Any +) -> Optional[Decimal]: + min_amount = _config_min_payment_amount(config) + if min_amount <= 0: + return None + min_currency = _config_min_payment_currency(config) + payment_currency = normalize_payment_currency_code(payment_currency) + if payment_currency == min_currency: + return format_decimal_amount(min_amount) + rate = _exchange_rate_sync(config, payment_currency, min_currency) + if rate is None or rate <= 0: + return None + return (min_amount / rate).quantize(Decimal("0.01"), rounding=ROUND_CEILING) + + +def _paykilla_payment_minimum_metadata( + config: PaykillaConfig, payment_currency: Any +) -> Optional[Dict[str, Any]]: + payment_currency = normalize_payment_currency_code(payment_currency) + threshold = _min_payment_threshold_for_currency(config, payment_currency) + if threshold is None: + return None + return { + "min_amount": str(threshold), + "min_currency": payment_currency, + "configured_min_amount": str(format_decimal_amount(_config_min_payment_amount(config))), + "configured_min_currency": _config_min_payment_currency(config), + } + + +def _paykilla_payment_amount_supported( + config: PaykillaConfig, + payment_currency: Any, + amount: Any, +) -> bool: + threshold = _min_payment_threshold_for_currency(config, payment_currency) + if threshold is None: + return True + value = _decimal_from_api(amount) + if value is None: + return True + return format_decimal_amount(value) >= threshold + + +class PaykillaService(HttpClientMixin): + def __init__( + self, + *, + bot: Bot, + settings: Settings, + config: PaykillaConfig, + i18n: JsonI18n, + async_session_factory: sessionmaker, + subscription_service: SubscriptionService, + referral_service: ReferralService, + default_return_url: str, + ): + self.bot = bot + self.settings = settings + self.config = config + self.i18n = i18n + self.async_session_factory = async_session_factory + self.subscription_service = subscription_service + self.referral_service = referral_service + self._default_return_url = default_return_url + self._exchange_rate_cache: Dict[tuple[str, str], tuple[float, Decimal]] = {} + self._currency_cache: tuple[float, List[Dict[str, Any]]] = (0, []) + + self._init_http_client(total_timeout=lambda: self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS) + if not self.configured: + logging.warning( + "PaykillaService initialized but not fully configured. Payments disabled." + ) + + @property + def configured(self) -> bool: + return bool(provider_runtime_enabled(self.config) and self.api_key and self.secret_key) + + @property + def base_url(self) -> str: + return (self.config.BASE_URL or "https://account-api.paykilla.com").rstrip("/") + + @property + def widget_url(self) -> str: + return (self.config.WIDGET_URL or "https://gopay.paykilla.com").rstrip("/") + + @property + def api_key(self) -> str: + return (self.config.API_KEY or "").strip() + + @property + def secret_key(self) -> str: + return (self.config.SECRET_KEY or "").strip() + + @property + def currency(self) -> str: + return normalize_payment_currency_code(self.config.CURRENCY or "USD") + + @property + def verify_webhook_signature(self) -> bool: + return self.config.VERIFY_WEBHOOK_SIGNATURE + + def _signed_invoice_url(self) -> str: + timestamp_ms = int(time.time() * 1000) + recv_window_ms = int(self.config.RECV_WINDOW_MS) + query, signature = _sign_query(timestamp_ms, recv_window_ms, self.secret_key) + return f"{self.base_url}/api/v2/invoice?{query}&signature={signature}" + + def _signed_invoice_details_url(self, invoice_id: str) -> str: + timestamp_ms = int(time.time() * 1000) + recv_window_ms = int(self.config.RECV_WINDOW_MS) + query, signature = _sign_query(timestamp_ms, recv_window_ms, self.secret_key) + return f"{self.base_url}/api/v2/invoice/{invoice_id}?{query}&signature={signature}" + + def _signed_currency_url(self) -> str: + timestamp_ms = int(time.time() * 1000) + recv_window_ms = int(self.config.RECV_WINDOW_MS) + query, signature = _sign_query(timestamp_ms, recv_window_ms, self.secret_key) + return f"{self.base_url}/api/v2/currency?{query}&signature={signature}" + + def _exchange_rate_url(self, source_currency: str, target_currency: str) -> str: + return _exchange_rate_url_for(self.config, source_currency, target_currency) + + async def _exchange_rate(self, source_currency: str, target_currency: str) -> Decimal: + source_currency = normalize_payment_currency_code(source_currency) + target_currency = normalize_payment_currency_code(target_currency) + if source_currency == target_currency: + return Decimal("1") + + cache_key = (source_currency, target_currency) + cache_seconds = int(self.config.EXCHANGE_RATE_CACHE_SECONDS) + now = time.time() + cache = getattr(self, "_exchange_rate_cache", None) + if cache is None: + cache = {} + self._exchange_rate_cache = cache + cached = cache.get(cache_key) + if cached and now - cached[0] < cache_seconds: + return cached[1] + + session = await self._get_session() + url = self._exchange_rate_url(source_currency, target_currency) + async with session.get(url) as response: + response_text = await response.text() + try: + response_data = json.loads(response_text) if response_text else {} + except json.JSONDecodeError as exc: + raise ValueError("exchange_rate_invalid_json") from exc + if response.status != 200 or response_data.get("result") != "success": + logging.error( + "Paykilla exchange rate request failed " + "(status=%s, body=%s, source=%s, target=%s)", + response.status, + response_data, + source_currency, + target_currency, + ) + raise ValueError("exchange_rate_unavailable") + rates = response_data.get("rates") if isinstance(response_data, dict) else None + target_rate = rates.get(target_currency) if isinstance(rates, dict) else None + rate = _decimal_from_api(target_rate) + if rate is None or rate <= 0: + raise ValueError("exchange_rate_missing") + + cache[cache_key] = (now, rate) + return rate + + async def _paykilla_currencies(self) -> List[Dict[str, Any]]: + cache_seconds = int(self.config.EXCHANGE_RATE_CACHE_SECONDS) + now = time.time() + cached_at, cached_data = getattr(self, "_currency_cache", (0, [])) + if cached_data and now - cached_at < cache_seconds: + return cached_data + + headers = {"X-API-KEY": self.api_key} + session = await self._get_session() + try: + async with session.get(self._signed_currency_url(), headers=headers) as response: + response_text = await response.text() + try: + response_data = json.loads(response_text) if response_text else [] + except json.JSONDecodeError: + logging.warning( + "Paykilla currency metadata request returned invalid JSON: %s", + response_text, + ) + return cached_data + if response.status != 200 or not isinstance(response_data, list): + logging.warning( + "Paykilla currency metadata request failed (status=%s, body=%s)", + response.status, + response_data, + ) + return cached_data + except Exception: + logging.exception("Paykilla currency metadata request failed.") + return cached_data + + self._currency_cache = (now, response_data) + return response_data + + async def _currency_info_for(self, currency: str) -> Optional[Dict[str, Any]]: + currency = normalize_payment_currency_code(currency) + for item in await self._paykilla_currencies(): + if not isinstance(item, dict): + continue + if normalize_payment_currency_code(item.get("ticker"), default="") == currency: + return item + return None + + async def _invoice_amount_bounds_error( + self, *, amount: Decimal, currency: str + ) -> Optional[Dict[str, Any]]: + info = await self._currency_info_for(currency) + if not info: + return None + minimum = _decimal_from_api(info.get("invoiceMin")) + maximum = _decimal_from_api(info.get("invoiceMax")) + if minimum is not None and amount < minimum: + return { + "message": "invoice_amount_below_minimum", + "currency": currency, + "amount": str(amount), + "minimum": str(format_decimal_amount(minimum)), + } + if maximum is not None and amount > maximum: + return { + "message": "invoice_amount_above_maximum", + "currency": currency, + "amount": str(amount), + "maximum": str(format_decimal_amount(maximum)), + } + return None + + async def _invoice_amount_and_currency( + self, *, amount: float, payment_currency: str + ) -> tuple[Decimal, str]: + payment_currency = normalize_payment_currency_code(payment_currency or self.currency) + invoice_currency = _target_invoice_currency(self.config, payment_currency) + invoice_amount = format_decimal_amount(amount) + if invoice_currency == payment_currency: + return invoice_amount, invoice_currency + + rate = await self._exchange_rate(payment_currency, invoice_currency) + converted_amount = format_decimal_amount(invoice_amount * rate) + logging.info( + "Paykilla invoice currency conversion: payment=%s %s, invoice=%s %s, rate=%s", + invoice_amount, + payment_currency, + converted_amount, + invoice_currency, + rate, + ) + return converted_amount, invoice_currency + + async def _configured_minimum_error( + self, *, amount: float, payment_currency: str + ) -> Optional[Dict[str, Any]]: + min_amount = _config_min_payment_amount(self.config) + if min_amount <= 0: + return None + min_currency = _config_min_payment_currency(self.config) + payment_currency = normalize_payment_currency_code(payment_currency) + payment_amount = format_decimal_amount(amount) + if payment_currency == min_currency: + comparable_amount = payment_amount + else: + rate = await self._exchange_rate(payment_currency, min_currency) + comparable_amount = format_decimal_amount(payment_amount * rate) + if comparable_amount >= min_amount: + return None + return { + "message": "payment_amount_below_minimum", + "currency": payment_currency, + "amount": str(payment_amount), + "minimum": str(format_decimal_amount(min_amount)), + "minimum_currency": min_currency, + "converted_amount": str(comparable_amount), + } + + def _invoice_body( + self, + *, + payment_db_id: int, + amount: Any, + currency: Optional[str], + description: str, + ) -> Dict[str, Any]: + currency_code = normalize_payment_currency_code(currency or self.currency) + invoice_text = _invoice_text(getattr(self.settings, "WEBAPP_TITLE", None), payment_db_id) + body: Dict[str, Any] = { + "type": _invoice_type_for(self.config, currency_code), + "purpose": invoice_text, + "currency": currency_code, + "totalPrice": str(format_decimal_amount(amount)), + "paymentCurrencies": _payment_currencies(self.config), + "clientOrderId": str(payment_db_id), + "userPaysServiceFee": bool(self.config.USER_PAYS_SERVICE_FEE), + "userPaysNetworkFee": bool(self.config.USER_PAYS_NETWORK_FEE), + "description": invoice_text, + } + if self.config.LIFETIME_SECONDS: + expires_at = datetime.now(timezone.utc) + timedelta( + seconds=int(self.config.LIFETIME_SECONDS) + ) + body["expiredAt"] = expires_at.isoformat().replace("+00:00", "Z") + return body + + async def create_payment_link( + self, + *, + payment_db_id: int, + amount: float, + currency: Optional[str], + description: str, + url_callback: Optional[str] = None, + ) -> Tuple[bool, Dict[str, Any]]: + if not self.configured: + logging.error("PaykillaService is not configured. Cannot create payment link.") + return False, {"message": "service_not_configured"} + + currency_code = normalize_payment_currency_code(currency or self.currency) + supported = parse_supported_currency_codes(self.config.SUPPORTED_CURRENCIES) + if supported and currency_code not in supported: + return False, { + "message": "unsupported_currency", + "currency": currency_code, + "supported_currencies": list(supported), + } + + try: + minimum_error = await self._configured_minimum_error( + amount=amount, + payment_currency=currency_code, + ) + if minimum_error: + logging.error( + "Paykilla create_payment_link: payment amount below configured minimum " + "(details=%s)", + minimum_error, + ) + return False, minimum_error + invoice_amount, invoice_currency = await self._invoice_amount_and_currency( + amount=amount, + payment_currency=currency_code, + ) + except Exception as exc: + logging.exception( + "Paykilla create_payment_link: failed to resolve invoice currency " + "(amount=%s currency=%s target=%s).", + amount, + currency_code, + _target_invoice_currency(self.config, currency_code), + ) + return False, {"message": str(exc) or "exchange_rate_unavailable"} + + bounds_error = await self._invoice_amount_bounds_error( + amount=invoice_amount, + currency=invoice_currency, + ) + if bounds_error: + logging.error( + "Paykilla create_payment_link: invoice amount violates PayKilla limits " + "(details=%s, payment_amount=%s, payment_currency=%s)", + bounds_error, + format_decimal_amount(amount), + currency_code, + ) + return False, bounds_error + + body = self._invoice_body( + payment_db_id=payment_db_id, + amount=invoice_amount, + currency=invoice_currency, + description=description, + ) + headers = { + "X-API-KEY": self.api_key, + "Content-Type": "application/json", + } + session = await self._get_session() + try: + async with session.post( + self._signed_invoice_url(), + json=body, + headers=headers, + ) as response: + response_text = await response.text() + try: + response_data = json.loads(response_text) if response_text else {} + except json.JSONDecodeError: + logging.error("Paykilla create_payment_link: invalid JSON: %s", response_text) + return False, { + "status": response.status, + "message": "invalid_json", + "raw": response_text, + } + invoice = _response_invoice_data(response_data) + invoice_id = first_value(invoice, "id") + if response.status not in {200, 201} or not invoice_id: + logging.error( + "Paykilla create_payment_link: API error " + "(status=%s, body=%s, request_body=%s)", + response.status, + response_data, + _debug_invoice_body(body), + ) + return False, {"status": response.status, "message": response_data} + invoice["payment_url"] = f"{self.widget_url}/{invoice_id}" + return True, invoice + except Exception as exc: + logging.exception("Paykilla create_payment_link: request failed.") + return False, {"message": str(exc)} + + async def get_invoice_details(self, invoice_id: str) -> Tuple[bool, Dict[str, Any]]: + if not self.configured: + return False, {"message": "service_not_configured"} + + invoice_id = str(invoice_id or "").strip() + if not invoice_id: + return False, {"message": "missing_invoice_id"} + + headers = { + "X-API-KEY": self.api_key, + "Content-Type": "application/json", + } + session = await self._get_session() + try: + async with session.get( + self._signed_invoice_details_url(invoice_id), + headers=headers, + ) as response: + response_text = await response.text() + try: + response_data = json.loads(response_text) if response_text else {} + except json.JSONDecodeError: + logging.error("Paykilla get_invoice_details: invalid JSON: %s", response_text) + return False, { + "status": response.status, + "message": "invalid_json", + "raw": response_text, + } + invoice = _response_invoice_data(response_data) + if response.status != 200 or not first_value(invoice, "id"): + logging.warning( + "Paykilla get_invoice_details failed: id=%s status=%s body=%s", + invoice_id, + response.status, + response_data, + ) + return False, {"status": response.status, "message": response_data} + return True, invoice + except Exception as exc: + logging.exception("Paykilla get_invoice_details request failed: id=%s", invoice_id) + return False, {"message": str(exc)} + + async def try_reuse_pending_invoice(self, payment: Any) -> Optional[str]: + invoice_id = str(getattr(payment, "provider_payment_id", None) or "").strip() + if not invoice_id: + return None + + success, data = await self.get_invoice_details(invoice_id) + if not success: + return None + if str(first_value(data, "id") or "") != invoice_id: + return None + if str(data.get("clientOrderId") or "") != str(payment.payment_id): + return None + if str(data.get("status") or "").strip().upper() != "PROCESSING": + return None + return f"{self.widget_url}/{invoice_id}" + + def _webhook_url_for_request(self, request: web.Request) -> Optional[str]: + configured = self.config.full_webhook_url(getattr(self.settings, "WEBHOOK_BASE_URL", None)) + if configured: + return configured + try: + return f"{request.scheme}://{request.host}{request.path_qs}" + except Exception: + return None + + def _verify_webhook_signature(self, request: web.Request, raw_body: bytes) -> bool: + timestamp = str(request.headers.get("X-API-TIMESTAMP") or "").strip() + signature = str(request.headers.get("X-API-SIGN") or "").strip().lower() + recv_window_raw = str(request.headers.get("X-API-RECV-WINDOW") or "5000").strip() + api_key = str(request.headers.get("X-API-KEY") or "").strip() + if not timestamp or not signature: + return False + if api_key and not hmac.compare_digest(api_key, self.api_key): + logging.warning("Paykilla webhook: X-API-KEY does not match configured API key.") + return False + try: + timestamp_ms = int(timestamp) + recv_window_ms = int(recv_window_raw) + except ValueError: + return False + now_ms = int(time.time() * 1000) + if abs(now_ms - timestamp_ms) > max(recv_window_ms, 1000): + logging.warning( + "Paykilla webhook: timestamp outside recv window " + "(timestamp=%s now=%s recvWindow=%s).", + timestamp_ms, + now_ms, + recv_window_ms, + ) + return False + webhook_url = self._webhook_url_for_request(request) + if not webhook_url: + logging.warning("Paykilla webhook: cannot resolve webhook URL for signature.") + return False + expected = _webhook_signature( + timestamp=timestamp, + method=request.method or "POST", + url=webhook_url, + raw_body=raw_body, + secret_key=self.secret_key, + ) + if hmac.compare_digest(expected, signature): + return True + logging.warning( + "Paykilla webhook: invalid signature (received=%s expected=%s url=%s).", + _signature_preview(signature), + _signature_preview(expected), + webhook_url, + ) + return False + + async def webhook_route(self, request: web.Request) -> web.Response: + if not self.configured: + return web.Response(status=503, text="paykilla_disabled") + + client_ip = request_client_ip(request, trusted_proxies=self.settings.trusted_proxies) + trusted = self.config.trusted_ips_list + if trusted and not ip_in_allowlist(client_ip, trusted): + logging.warning( + "Paykilla webhook denied from unauthorized IP source " + "(client_ip=%s remote=%s x_forwarded_for=%s trusted_ip_count=%d).", + client_ip, + request.remote, + request.headers.get("X-Forwarded-For"), + len(trusted), + ) + return web.Response(status=403, text="forbidden") + + raw_body = await request.read() + if self.verify_webhook_signature and not self._verify_webhook_signature( + request, + raw_body, + ): + return web.Response(status=403, text="invalid_signature") + + try: + payload = json.loads(raw_body.decode("utf-8")) + except Exception: + logging.exception("Paykilla webhook: failed to parse JSON.") + return web.Response(status=400, text="bad_request") + + if not isinstance(payload, dict): + return web.Response(status=400, text="bad_request") + + event_type = str(payload.get("eventType") or "").strip().upper() + data = payload.get("data") + if not event_type or not isinstance(data, dict): + logging.error("Paykilla webhook: missing event envelope fields: %s", payload) + return web.Response(status=400, text="missing_fields") + + if event_type not in _SUCCESS_EVENTS and event_type not in _FAILED_EVENTS: + logging.info("Paykilla webhook: intermediate event '%s' ignored.", event_type) + return web.Response(text="status_ignored") + + data_type = str(data.get("type") or "").strip().upper() + if data_type and data_type != "INVOICE": + logging.info( + "Paykilla webhook: non-invoice event '%s' ignored (type=%s).", + event_type, + data_type, + ) + return web.Response(text="status_ignored") + + invoice_id = str(data.get("id") or "").strip() + client_order_id = data.get("clientOrderId") + amount_raw = data.get("amount") or data.get("expectedAmount") + invoice_currency = normalize_payment_currency_code(data.get("currency") or self.currency) + + if not (invoice_id or client_order_id): + logging.error("Paykilla webhook: missing invoice ids: %s", payload) + return web.Response(status=400, text="missing_fields") + + async with self.async_session_factory() as session: + payment = await lookup_payment_by_order_or_provider_id( + session, + order_id_raw=client_order_id, + provider_payment_id=invoice_id or None, + ) + if not payment: + logging.error( + "Paykilla webhook: payment not found (clientOrderId=%s, invoice_id=%s)", + client_order_id, + invoice_id, + ) + return web.Response(status=404, text="payment_not_found") + + if payment.status == "succeeded" and event_type in _SUCCESS_EVENTS: + return web.Response(text="ok") + + resolved_id = invoice_id or str(payment.payment_id) + + if event_type in _SUCCESS_EVENTS: + payment_currency = normalize_payment_currency_code(payment.currency) + if amount_raw is not None and invoice_currency == payment_currency: + try: + if not decimal_amounts_equal(amount_raw, payment.amount): + logging.warning( + "Paykilla webhook: amount mismatch for payment %s " + "(expected %s, got %s)", + payment.payment_id, + format_decimal_amount(payment.amount), + format_decimal_amount(amount_raw), + ) + except Exception as exc: + logging.warning( + "Paykilla webhook: failed to compare amounts for %s: %s", + payment.payment_id, + exc, + ) + elif amount_raw is not None: + logging.info( + "Paykilla webhook: invoice amount is in %s while payment record is in %s; " + "skipping direct amount comparison for payment %s.", + invoice_currency, + payment_currency, + payment.payment_id, + ) + + try: + await payment_dal.update_provider_payment_and_status( + session, + payment.payment_id, + resolved_id, + "succeeded", + ) + await session.commit() + except Exception: + await session.rollback() + logging.exception( + "Paykilla webhook: failed to mark payment %s as succeeded.", + resolved_id, + ) + return web.Response(status=500, text="processing_error") + + sale_mode = payment.sale_mode or ( + "traffic" if self.settings.traffic_sale_mode else "subscription" + ) + payment_units = payment_units_for_activation(payment, sale_mode) + + outcome = await finalize_successful_payment( + PaymentSuccessRequest( + bot=self.bot, + settings=self.settings, + i18n=self.i18n, + session=session, + subscription_service=self.subscription_service, + referral_service=self.referral_service, + payment=payment, + user_id=payment.user_id, + amount=float(payment.amount), + currency=str(payment.currency), + sale_mode=sale_mode, + months=payment_units, + traffic_amount=float(payment_units), + provider_subscription="paykilla", + provider_notification="paykilla", + db_user=payment.user, + log_prefix="Paykilla webhook", + ) + ) + if outcome is None: + return web.Response(status=500, text="processing_error") + return web.Response(text="ok") + + if event_type in _FAILED_EVENTS: + try: + await payment_dal.update_provider_payment_and_status( + session, + payment.payment_id, + resolved_id, + "failed", + ) + await session.commit() + except Exception: + await session.rollback() + logging.exception( + "Paykilla webhook: failed to mark payment %s as failed.", + resolved_id, + ) + return web.Response(status=500, text="processing_error") + await notify_user_payment_failed( + bot=self.bot, + settings=self.settings, + i18n=self.i18n, + session=session, + payment=payment, + ) + return web.Response(text="ok") + + +@router.callback_query(F.data.startswith("pay_paykilla:")) +async def pay_paykilla_callback_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + paykilla_service: PaykillaService, + session: AsyncSession, +): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + translator = make_translator(i18n, current_lang) + + if not i18n or not callback.message: + await notify_callback_parse_error(callback, translator) + return + + if not SPEC.is_available_to_user( + settings, + user_id=callback.from_user.id, + require_configured=False, + ): + await notify_service_unavailable(callback, translator) + return + + if not paykilla_service or not paykilla_service.configured: + logging.error("Paykilla service is not configured or unavailable.") + await notify_service_unavailable(callback, translator) + return + + parts = parse_payment_callback(callback.data or "") + if not parts: + logging.error("Invalid pay_paykilla data in callback: %s", callback.data) + await notify_callback_parse_error(callback, translator) + return + parts, hwid_quote = await quote_hwid_callback_parts( + session=session, + user_id=callback.from_user.id, + parts=parts, + subscription_service=paykilla_service.subscription_service, + currency=default_currency_key_for_settings(settings), + ) + if not parts: + await notify_callback_parse_error(callback, translator) + return + + currency_code = default_payment_currency_code_for_settings(settings) + if not SPEC.is_usable_for_payment_amount(settings, currency_code, parts.price): + logging.warning( + "Paykilla callback rejected below-minimum payment (amount=%s currency=%s user=%s).", + parts.price, + currency_code, + callback.from_user.id, + ) + await notify_service_unavailable(callback, translator) + return + payment_description = describe_payment(translator, parts) + record_payload = build_payment_record_payload( + user_id=callback.from_user.id, + amount=parts.price, + currency=currency_code, + status="pending_paykilla", + description=payment_description, + months=parts.months, + provider="paykilla", + sale_mode=parts.sale_mode, + hwid_quote=hwid_quote, + ) + + try: + payment_record = await payment_dal.create_payment_record(session, record_payload) + await session.commit() + except Exception: + await session.rollback() + logging.exception( + "Paykilla: failed to create payment record for user %s.", callback.from_user.id + ) + await notify_payment_record_failure(callback, translator) + return + + success, response_data = await paykilla_service.create_payment_link( + payment_db_id=payment_record.payment_id, + amount=parts.price, + currency=currency_code, + description=payment_description, + url_callback=paykilla_service.config.full_webhook_url(settings.WEBHOOK_BASE_URL), + ) + await render_link_or_fail( + callback, + translator=translator, + current_lang=current_lang, + i18n=i18n, + parts=parts, + session=session, + payment=payment_record, + api_success=success, + payment_url=first_value(response_data, "payment_url"), + provider_payment_id=first_value(response_data, "id"), + log_prefix=_LOG, + ) + + +async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: + settings: Settings = ctx.request.app["settings"] + service: PaykillaService = ctx.request.app["paykilla_service"] + if not service or not service.configured: + return payment_unavailable() + + currency = ctx.currency or default_payment_currency_code_for_settings(settings) + try: + payment = await create_webapp_payment_record( + ctx, + amount=ctx.price, + currency=currency, + status="pending_paykilla", + provider="paykilla", + ) + success, response_data = await service.create_payment_link( + payment_db_id=payment.payment_id, + amount=ctx.price, + currency=currency, + description=ctx.description, + url_callback=service.config.full_webhook_url(settings.WEBHOOK_BASE_URL), + ) + except Exception: + await ctx.session.rollback() + logging.exception("Paykilla WebApp payment failed") + return payment_failed() + + return await finalize_webapp_link_payment( + session=ctx.session, + payment=payment, + api_success=success, + payment_url=first_value(response_data, "payment_url") if success else None, + provider_payment_id=first_value(response_data, "id"), + log_prefix="Paykilla", + ) + + +async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]: + service: PaykillaService = ctx.request.app.get("paykilla_service") + if not service or not service.configured: + return None + return await service.try_reuse_pending_invoice(payment) + + +async def paykilla_webhook_route(request: web.Request) -> web.Response: + service: PaykillaService = request.app["paykilla_service"] + return await service.webhook_route(request) + + +def create_service(ctx: ServiceFactoryContext) -> PaykillaService: + bundle = ctx.config_for("paykilla_service") + config = ( + bundle.config if bundle and isinstance(bundle.config, PaykillaConfig) else PaykillaConfig() + ) + return PaykillaService( + bot=ctx.bot, + settings=ctx.settings, + config=config, + i18n=ctx.i18n, + async_session_factory=ctx.async_session_factory, + subscription_service=ctx.subscription_service, + referral_service=ctx.referral_service, + default_return_url=ctx.bot_username_for_default_return, + ) + + +_PRESENTATION_MANIFEST = tuple( + ProviderManifestField( + key=key, + type=type_, + label=label, + description=description, + placeholder=placeholder, + subsection="PayKilla", + target="presentation", + attr=attr, + ) + for key, type_, label, description, placeholder, attr in ( + ( + "PAYMENT_PAYKILLA_WEBAPP_LABEL_RU", + "string", + "WebApp button text (RU)", + "Custom Russian text shown in the Web App payment method button.", + "", + "WEBAPP_LABEL_RU", + ), + ( + "PAYMENT_PAYKILLA_WEBAPP_LABEL_EN", + "string", + "WebApp button text (EN)", + "Custom English text shown in the Web App payment method button.", + "", + "WEBAPP_LABEL_EN", + ), + ( + "PAYMENT_PAYKILLA_WEBAPP_ICON", + "icon", + "WebApp button icon", + "Lucide icon name rendered inside the Web App payment method button.", + "Bitcoin", + "WEBAPP_ICON", + ), + ( + "PAYMENT_PAYKILLA_TELEGRAM_LABEL_RU", + "string", + "Telegram button text (RU)", + "Custom Russian text shown in Telegram bot payment buttons.", + "", + "TELEGRAM_LABEL_RU", + ), + ( + "PAYMENT_PAYKILLA_TELEGRAM_LABEL_EN", + "string", + "Telegram button text (EN)", + "Custom English text shown in Telegram bot payment buttons.", + "", + "TELEGRAM_LABEL_EN", + ), + ( + "PAYMENT_PAYKILLA_TELEGRAM_EMOJI", + "string", + "Telegram button emoji", + "Emoji prepended to the Telegram bot payment button when customized.", + "", + "TELEGRAM_EMOJI", + ), + ) +) + + +_CONFIG_MANIFEST = ( + ProviderManifestField( + "PAYKILLA_ENABLED", "bool", "Enabled", subsection="PayKilla", attr="ENABLED" + ), + ProviderManifestField( + "PAYKILLA_API_KEY", + "string", + "API key", + description="PayKilla public HMAC key with INVOICE permission.", + subsection="PayKilla", + secret=True, + attr="API_KEY", + ), + ProviderManifestField( + "PAYKILLA_SECRET_KEY", + "string", + "Secret key", + description="PayKilla HMAC secret key. Never expose it client-side.", + subsection="PayKilla", + secret=True, + attr="SECRET_KEY", + ), + ProviderManifestField( + "PAYKILLA_BASE_URL", + "url", + "Base URL", + placeholder="https://account-api.paykilla.com", + subsection="PayKilla", + attr="BASE_URL", + ), + ProviderManifestField( + "PAYKILLA_WIDGET_URL", + "url", + "Widget URL", + placeholder="https://gopay.paykilla.com", + subsection="PayKilla", + attr="WIDGET_URL", + ), + ProviderManifestField( + "PAYKILLA_CURRENCY", + "string", + "Fallback invoice currency", + description=( + "Currency used for PayKilla invoice creation when the tariff currency is not " + "accepted by PayKilla as an invoice currency. Default: USD." + ), + placeholder="USD", + subsection="PayKilla", + attr="CURRENCY", + ), + ProviderManifestField( + "PAYKILLA_INVOICE_CURRENCIES", + "string", + "PayKilla invoice currencies", + description=( + "Comma-separated currencies accepted by PayKilla as invoice currency. " + "Payments in other tariff currencies are converted to PAYKILLA_CURRENCY." + ), + placeholder=PAYKILLA_DEFAULT_INVOICE_CURRENCIES, + subsection="PayKilla", + attr="INVOICE_CURRENCIES", + ), + ProviderManifestField( + "PAYKILLA_SUPPORTED_CURRENCIES", + "string", + "Supported tariff currencies", + description=( + "Comma-separated tariff/payment currencies that may use PayKilla. " + "Unsupported PayKilla invoice currencies are converted before invoice creation." + ), + placeholder=PAYKILLA_DEFAULT_SUPPORTED_CURRENCIES, + subsection="PayKilla", + attr="SUPPORTED_CURRENCIES", + ), + ProviderManifestField( + "PAYKILLA_PAYMENT_CURRENCIES", + "string", + "Accepted crypto tickers", + description=( + "Comma-separated PayKilla tickers sent as paymentCurrencies. " + "Default: USDTTRC,BTC,ETH,USDTBSC,USDTTON." + ), + placeholder=PAYKILLA_DEFAULT_PAYMENT_CURRENCIES, + subsection="PayKilla", + attr="PAYMENT_CURRENCIES", + ), + ProviderManifestField( + "PAYKILLA_INVOICE_TYPE", + "string", + "Invoice type", + description="Optional override: FIAT_BASED, FIXED_AMOUNT, or OPEN_AMOUNT.", + subsection="PayKilla", + attr="INVOICE_TYPE", + choices=( + ("", "Auto"), + ("FIAT_BASED", "FIAT_BASED"), + ("FIXED_AMOUNT", "FIXED_AMOUNT"), + ("OPEN_AMOUNT", "OPEN_AMOUNT"), + ), + ), + ProviderManifestField( + "PAYKILLA_LIFETIME_SECONDS", + "int", + "Invoice lifetime (seconds)", + description="Used to send expiredAt to PayKilla.", + subsection="PayKilla", + min=300, + max=2_592_000, + attr="LIFETIME_SECONDS", + ), + ProviderManifestField( + "PAYKILLA_RECV_WINDOW_MS", + "int", + "Request recvWindow (ms)", + description="Validity window for signed PayKilla API requests.", + subsection="PayKilla", + min=1000, + max=60_000, + attr="RECV_WINDOW_MS", + ), + ProviderManifestField( + "PAYKILLA_USER_PAYS_SERVICE_FEE", + "bool", + "User pays service fee", + subsection="PayKilla", + attr="USER_PAYS_SERVICE_FEE", + ), + ProviderManifestField( + "PAYKILLA_USER_PAYS_NETWORK_FEE", + "bool", + "User pays network fee", + subsection="PayKilla", + attr="USER_PAYS_NETWORK_FEE", + ), + ProviderManifestField( + "PAYKILLA_EXCHANGE_RATE_URL", + "url", + "Exchange rate URL", + description=( + "No-key exchange rate endpoint used when tariff currency must be converted. " + "Supports {source} and {target} placeholders." + ), + placeholder=PAYKILLA_DEFAULT_EXCHANGE_RATE_URL, + subsection="PayKilla", + attr="EXCHANGE_RATE_URL", + ), + ProviderManifestField( + "PAYKILLA_EXCHANGE_RATE_CACHE_SECONDS", + "int", + "Exchange rate cache (seconds)", + description="How long PayKilla currency conversion rates and PayKilla limits are cached.", + subsection="PayKilla", + min=60, + max=86_400, + attr="EXCHANGE_RATE_CACHE_SECONDS", + ), + ProviderManifestField( + "PAYKILLA_MIN_PAYMENT_AMOUNT", + "float", + "Minimum payment amount", + description=( + "Minimum payment amount accepted through PayKilla. The value is interpreted " + "in PAYKILLA_MIN_PAYMENT_CURRENCY and converted for tariff currencies." + ), + placeholder=str(PAYKILLA_DEFAULT_MIN_PAYMENT_AMOUNT), + subsection="PayKilla", + min=0, + attr="MIN_PAYMENT_AMOUNT", + ), + ProviderManifestField( + "PAYKILLA_MIN_PAYMENT_CURRENCY", + "string", + "Minimum payment currency", + description="Currency for PAYKILLA_MIN_PAYMENT_AMOUNT. Default: USD.", + placeholder=PAYKILLA_DEFAULT_MIN_PAYMENT_CURRENCY, + subsection="PayKilla", + attr="MIN_PAYMENT_CURRENCY", + ), + ProviderManifestField( + "PAYKILLA_VERIFY_WEBHOOK_SIGNATURE", + "bool", + "Verify webhook signature", + subsection="PayKilla", + attr="VERIFY_WEBHOOK_SIGNATURE", + ), + ProviderManifestField( + "PAYKILLA_WEBHOOK_URL", + "url", + "Exact webhook URL", + description=( + "Optional override for signature verification. Leave empty to use " + "WEBHOOK_BASE_URL + /webhook/paykilla." + ), + subsection="PayKilla", + attr="WEBHOOK_URL", + ), + ProviderManifestField( + "PAYKILLA_TRUSTED_IPS", + "string", + "Trusted IPs", + description="Optional comma-separated IP addresses accepted for PayKilla webhooks.", + subsection="PayKilla", + attr="TRUSTED_IPS", + ), +) + + +SPEC = PaymentProviderSpec( + id="paykilla", + provider_key="paykilla", + label="PayKilla", + webapp_label="PayKilla", + webapp_labels={"ru": "PayKilla", "en": "PayKilla"}, + webapp_icon="Bitcoin", + telegram_labels={"ru": "PayKilla", "en": "PayKilla"}, + telegram_emoji="", + pending_status="pending_paykilla", + enabled=lambda config: bool(getattr(config, "ENABLED", False)), + service_key="paykilla_service", + callback_prefix="pay_paykilla", + router=router, + create_service=create_service, + webhook_path=lambda source: "/webhook/paykilla", + webhook_route=paykilla_webhook_route, + create_webapp_payment=create_webapp_payment, + reuse_webapp_payment=reuse_webapp_payment, + emoji="", + config_class=PaykillaConfig, + presentation_class=PaykillaPresentation, + manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST, + supported_currencies_resolver=lambda config: getattr( + config, "SUPPORTED_CURRENCIES", PAYKILLA_DEFAULT_SUPPORTED_CURRENCIES + ), + payment_amount_resolver=_paykilla_payment_amount_supported, + payment_minimum_resolver=_paykilla_payment_minimum_metadata, + currency_support_note=( + "PayKilla invoice currency and paymentCurrencies availability can depend on " + "merchant account settings." + ), + currency_support_url="https://paykilla.gitbook.io/paykilla-docs/api-integration/supported-currencies", +) diff --git a/backend/bot/payment_providers/platega.py b/backend/bot/payment_providers/platega.py index 9fb8ab6..354d765 100644 --- a/backend/bot/payment_providers/platega.py +++ b/backend/bot/payment_providers/platega.py @@ -55,6 +55,7 @@ from .shared import ( post_json_request, quote_hwid_callback_parts, render_link_or_fail, + render_payment_link, safe_callback_answer, ) @@ -157,7 +158,7 @@ class PlategaService(HttpClientMixin): self.referral_service = referral_service self._default_return_url = default_return_url - self._init_http_client(total_timeout=20) + self._init_http_client(total_timeout=lambda: self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS) if not self.configured: logging.warning( "PlategaService initialized but not fully configured. Payments disabled." @@ -282,6 +283,69 @@ class PlategaService(HttpClientMixin): log_prefix="Platega create_transaction", ) + async def get_transaction(self, transaction_id: str) -> Tuple[bool, Dict[str, Any]]: + if not self.configured: + return False, {"message": "service_not_configured"} + + transaction_id = str(transaction_id or "").strip() + if not transaction_id: + return False, {"message": "missing_transaction_id"} + + session = await self._get_session() + try: + async with session.get( + f"{self.base_url}/transaction/{transaction_id}", + headers=self._auth_headers, + ) as response: + data = await response.json(content_type=None) + if response.status != 200 or not isinstance(data, dict): + logging.warning( + "Platega get_transaction failed: id=%s status=%s body=%s", + transaction_id, + response.status, + data, + ) + return False, {"status": response.status, "message": data} + return True, data + except Exception as exc: + logging.exception("Platega get_transaction request failed: id=%s", transaction_id) + return False, {"message": str(exc)} + + async def try_reuse_pending_transaction( + self, + payment: Any, + *, + user_id: int, + sale_mode: str, + variant: str, + ) -> Optional[str]: + transaction_id = str(getattr(payment, "provider_payment_id", None) or "").strip() + payment_url = str(getattr(payment, "provider_payment_url", None) or "").strip() + if not transaction_id or not payment_url: + return None + + success, data = await self.get_transaction(transaction_id) + if not success or str(data.get("status") or "").upper() != "PENDING": + return None + if str(data.get("id") or "") != transaction_id: + return None + + try: + payload = json.loads(str(data.get("payload") or "")) + except (TypeError, ValueError, json.JSONDecodeError): + return None + expected = { + "payment_db_id": str(payment.payment_id), + "user_id": str(user_id), + "sale_mode": str(sale_mode), + "platega_variant": str(variant), + } + if not isinstance(payload, dict) or any( + str(payload.get(key) or "") != value for key, value in expected.items() + ): + return None + return payment_url + async def webhook_route(self, request: web.Request) -> web.Response: if not self.configured: return web.Response(status=503, text="platega_disabled") @@ -519,6 +583,43 @@ async def pay_platega_callback_handler( hwid_quote=hwid_quote, ) + reuse_amounts = payment_record_amounts( + months=parts.months, + sale_mode=parts.sale_mode, + hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None, + ) + reusable_payment = await payment_dal.find_recent_pending_provider_payment( + session, + user_id=callback.from_user.id, + provider="platega", + pending_status="pending_platega", + amount=parts.price, + currency=currency_code, + sale_mode=parts.sale_mode, + months=reuse_amounts.months, + purchased_gb=reuse_amounts.purchased_gb, + purchased_hwid_devices=reuse_amounts.purchased_hwid_devices, + tariff_key=reuse_amounts.tariff_key, + ) + if reusable_payment is not None: + reusable_url = await platega_service.try_reuse_pending_transaction( + reusable_payment, + user_id=callback.from_user.id, + sale_mode=parts.sale_mode, + variant=platega_variant, + ) + if reusable_url: + await render_payment_link( + callback, + translator=translator, + current_lang=current_lang, + i18n=i18n, + parts=parts, + payment_url=reusable_url, + log_prefix=_LOG, + ) + return + try: payment_record = await payment_dal.create_payment_record(session, record_payload) await session.commit() @@ -549,7 +650,6 @@ async def pay_platega_callback_handler( ) transaction_id = first_value(response_data, "transactionId", "id") redirect_url = first_value(response_data, "redirect", "url", "paymentUrl") - provider_status = str((response_data or {}).get("status") or payment_record.status) # Platega requires *both* a transaction id and a redirect url to count as a # usable payment — neither field is sufficient on its own. Skipping the # persistence step when the redirect is missing matches the pre-refactor @@ -566,7 +666,6 @@ async def pay_platega_callback_handler( api_success=success, payment_url=redirect_url, provider_payment_id=persistable_id, - new_status=provider_status if persistable_id else None, log_prefix=_LOG, ) @@ -609,6 +708,7 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web months=ctx.months, sale_mode=ctx.sale_mode, traffic_gb=ctx.traffic_gb, + hwid_device_count=ctx.hwid_device_count, ) payment = await create_webapp_payment_record( ctx, @@ -651,7 +751,6 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web first_value(response_data, "redirect", "url", "paymentUrl") if success else None ), provider_payment_id=first_value(response_data, "transactionId", "id"), - new_status=str((response_data or {}).get("status") or payment.status), log_prefix="Platega", ) @@ -664,6 +763,19 @@ async def create_crypto_webapp_payment(ctx: WebAppPaymentContext) -> web.Respons return await _create_webapp_payment(ctx, "platega_crypto") +async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]: + service: PlategaService = ctx.request.app.get("platega_service") + if not service or not service.configured: + return None + variant = "crypto" if ctx.method == "platega_crypto" else "sbp" + return await service.try_reuse_pending_transaction( + payment, + user_id=ctx.user_id, + sale_mode=ctx.sale_mode, + variant=variant, + ) + + def _platega_presentation_manifest(subsection: str, default_icon: str, prefix: str) -> tuple: return tuple( ProviderManifestField( @@ -818,6 +930,7 @@ SBP_SPEC = PaymentProviderSpec( webhook_path=lambda source: "/webhook/platega", webhook_route=platega_webhook_route, create_webapp_payment=create_sbp_webapp_payment, + reuse_webapp_payment=reuse_webapp_payment, config_class=PlategaConfig, presentation_class=PlategaSbpPresentation, manifest_fields=_CONFIG_MANIFEST @@ -850,6 +963,7 @@ CRYPTO_SPEC = PaymentProviderSpec( service_key="platega_service", callback_prefix="pay_platega_crypto", create_webapp_payment=create_crypto_webapp_payment, + reuse_webapp_payment=reuse_webapp_payment, config_class=PlategaConfig, presentation_class=PlategaCryptoPresentation, manifest_fields=_platega_presentation_manifest("Platega", "Bitcoin", "PLATEGA_CRYPTO"), diff --git a/backend/bot/payment_providers/registry.py b/backend/bot/payment_providers/registry.py index 1767ca9..673a10e 100644 --- a/backend/bot/payment_providers/registry.py +++ b/backend/bot/payment_providers/registry.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any, Dict, Iterable, List, Mapping, Optional -from . import cryptopay, freekassa, heleket, platega, severpay, stars, wata, yookassa +from . import cryptopay, freekassa, heleket, paykilla, platega, severpay, stars, wata, yookassa from .base import ( PaymentProviderPresentation, PaymentProviderSpec, @@ -21,6 +21,7 @@ PAYMENT_PROVIDER_SPECS: tuple[PaymentProviderSpec, ...] = ( stars.SPEC, cryptopay.SPEC, heleket.SPEC, + paykilla.SPEC, ) diff --git a/backend/bot/payment_providers/severpay.py b/backend/bot/payment_providers/severpay.py index 2822e4a..92bebc6 100644 --- a/backend/bot/payment_providers/severpay.py +++ b/backend/bot/payment_providers/severpay.py @@ -51,11 +51,13 @@ from .shared import ( notify_user_payment_failed, parse_payment_callback, payment_failed, + payment_record_amounts, payment_unavailable, payment_units_for_activation, post_json_request, quote_hwid_callback_parts, render_link_or_fail, + render_payment_link, ) _LOG = "severpay" @@ -136,7 +138,7 @@ class SeverPayService(HttpClientMixin): self.referral_service = referral_service self._default_return_url = default_return_url - self._init_http_client(total_timeout=15) + self._init_http_client(total_timeout=lambda: self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS) if not self.configured: logging.warning( @@ -248,6 +250,48 @@ class SeverPayService(HttpClientMixin): return True, response_data.get("data") or response_data return False, response_data + async def get_payment(self, provider_payment_id: str) -> Tuple[bool, Dict[str, Any]]: + if not self.configured: + return False, {"message": "service_not_configured"} + + provider_payment_id = str(provider_payment_id or "").strip() + if not provider_payment_id: + return False, {"message": "missing_payment_id"} + + identifier: Dict[str, Any] + if provider_payment_id.isdigit(): + identifier = {"id": int(provider_payment_id)} + else: + identifier = {"uid": provider_payment_id} + + session = await self._get_session() + success, response_data = await post_json_request( + session, + f"{self.base_url}/payin/get", + body=self._build_signed_body(identifier), + log_prefix="SeverPay get_payment", + is_success=lambda status, data: status == 200 and bool((data or {}).get("status")), + ) + if success: + return True, response_data.get("data") or response_data + return False, response_data + + async def try_reuse_pending_payment(self, payment: Any) -> Optional[str]: + provider_payment_id = str(getattr(payment, "provider_payment_id", None) or "").strip() + payment_url = str(getattr(payment, "provider_payment_url", None) or "").strip() + if not provider_payment_id or not payment_url: + return None + + success, data = await self.get_payment(provider_payment_id) + if not success or str(data.get("status") or "").lower() not in {"new", "process"}: + return None + returned_ids = {str(data.get("id") or ""), str(data.get("uid") or "")} + if provider_payment_id not in returned_ids: + return None + if str(data.get("order_id") or "") != str(payment.payment_id): + return None + return payment_url + async def webhook_route(self, request: web.Request) -> web.Response: if not self.configured: return web.json_response({"status": False, "msg": "severpay_disabled"}, status=503) @@ -465,6 +509,38 @@ async def pay_severpay_callback_handler( hwid_quote=hwid_quote, ) + reuse_amounts = payment_record_amounts( + months=parts.months, + sale_mode=parts.sale_mode, + hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None, + ) + reusable_payment = await payment_dal.find_recent_pending_provider_payment( + session, + user_id=callback.from_user.id, + provider="severpay", + pending_status="pending_severpay", + amount=parts.price, + currency=currency_code, + sale_mode=parts.sale_mode, + months=reuse_amounts.months, + purchased_gb=reuse_amounts.purchased_gb, + purchased_hwid_devices=reuse_amounts.purchased_hwid_devices, + tariff_key=reuse_amounts.tariff_key, + ) + if reusable_payment is not None: + reusable_url = await severpay_service.try_reuse_pending_payment(reusable_payment) + if reusable_url: + await render_payment_link( + callback, + translator=translator, + current_lang=current_lang, + i18n=i18n, + parts=parts, + payment_url=reusable_url, + log_prefix=_LOG, + ) + return + try: payment_record = await payment_dal.create_payment_record(session, record_payload) await session.commit() @@ -552,6 +628,13 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: ) +async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]: + service: SeverPayService = ctx.request.app.get("severpay_service") + if not service or not service.configured: + return None + return await service.try_reuse_pending_payment(payment) + + _PRESENTATION_MANIFEST = tuple( ProviderManifestField( key=key, @@ -677,6 +760,7 @@ SPEC = PaymentProviderSpec( webhook_path=lambda source: "/webhook/severpay", webhook_route=severpay_webhook_route, create_webapp_payment=create_webapp_payment, + reuse_webapp_payment=reuse_webapp_payment, config_class=SeverPayConfig, presentation_class=SeverPayPresentation, manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST, diff --git a/backend/bot/payment_providers/shared/__init__.py b/backend/bot/payment_providers/shared/__init__.py index e8b4e82..67027c2 100644 --- a/backend/bot/payment_providers/shared/__init__.py +++ b/backend/bot/payment_providers/shared/__init__.py @@ -42,6 +42,7 @@ from .common import ( payment_record_amounts, payment_unavailable, payment_units_for_activation, + reusable_webapp_payment_response, sale_mode_base, sale_mode_is_hwid_devices, sale_mode_is_traffic, @@ -117,6 +118,7 @@ __all__ = [ "payment_link_message_text", "payment_link_response", "payment_record_amounts", + "reusable_webapp_payment_response", "payment_units_for_activation", "payment_unavailable", "post_json_request", diff --git a/backend/bot/payment_providers/shared/callbacks.py b/backend/bot/payment_providers/shared/callbacks.py index 66cc7c7..0748469 100644 --- a/backend/bot/payment_providers/shared/callbacks.py +++ b/backend/bot/payment_providers/shared/callbacks.py @@ -8,8 +8,10 @@ from aiogram import types from sqlalchemy.ext.asyncio import AsyncSession from bot.keyboards.inline.user_keyboards import ( + HWID_RENEWAL_TOKEN, get_payment_url_keyboard, payment_methods_back_callback, + sale_mode_has_token, ) from bot.middlewares.i18n import JsonI18n from db.dal import payment_dal @@ -123,6 +125,27 @@ async def quote_hwid_callback_parts( subscription_service, currency: str = "rub", ) -> tuple[Optional[PaymentCallbackParts], Optional[dict]]: + base = sale_mode_base(parts.sale_mode) + if base == "subscription" and sale_mode_has_token(parts.sale_mode, HWID_RENEWAL_TOKEN): + try: + months = int(parts.months) + except (TypeError, ValueError): + return None, None + quote = await subscription_service.quote_hwid_device_renewal_for_subscription( + session, + user_id=user_id, + target_tariff_key=sale_mode_tariff_key(parts.sale_mode), + months=months, + currency=currency, + ) + if not quote: + return parts, None + quoted_parts = PaymentCallbackParts( + months=months, + price=float(parts.price or 0) + float(quote.get("price") or 0), + sale_mode=parts.sale_mode, + ) + return quoted_parts, quote if not sale_mode_is_hwid_devices(parts.sale_mode): return parts, None device_count = parse_positive_int_units(parts.months) @@ -262,6 +285,7 @@ async def safe_store_provider_payment_id( payment: Payment, *, provider_payment_id: str, + provider_payment_url: Optional[str] = None, new_status: Optional[str] = None, log_prefix: str, ) -> bool: @@ -277,6 +301,7 @@ async def safe_store_provider_payment_id( payment.payment_id, str(provider_payment_id), new_status or payment.status, + provider_payment_url=provider_payment_url, ) await session.commit() return True @@ -331,11 +356,12 @@ async def render_link_or_fail( payment as ``failed_creation``. Every link-style provider used to inline this same sequence. """ - if api_success and provider_payment_id: + if api_success and provider_payment_id and payment_url: await safe_store_provider_payment_id( session, payment, provider_payment_id=provider_payment_id, + provider_payment_url=payment_url, new_status=new_status, log_prefix=log_prefix, ) diff --git a/backend/bot/payment_providers/shared/common.py b/backend/bot/payment_providers/shared/common.py index 53ac7b9..fd266c6 100644 --- a/backend/bot/payment_providers/shared/common.py +++ b/backend/bot/payment_providers/shared/common.py @@ -100,6 +100,11 @@ def build_payment_record_payload( base = sale_mode_base(sale_mode) is_traffic = sale_mode_is_traffic(sale_mode) is_hwid = sale_mode_is_hwid_devices(sale_mode) + hwid_devices = int(float(months)) if is_hwid else None + if hwid_quote: + quote_devices = parse_positive_int_units(hwid_quote.get("device_count")) + if quote_devices is not None: + hwid_devices = quote_devices payload = { "user_id": user_id, "amount": amount, @@ -111,9 +116,9 @@ def build_payment_record_payload( "sale_mode": sale_mode, "tariff_key": sale_mode_tariff_key(sale_mode), "purchased_gb": float(months) if is_traffic else None, - "purchased_hwid_devices": int(float(months)) if is_hwid else None, + "purchased_hwid_devices": hwid_devices, } - if hwid_quote and is_hwid: + if hwid_quote and hwid_devices is not None: payload.update( { "hwid_valid_from": hwid_quote.get("valid_from"), @@ -164,14 +169,20 @@ def payment_record_amounts( months: Any, sale_mode: str, traffic_gb: Optional[float] = None, + hwid_device_count: Optional[int] = None, ) -> PaymentRecordAmounts: traffic_sale = sale_mode_is_traffic(sale_mode) hwid_devices_sale = sale_mode_is_hwid_devices(sale_mode) units = traffic_gb if traffic_sale and traffic_gb is not None else months + purchased_hwid_devices = int(float(months)) if hwid_devices_sale else None + if not hwid_devices_sale and hwid_device_count is not None: + parsed_hwid_devices = parse_positive_int_units(hwid_device_count) + if parsed_hwid_devices is not None: + purchased_hwid_devices = parsed_hwid_devices return PaymentRecordAmounts( months=int(float(units)) if traffic_sale else int(float(months)), purchased_gb=float(units) if traffic_sale else None, - purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None, + purchased_hwid_devices=purchased_hwid_devices, tariff_key=sale_mode_tariff_key(sale_mode), traffic_sale=traffic_sale, hwid_devices_sale=hwid_devices_sale, @@ -281,6 +292,7 @@ async def create_webapp_payment_record( months=ctx.months, sale_mode=ctx.sale_mode, traffic_gb=ctx.traffic_gb, + hwid_device_count=ctx.hwid_device_count, ) return await create_base_payment_record( ctx.session, @@ -303,6 +315,45 @@ async def create_webapp_payment_record( ) +async def reusable_webapp_payment_response( + ctx: WebAppPaymentContext, + provider_spec: Any, + *, + since_minutes: Optional[int] = None, +) -> Optional[web.Response]: + resolver = getattr(provider_spec, "reuse_webapp_payment", None) + if resolver is None: + return None + + amounts = payment_record_amounts( + months=ctx.months, + sale_mode=ctx.sale_mode, + traffic_gb=ctx.traffic_gb, + hwid_device_count=ctx.hwid_device_count, + ) + payment = await payment_dal.find_recent_pending_provider_payment( + ctx.session, + user_id=ctx.user_id, + provider=provider_spec.provider_key, + pending_status=provider_spec.pending_status, + amount=ctx.price, + currency=ctx.currency, + sale_mode=ctx.sale_mode, + months=amounts.months, + purchased_gb=amounts.purchased_gb, + purchased_hwid_devices=amounts.purchased_hwid_devices, + tariff_key=amounts.tariff_key, + since_minutes=since_minutes, + ) + if payment is None: + return None + + payment_url = await resolver(ctx, payment) + if not payment_url: + return None + return payment_link_response(payment_url=payment_url, payment_id=payment.payment_id) + + async def mark_payment_failed_creation(session: AsyncSession, payment_id: int) -> None: await payment_dal.update_payment_status_by_db_id(session, payment_id, "failed_creation") await session.commit() diff --git a/backend/bot/payment_providers/shared/http_client.py b/backend/bot/payment_providers/shared/http_client.py index c5dbd6d..3ba72d9 100644 --- a/backend/bot/payment_providers/shared/http_client.py +++ b/backend/bot/payment_providers/shared/http_client.py @@ -1,12 +1,16 @@ from __future__ import annotations +import asyncio import json import logging -from typing import Any, Callable, Dict, Mapping, Optional, Tuple +from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Tuple, Union -from aiohttp import ClientSession, ClientTimeout +from aiohttp import ClientError, ClientSession, ClientTimeout, TraceConfig SuccessCheck = Callable[[int, Any], bool] +TimeoutSource = Union[float, Callable[[], float]] +_TRANSPORT_ATTEMPTS = 2 +_DEFAULT_TIMEOUT_SECONDS = 20.0 def http_ok(status: int, _body: Any) -> bool: @@ -14,6 +18,29 @@ def http_ok(status: int, _body: Any) -> bool: return status == 200 +def _trace_request_ctx(trace_config_ctx: Any) -> Optional[dict]: + ctx = getattr(trace_config_ctx, "trace_request_ctx", None) + return ctx if isinstance(ctx, dict) else None + + +async def _mark_request_headers_sent(session, trace_config_ctx, params) -> None: + ctx = _trace_request_ctx(trace_config_ctx) + if ctx is not None: + ctx["headers_sent"] = True + + +def _payment_trace_config() -> TraceConfig: + trace_config = TraceConfig() + trace_config.on_request_headers_sent.append(_mark_request_headers_sent) + return trace_config + + +def _should_retry_transport_error(exc: Exception, trace_ctx: Mapping[str, Any]) -> bool: + if trace_ctx.get("headers_sent"): + return False + return isinstance(exc, (asyncio.TimeoutError, ClientError, OSError)) + + async def post_json_request( session: ClientSession, url: str, @@ -29,34 +56,47 @@ async def post_json_request( returns ``(False, {"status": ..., "message": ..., "raw": ...?})`` so callers can decide what to do (typically: mark the payment as ``failed_creation``). """ - try: - async with session.post( - url, - json=body, - headers=dict(headers) if headers else None, - ) as response: - response_text = await response.text() - try: - response_data = json.loads(response_text) if response_text else {} - except json.JSONDecodeError: - logging.error("%s: invalid JSON response: %s", log_prefix, response_text) - return False, { - "status": response.status, - "message": "invalid_json", - "raw": response_text, - } - if not is_success(response.status, response_data): - logging.error( - "%s: API returned error (status=%s, body=%s)", + for attempt in range(1, _TRANSPORT_ATTEMPTS + 1): + trace_ctx: dict[str, Any] = {"headers_sent": False} + try: + async with session.post( + url, + json=body, + headers=dict(headers) if headers else None, + trace_request_ctx=trace_ctx, + ) as response: + response_text = await response.text() + try: + response_data = json.loads(response_text) if response_text else {} + except json.JSONDecodeError: + logging.error("%s: invalid JSON response: %s", log_prefix, response_text) + return False, { + "status": response.status, + "message": "invalid_json", + "raw": response_text, + } + if not is_success(response.status, response_data): + logging.error( + "%s: API returned error (status=%s, body=%s)", + log_prefix, + response.status, + response_data, + ) + return False, {"status": response.status, "message": response_data} + return True, response_data + except Exception as exc: + if attempt < _TRANSPORT_ATTEMPTS and _should_retry_transport_error(exc, trace_ctx): + logging.warning( + "%s: transport failed before request headers were sent; retrying (%s/%s): %s", # noqa: E501 log_prefix, - response.status, - response_data, + attempt + 1, + _TRANSPORT_ATTEMPTS, + exc, ) - return False, {"status": response.status, "message": response_data} - return True, response_data - except Exception as exc: - logging.exception("%s: request failed.", log_prefix) - return False, {"message": str(exc)} + continue + logging.exception("%s: request failed.", log_prefix) + return False, {"message": str(exc)} + return False, {"message": "request_failed"} def first_value(data: Optional[Mapping[str, Any]], *keys: str) -> Optional[str]: @@ -76,20 +116,69 @@ class HttpClientMixin: Each subclass calls ``self._init_http_client(total_timeout=...)`` from ``__init__`` and inherits ``_get_session`` / ``close``. The session is created on first use and recreated transparently if it was closed. + + ``total_timeout`` may be a callable so the timeout follows runtime + settings changes (admin overrides apply in-process without a restart). + When the value changes, the next request gets a fresh session; the old + session stays open until its own in-flight requests cannot outlive it. + + Provider API calls are traced so callers can retry transport failures only + when aiohttp has not sent request headers yet. """ - _timeout: ClientTimeout + _timeout_source: TimeoutSource _session: Optional[ClientSession] + _stale_sessions: List[ClientSession] + _session_cleanup_tasks: Set["asyncio.Task[None]"] - def _init_http_client(self, *, total_timeout: float = 20.0) -> None: - self._timeout = ClientTimeout(total=total_timeout) + def _init_http_client(self, *, total_timeout: TimeoutSource = _DEFAULT_TIMEOUT_SECONDS) -> None: + self._timeout_source = total_timeout self._session = None + self._stale_sessions = [] + self._session_cleanup_tasks = set() + + def _current_timeout_seconds(self) -> float: + source = self._timeout_source + try: + seconds = float(source() if callable(source) else source) + except Exception: + return _DEFAULT_TIMEOUT_SECONDS + return seconds if seconds > 0 else _DEFAULT_TIMEOUT_SECONDS async def _get_session(self) -> ClientSession: - if self._session is None or self._session.closed: - self._session = ClientSession(timeout=self._timeout) - return self._session + timeout_seconds = self._current_timeout_seconds() + session = self._session + if session is not None and not session.closed and session.timeout.total != timeout_seconds: + self._session = None + self._stale_sessions.append(session) + task = asyncio.create_task(self._close_stale_session(session)) + self._session_cleanup_tasks.add(task) + task.add_done_callback(self._session_cleanup_tasks.discard) + session = None + if session is None or session.closed: + session = ClientSession( + timeout=ClientTimeout(total=timeout_seconds), + trace_configs=[_payment_trace_config()], + ) + self._session = session + return session + + async def _close_stale_session(self, session: ClientSession) -> None: + # Any request started on this session is bound by its total timeout, + # so after that long it is safe to close without cutting one off. + await asyncio.sleep((session.timeout.total or _DEFAULT_TIMEOUT_SECONDS) + 1.0) + if session in self._stale_sessions: + self._stale_sessions.remove(session) + if not session.closed: + await session.close() async def close(self) -> None: - if self._session and not self._session.closed: - await self._session.close() + for task in list(self._session_cleanup_tasks): + task.cancel() + self._session_cleanup_tasks.clear() + sessions = [self._session, *self._stale_sessions] + self._session = None + self._stale_sessions = [] + for session in sessions: + if session and not session.closed: + await session.close() diff --git a/backend/bot/payment_providers/shared/success.py b/backend/bot/payment_providers/shared/success.py index 26e342a..317c399 100644 --- a/backend/bot/payment_providers/shared/success.py +++ b/backend/bot/payment_providers/shared/success.py @@ -156,6 +156,28 @@ def append_hwid_renewal_note( return f"{text}\n\n{note}" +def append_hwid_renewed_note( + text: str, + translator: Translator, + *, + count: Any, + valid_until: Optional[datetime], +) -> str: + try: + count_int = int(count or 0) + except (TypeError, ValueError): + count_int = 0 + if count_int <= 0: + return text + date_text = valid_until.strftime("%Y-%m-%d") if valid_until else "" + note = translator( + "payment_successful_hwid_devices_renewed_note", + count=format_human_units(count_int), + date=date_text, + ) + return f"{text}\n\n{note}" + + async def send_success_message_to_user( *, bot: Bot, @@ -320,8 +342,37 @@ async def finalize_successful_payment( req.log_prefix, req.payment.payment_id, ) + try: + await payment_dal.update_payment_status_by_db_id( + req.session, + req.payment.payment_id, + "activation_failed", + ) + await req.session.commit() + except Exception: + await req.session.rollback() + logging.exception( + "%s: failed to mark payment %s activation_failed.", + req.log_prefix, + req.payment.payment_id, + ) return None + try: + from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches + + await invalidate_webapp_user_caches( + req.settings, + req.user_id, + include_devices=True, + ) + except Exception: + logging.exception( + "%s: failed to invalidate webapp caches for user %s.", + req.log_prefix, + req.user_id, + ) + db_user, language = await resolve_user_language( req.session, user_id=req.user_id, @@ -363,12 +414,20 @@ async def finalize_successful_payment( ) ) if is_subscription and activation: - success_text = append_hwid_renewal_note( - success_text, - translator, - count=activation.get("hwid_devices_renewal_recommended_count"), - valid_until=activation.get("hwid_devices_valid_until"), - ) + if activation.get("hwid_devices_renewed_count"): + success_text = append_hwid_renewed_note( + success_text, + translator, + count=activation.get("hwid_devices_renewed_count"), + valid_until=final_end_date or activation.get("hwid_devices_renewed_until"), + ) + else: + success_text = append_hwid_renewal_note( + success_text, + translator, + count=activation.get("hwid_devices_renewal_recommended_count"), + valid_until=activation.get("hwid_devices_valid_until"), + ) if req.text_prefix: success_text = f"{req.text_prefix}\n{success_text}" diff --git a/backend/bot/payment_providers/shared/webapp.py b/backend/bot/payment_providers/shared/webapp.py index d8f1c23..45981f0 100644 --- a/backend/bot/payment_providers/shared/webapp.py +++ b/backend/bot/payment_providers/shared/webapp.py @@ -39,13 +39,16 @@ async def finalize_webapp_link_payment( log_prefix="Wata", ) """ - if api_success and provider_payment_id: + # Reuse logic needs both a provider id and a redirect URL; persisting only + # the id creates orphan records that match find_recent but fail verification. + if api_success and provider_payment_id and payment_url: try: await payment_dal.update_provider_payment_and_status( session, payment.payment_id, str(provider_payment_id), new_status or payment.status, + provider_payment_url=payment_url, ) await session.commit() except Exception: diff --git a/backend/bot/payment_providers/shared/webhooks.py b/backend/bot/payment_providers/shared/webhooks.py index 474df6b..204614e 100644 --- a/backend/bot/payment_providers/shared/webhooks.py +++ b/backend/bot/payment_providers/shared/webhooks.py @@ -51,7 +51,7 @@ async def notify_user_payment_failed( message_key: str = "payment_failed", ) -> None: """Send the localized ``payment_failed`` text to the user; never raises.""" - db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id) + db_user = await user_dal.get_user_by_id(session, payment.user_id) language = ( db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE ) diff --git a/backend/bot/payment_providers/stars.py b/backend/bot/payment_providers/stars.py index 0a69ad1..70c7064 100644 --- a/backend/bot/payment_providers/stars.py +++ b/backend/bot/payment_providers/stars.py @@ -344,6 +344,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: months=ctx.months, sale_mode=ctx.sale_mode, traffic_gb=ctx.traffic_gb, + hwid_device_count=ctx.hwid_device_count, ) payment = await create_webapp_payment_record( ctx, diff --git a/backend/bot/payment_providers/wata.py b/backend/bot/payment_providers/wata.py index 422db61..4e5a5e2 100644 --- a/backend/bot/payment_providers/wata.py +++ b/backend/bot/payment_providers/wata.py @@ -54,7 +54,6 @@ from .shared import ( notify_user_payment_failed, parse_payment_callback, payment_failed, - payment_link_response, payment_record_amounts, payment_unavailable, payment_units_for_activation, @@ -63,7 +62,6 @@ from .shared import ( render_link_or_fail, render_payment_link, safe_callback_answer, - sale_mode_base, ) router = Router(name="user_subscription_payments_wata_router") @@ -206,7 +204,7 @@ class WataService(HttpClientMixin): self._default_return_url = default_return_url self._cached_public_key_pem = None # populated by webhook on first verify - self._init_http_client(total_timeout=10) + self._init_http_client(total_timeout=lambda: self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS) if not self.configured: logging.warning("WataService initialized but not fully configured. Payments disabled.") @@ -361,6 +359,19 @@ class WataService(HttpClientMixin): if not success or not isinstance(data, dict): return None + returned_ids = { + str(data.get("id") or "").strip(), + str(data.get("paymentLinkId") or "").strip(), + str(data.get("payment_link_id") or "").strip(), + } + returned_ids.discard("") + if returned_ids and provider_payment_id not in returned_ids: + return None + + order_id = first_value(data, "orderId", "order_id") + if order_id is not None and str(order_id) != str(payment.payment_id): + return None + status = _normalized_wata_status(data) or str(data.get("status") or "").strip().lower() if status and status not in _WATA_LINK_OPENED_STATUSES: return None @@ -889,17 +900,15 @@ async def pay_wata_callback_handler( payment_description = describe_payment(translator, parts) reuse_amounts = payment_record_amounts(months=parts.months, sale_mode=parts.sale_mode) - months_for_lookup = ( - reuse_amounts.months if sale_mode_base(parts.sale_mode) == "subscription" else None - ) reusable_payment = await payment_dal.find_recent_pending_provider_payment( session, user_id=callback.from_user.id, provider="wata", pending_status="pending_wata", amount=parts.price, + currency=currency_code, sale_mode=parts.sale_mode, - months=months_for_lookup, + months=reuse_amounts.months, purchased_gb=reuse_amounts.purchased_gb, purchased_hwid_devices=reuse_amounts.purchased_hwid_devices, tariff_key=reuse_amounts.tariff_key, @@ -974,44 +983,6 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: currency = ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB" - reuse_amounts = payment_record_amounts( - months=ctx.months, - sale_mode=ctx.sale_mode, - traffic_gb=ctx.traffic_gb, - ) - months_for_lookup = ( - reuse_amounts.months if sale_mode_base(ctx.sale_mode) == "subscription" else None - ) - try: - reusable_payment = await payment_dal.find_recent_pending_provider_payment( - ctx.session, - user_id=ctx.user_id, - provider="wata", - pending_status="pending_wata", - amount=ctx.price, - sale_mode=ctx.sale_mode, - months=months_for_lookup, - purchased_gb=reuse_amounts.purchased_gb, - purchased_hwid_devices=reuse_amounts.purchased_hwid_devices, - tariff_key=reuse_amounts.tariff_key, - since_minutes=service.payment_link_ttl_minutes, - ) - except Exception: - logging.exception("Wata WebApp: lookup of reusable payment failed") - reusable_payment = None - - if reusable_payment is not None: - try: - reusable_url = await service.try_reuse_pending_link(reusable_payment) - except Exception: - logging.exception("Wata WebApp: failed to verify reusable link") - reusable_url = None - if reusable_url: - return payment_link_response( - payment_url=reusable_url, - payment_id=reusable_payment.payment_id, - ) - try: payment = await create_webapp_payment_record( ctx, @@ -1043,6 +1014,13 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: ) +async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]: + service: WataService = ctx.request.app.get("wata_service") + if not service or not service.configured: + return None + return await service.try_reuse_pending_link(payment) + + async def wata_webhook_route(request: web.Request) -> web.Response: service: WataService = request.app["wata_service"] return await service.webhook_route(request) @@ -1203,6 +1181,7 @@ SPEC = PaymentProviderSpec( webhook_path=lambda source: "/webhook/wata", webhook_route=wata_webhook_route, create_webapp_payment=create_webapp_payment, + reuse_webapp_payment=reuse_webapp_payment, config_class=WataConfig, presentation_class=WataPresentation, manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST, diff --git a/backend/bot/payment_providers/yookassa.py b/backend/bot/payment_providers/yookassa.py index 3c420fa..c40ecc1 100644 --- a/backend/bot/payment_providers/yookassa.py +++ b/backend/bot/payment_providers/yookassa.py @@ -382,6 +382,10 @@ class YooKassaService: "title": pm_title, "card_last4": last4_val, } + confirmation = getattr(payment_info_yk, "confirmation", None) + confirmation_url = ( + getattr(confirmation, "confirmation_url", None) if confirmation else None + ) return { "id": payment_info_yk.id, "status": payment_info_yk.status, @@ -399,6 +403,7 @@ class YooKassaService: and hasattr(payment_info_yk.captured_at, "isoformat") else None, "payment_method": pm_payload, + "confirmation_url": confirmation_url, "test_mode": getattr(payment_info_yk, "test", None), } else: @@ -448,6 +453,36 @@ def _metadata_value_present(value: Optional[Any]) -> bool: return value is not None and str(value).strip() != "" +def _metadata_int(value: Optional[Any]) -> Optional[int]: + if not _metadata_value_present(value): + return None + try: + return int(float(str(value).strip())) + except (TypeError, ValueError): + return None + + +def _metadata_float(value: Optional[Any]) -> Optional[float]: + if not _metadata_value_present(value): + return None + try: + return float(str(value).strip()) + except (TypeError, ValueError): + return None + + +def _metadata_datetime(value: Optional[Any]) -> Optional[datetime]: + if not _metadata_value_present(value): + return None + try: + parsed = datetime.fromisoformat(str(value).strip().replace("Z", "+00:00")) + except (TypeError, ValueError): + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed + + def _resolve_yookassa_activation_amounts( *, sale_mode_base: str, @@ -559,6 +594,11 @@ async def process_successful_payment( months_for_record = int(subscription_months) if sale_mode_base == "subscription" else 0 payment_value = float(amount_data.get("value", 0.0)) yk_payment_id_from_hook = payment_info_from_webhook.get("id") + hwid_valid_from = _metadata_datetime(metadata.get("hwid_valid_from")) + hwid_valid_until = _metadata_datetime(metadata.get("hwid_valid_until")) + hwid_pricing_period_months = _metadata_int(metadata.get("hwid_pricing_period_months")) + hwid_proration_ratio = _metadata_float(metadata.get("hwid_proration_ratio")) + hwid_full_price = _metadata_float(metadata.get("hwid_full_price")) if _is_hwid_device_sale_base(sale_mode_base) and hwid_devices_count <= 0: logging.error( @@ -574,6 +614,19 @@ async def process_successful_payment( yk_payment_id_from_hook, ) return + if sale_mode_base == "subscription" and hwid_devices_count > 0: + if ( + not hwid_valid_from + or not hwid_valid_until + or hwid_valid_from >= hwid_valid_until + or hwid_full_price is None + ): + logging.error( + "YooKassa subscription+HWID payment %s has invalid HWID metadata: %s", + yk_payment_id_from_hook, + metadata, + ) + return payment_record = None # If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists @@ -600,6 +653,16 @@ async def process_successful_payment( or f"Auto-renewal for {months_for_record or subscription_months} months", provider="yookassa", provider_payment_id=yk_payment_id_from_hook, + sale_mode=sale_mode, + tariff_key=_sale_mode_tariff_key(sale_mode), + purchased_hwid_devices=( + hwid_devices_count if hwid_devices_count > 0 else None + ), + hwid_valid_from=hwid_valid_from, + hwid_valid_until=hwid_valid_until, + hwid_pricing_period_months=hwid_pricing_period_months, + hwid_proration_ratio=hwid_proration_ratio, + hwid_full_price=hwid_full_price, ) payment_db_id = payment_record.payment_id except Exception as e_ensure: @@ -1315,6 +1378,36 @@ def _parse_offer_payload(payload: str) -> Optional[Tuple[float, float, str]]: return None +def _parse_saved_list_payload(payload: str) -> Optional[Tuple[float, float, int, str]]: + parts = payload.split(":") + if len(parts) < 2: + return None + try: + months = float(parts[0]) + price = float(parts[1]) + except (ValueError, IndexError): + return None + + page = 0 + sale_mode = "subscription" + if len(parts) > 2: + try: + page = int(parts[2]) + sale_mode = parts[3] if len(parts) > 3 else "subscription" + except ValueError: + sale_mode = parts[2] + return months, price, page, sale_mode + + +def _metadata_iso(value: Any) -> Optional[str]: + if value is None: + return None + if hasattr(value, "isoformat"): + return value.isoformat() + text = str(value).strip() + return text or None + + def _format_saved_payment_method_title( get_text, network: Optional[str], last4: Optional[str], is_default: bool ) -> str: @@ -1363,6 +1456,9 @@ async def _initiate_yk_payment( return False sale_base = _sale_mode_base(sale_mode) + hwid_device_count = None + if hwid_quote: + hwid_device_count = parse_positive_int_units(hwid_quote.get("device_count")) payment_description = ( get_text("payment_description_traffic", traffic_gb=_format_value(months)) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} @@ -1379,12 +1475,14 @@ async def _initiate_yk_payment( "status": "pending_yookassa", "description": payment_description, "subscription_duration_months": int(months) if sale_base == "subscription" else None, - "sale_mode": sale_base, + "sale_mode": sale_mode, "tariff_key": sale_mode.split("@", 1)[1].split("|", 1)[0] if "@" in sale_mode else None, "purchased_gb": float(months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None, - "purchased_hwid_devices": int(months) if sale_base in HWID_DEVICE_SALE_BASES else None, + "purchased_hwid_devices": ( + int(months) if sale_base in HWID_DEVICE_SALE_BASES else hwid_device_count + ), "hwid_valid_from": hwid_quote.get("valid_from") if hwid_quote else None, "hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None, "hwid_pricing_period_months": hwid_quote.get("pricing_period_months") @@ -1430,6 +1528,19 @@ async def _initiate_yk_payment( yookassa_metadata["traffic_gb"] = str(months) if sale_base in HWID_DEVICE_SALE_BASES: yookassa_metadata["hwid_devices"] = str(months) + elif hwid_device_count: + yookassa_metadata["hwid_devices"] = str(hwid_device_count) + if hwid_quote and hwid_device_count: + hwid_metadata = { + "hwid_valid_from": _metadata_iso(hwid_quote.get("valid_from")), + "hwid_valid_until": _metadata_iso(hwid_quote.get("valid_until")), + "hwid_pricing_period_months": hwid_quote.get("pricing_period_months"), + "hwid_proration_ratio": hwid_quote.get("proration_ratio"), + "hwid_full_price": hwid_quote.get("full_price"), + } + yookassa_metadata.update( + {key: str(value) for key, value in hwid_metadata.items() if value is not None} + ) if payment_method_id: yookassa_metadata["used_saved_payment_method_id"] = payment_method_id @@ -1497,7 +1608,7 @@ async def _initiate_yk_payment( await payment_dal.update_payment_status_by_db_id( session, payment_db_id=db_payment_record.payment_id, - new_status=payment_response_yk.get("status", "pending"), + new_status="pending_yookassa", yk_payment_id=payment_response_yk.get("id"), ) if selected_method_internal_id is not None: @@ -1565,12 +1676,11 @@ async def _initiate_yk_payment( return True if payment_response_yk and payment_method_id: - status_to_store = payment_response_yk.get("status", "pending") try: await payment_dal.update_payment_status_by_db_id( session, payment_db_id=db_payment_record.payment_id, - new_status=status_to_store, + new_status="pending_yookassa", yk_payment_id=payment_response_yk.get("id"), ) if selected_method_internal_id is not None: @@ -1709,22 +1819,6 @@ async def pay_yk_callback_handler( months, price_rub, sale_mode = parsed hwid_quote = None - if _sale_mode_base(sale_mode) in HWID_DEVICE_SALE_BASES: - quoted_parts, hwid_quote = await quote_hwid_callback_parts( - session=session, - user_id=callback.from_user.id, - parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode), - subscription_service=yookassa_service.subscription_service, - currency=default_currency_key_for_settings(settings), - ) - if not quoted_parts: - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - months = quoted_parts.months - price_rub = quoted_parts.price user_id = callback.from_user.id currency_code_for_yk = default_payment_currency_code_for_settings(settings) autopay_enabled = bool( @@ -1786,6 +1880,22 @@ async def pay_yk_callback_handler( pass return + quoted_parts, hwid_quote = await quote_hwid_callback_parts( + session=session, + user_id=callback.from_user.id, + parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode), + subscription_service=yookassa_service.subscription_service, + currency=default_currency_key_for_settings(settings), + ) + if not quoted_parts: + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + months = quoted_parts.months + price_rub = quoted_parts.price + await _initiate_yk_payment( callback, settings=settings, @@ -1863,6 +1973,22 @@ async def pay_yk_new_card_handler( return months, price_rub, sale_mode = parsed + hwid_quote = None + quoted_parts, hwid_quote = await quote_hwid_callback_parts( + session=session, + user_id=callback.from_user.id, + parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode), + subscription_service=yookassa_service.subscription_service, + currency=default_currency_key_for_settings(settings), + ) + if not quoted_parts: + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + months = quoted_parts.months + price_rub = quoted_parts.price user_id = callback.from_user.id currency_code_for_yk = default_payment_currency_code_for_settings(settings) autopay_enabled = bool( @@ -1889,6 +2015,7 @@ async def pay_yk_new_card_handler( save_payment_method=autopay_enabled and autopay_require_binding, back_callback=payment_methods_back_callback(_format_value(months), sale_mode, price_rub), sale_mode=sale_mode, + hwid_quote=hwid_quote, ) try: await callback.answer() @@ -1928,27 +2055,15 @@ async def pay_yk_saved_list_handler( pass return - parts = data_payload.split(":") - if len(parts) < 2: + parsed_saved_list = _parse_saved_list_payload(data_payload) + if not parsed_saved_list: logging.error(f"pay_yk_saved_list payload missing components: {callback.data}") try: await callback.answer(get_text("error_try_again"), show_alert=True) except Exception: pass return - - try: - months = float(parts[0]) - price_rub = float(parts[1]) - page = int(parts[2]) if len(parts) > 2 else 0 - sale_mode = parts[3] if len(parts) > 3 else "subscription" - except (ValueError, IndexError): - logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return + months, price_rub, page, sale_mode = parsed_saved_list autopay_enabled = bool( settings.yookassa_autopayments_active @@ -2138,6 +2253,24 @@ async def pay_yk_use_saved_handler( method_identifier = parts[2] user_id = callback.from_user.id + base_months = months + base_price_rub = price_rub + hwid_quote = None + quoted_parts, hwid_quote = await quote_hwid_callback_parts( + session=session, + user_id=user_id, + parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode), + subscription_service=yookassa_service.subscription_service, + currency=default_currency_key_for_settings(settings), + ) + if not quoted_parts: + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + months = quoted_parts.months + price_rub = quoted_parts.price try: saved_methods = await user_billing_dal.list_user_payment_methods( @@ -2182,10 +2315,13 @@ async def pay_yk_use_saved_handler( price_rub=price_rub, currency_code_for_yk=currency_code_for_yk, save_payment_method=False, - back_callback=f"pay_yk_saved_list:{_format_value(months)}:{price_rub}:{sale_mode}", + back_callback=( + f"pay_yk_saved_list:{_format_value(base_months)}:{base_price_rub}:0:{sale_mode}" + ), payment_method_id=selected_method.provider_payment_method_id, selected_method_internal_id=selected_method.method_id, sale_mode=sale_mode, + hwid_quote=hwid_quote, ) try: await callback.answer() @@ -2754,6 +2890,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: months=ctx.months, sale_mode=ctx.sale_mode, traffic_gb=ctx.traffic_gb, + hwid_device_count=ctx.hwid_device_count, ) payment = await create_webapp_payment_record( ctx, @@ -2775,8 +2912,8 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: } if amounts.traffic_sale: metadata["traffic_gb"] = format_number_for_payload(ctx.traffic_gb or ctx.months) - if amounts.hwid_devices_sale: - metadata["hwid_devices"] = str(int(float(ctx.months))) + if amounts.purchased_hwid_devices: + metadata["hwid_devices"] = str(int(amounts.purchased_hwid_devices)) if amounts.tariff_key: metadata["tariff_key"] = amounts.tariff_key response = await service.create_payment( @@ -2795,7 +2932,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: await payment_dal.update_payment_status_by_db_id( ctx.session, payment.payment_id, - response.get("status", "pending"), + "pending_yookassa", yk_payment_id=response.get("id"), ) await ctx.session.commit() @@ -2806,6 +2943,36 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: return payment_failed() +async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]: + service: YooKassaService = ctx.request.app.get("yookassa_service") + if not service or not service.configured: + return None + + provider_payment_id = str( + getattr(payment, "yookassa_payment_id", None) + or getattr(payment, "provider_payment_id", None) + or "" + ).strip() + if not provider_payment_id: + return None + + info = await service.get_payment_info(provider_payment_id) + if not info or str(info.get("status") or "").strip().lower() != "pending": + return None + if bool(info.get("paid")): + return None + + metadata = info.get("metadata") or {} + expected_metadata = { + "user_id": str(ctx.user_id), + "payment_db_id": str(payment.payment_id), + "sale_mode": str(ctx.sale_mode), + } + if any(str(metadata.get(key) or "") != value for key, value in expected_metadata.items()): + return None + return str(info.get("confirmation_url") or "").strip() or None + + _PRESENTATION_MANIFEST = tuple( ProviderManifestField( key=key, @@ -2940,6 +3107,7 @@ SPEC = PaymentProviderSpec( webhook_route=yookassa_webhook_route, webhook_requires_base_url=True, create_webapp_payment=create_webapp_payment, + reuse_webapp_payment=reuse_webapp_payment, config_class=YooKassaConfig, presentation_class=YooKassaPresentation, manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST, diff --git a/backend/bot/services/config_health_service.py b/backend/bot/services/config_health_service.py new file mode 100644 index 0000000..a7a1687 --- /dev/null +++ b/backend/bot/services/config_health_service.py @@ -0,0 +1,511 @@ +"""Detect common deployment misconfigurations for the admin panel. + +Each check returns :class:`ConfigAlert` items the admin UI renders as +banners on the dashboard and inside the affected sections. Local checks +(filesystem, settings flags) run on every request; network checks +(Telegram webhook, Remnawave panel) are cached for a couple of minutes so +the dashboard stays fast and external APIs are not hammered. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import time +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from bot.utils.request_security import ip_in_allowlist + +logger = logging.getLogger(__name__) + +APP_ROOT = Path(__file__).resolve().parents[3] + +NETWORK_CHECKS_TTL_SECONDS = 120.0 +NETWORK_CHECK_TIMEOUT_SECONDS = 8.0 +_WEBHOOK_ERROR_RECENT_SECONDS = 3600 +_WEBHOOK_PENDING_THRESHOLD = 50 + +SEVERITY_ERROR = "error" +SEVERITY_WARNING = "warning" + +# Admin section ids the frontend routes alerts to. +SECTION_SETTINGS = "settings" +SECTION_PAYMENTS = "payments" +SECTION_BACKUPS = "backups" +SECTION_TARIFFS = "tariffs" +SECTION_APPEARANCE = "appearance" +SECTION_TRANSLATIONS = "translations" +SECTION_USERS = "users" + +_DATA_DIR_SECTIONS = ( + SECTION_BACKUPS, + SECTION_TARIFFS, + SECTION_APPEARANCE, + SECTION_TRANSLATIONS, + SECTION_SETTINGS, +) + +# Every message key an alert can carry. Tests assert each has +# ``admin_health_`` entries in both locale files. +ALL_MESSAGE_KEYS = ( + "data_dir_missing", + "data_dir_not_writable", + "backups_dir_not_writable", + "tariffs_config_invalid", + "locale_overrides_invalid", + "subscription_page_config_invalid", + "provider_not_configured", + "provider_webhook_needs_base_url", + "no_payment_methods", + "mini_app_url_missing", + "mini_app_url_not_https", + "redis_not_configured", + "smtp_incomplete", + "proxy_not_trusted", + "bot_token_invalid", + "telegram_api_error", + "telegram_webhook_missing", + "telegram_webhook_mismatch", + "telegram_webhook_error", + "telegram_webhook_pending", + "panel_api_not_configured", + "panel_api_unreachable", +) + + +@dataclass(frozen=True) +class ConfigAlert: + id: str + severity: str + sections: Tuple[str, ...] + params: Dict[str, Any] = field(default_factory=dict) + # Locale key suffix; defaults to ``id``. Per-provider alerts carry ids + # like ``provider_not_configured:wata`` but share one message key. + message_key: Optional[str] = None + + def as_payload(self) -> Dict[str, Any]: + return { + "id": self.id, + "severity": self.severity, + "sections": list(self.sections), + "message_key": self.message_key or self.id, + "params": dict(self.params), + } + + +# ─── Filesystem checks ───────────────────────────────────────────── + + +def _dir_is_writable(path: Path) -> bool: + probe = path / f".health-probe-{uuid.uuid4().hex}.tmp" + try: + probe.write_text("ok", encoding="utf-8") + probe.unlink() + return True + except OSError: + try: + probe.unlink() + except OSError: + pass + return False + + +def _resolve_data_path(value: str) -> Path: + path = Path(value) + return path if path.is_absolute() else APP_ROOT / path + + +def data_dir_alerts(settings: Any, app_root: Path = APP_ROOT) -> List[ConfigAlert]: + alerts: List[ConfigAlert] = [] + data_dir = app_root / "data" + if not data_dir.is_dir(): + return [ + ConfigAlert( + id="data_dir_missing", + severity=SEVERITY_ERROR, + sections=_DATA_DIR_SECTIONS, + params={"path": str(data_dir)}, + ) + ] + if not _dir_is_writable(data_dir): + alerts.append( + ConfigAlert( + id="data_dir_not_writable", + severity=SEVERITY_ERROR, + sections=_DATA_DIR_SECTIONS, + params={"path": str(data_dir)}, + ) + ) + + backup_dir = _resolve_data_path(str(getattr(settings, "BACKUP_DIR", "") or "data/backups")) + if backup_dir.is_dir() and not _dir_is_writable(backup_dir): + alerts.append( + ConfigAlert( + id="backups_dir_not_writable", + severity=SEVERITY_WARNING, + sections=(SECTION_BACKUPS,), + params={"path": str(backup_dir)}, + ) + ) + return alerts + + +def config_file_alerts(settings: Any) -> List[ConfigAlert]: + alerts: List[ConfigAlert] = [] + + tariffs_path = _resolve_data_path( + str(getattr(settings, "TARIFFS_CONFIG_PATH", "") or "data/tariffs.json") + ) + if tariffs_path.is_file(): + try: + from config.tariffs_config import load_tariffs_config + + load_tariffs_config(tariffs_path) + except Exception as exc: + alerts.append( + ConfigAlert( + id="tariffs_config_invalid", + severity=SEVERITY_ERROR, + sections=(SECTION_TARIFFS,), + params={"path": str(tariffs_path), "error": str(exc)[:300]}, + ) + ) + + locale_overrides_path = APP_ROOT / "data" / "locales-overrides.json" + if locale_overrides_path.is_file(): + try: + json.loads(locale_overrides_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + alerts.append( + ConfigAlert( + id="locale_overrides_invalid", + severity=SEVERITY_WARNING, + sections=(SECTION_TRANSLATIONS,), + params={"path": str(locale_overrides_path), "error": str(exc)[:300]}, + ) + ) + + try: + from config.subscription_guides_config import ( + SubscriptionGuidesConfigError, + subscription_guides_admin_config_json, + ) + + try: + subscription_guides_admin_config_json(settings) + except SubscriptionGuidesConfigError as exc: + alerts.append( + ConfigAlert( + id="subscription_page_config_invalid", + severity=SEVERITY_WARNING, + sections=(SECTION_SETTINGS,), + params={"error": str(exc)[:300]}, + ) + ) + except Exception: # pragma: no cover - defensive import guard + logger.exception("Subscription guides config check failed unexpectedly") + + return alerts + + +# ─── Settings checks ─────────────────────────────────────────────── + + +def payment_provider_alerts(settings: Any, app: Any) -> List[ConfigAlert]: + from bot.payment_providers import iter_provider_specs + + alerts: List[ConfigAlert] = [] + any_enabled = False + seen_services: set = set() + for spec in iter_provider_specs(): + try: + enabled = spec.is_effectively_enabled(settings) + except Exception: # pragma: no cover - provider config errors + logger.exception("Provider %s enabled check failed", spec.id) + continue + if not enabled: + continue + any_enabled = True + if spec.service_key in seen_services: + continue + if spec.service_key: + seen_services.add(spec.service_key) + if not spec.is_service_configured(app): + alerts.append( + ConfigAlert( + id=f"provider_not_configured:{spec.id}", + severity=SEVERITY_ERROR, + sections=(SECTION_SETTINGS,), + params={"provider": spec.label}, + message_key="provider_not_configured", + ) + ) + if spec.webhook_requires_base_url and not getattr(settings, "WEBHOOK_BASE_URL", None): + alerts.append( + ConfigAlert( + id=f"provider_webhook_needs_base_url:{spec.id}", + severity=SEVERITY_ERROR, + sections=(SECTION_SETTINGS,), + params={"provider": spec.label}, + message_key="provider_webhook_needs_base_url", + ) + ) + if not any_enabled: + alerts.append( + ConfigAlert( + id="no_payment_methods", + severity=SEVERITY_WARNING, + sections=(SECTION_SETTINGS, SECTION_PAYMENTS), + ) + ) + return alerts + + +def settings_alerts(settings: Any) -> List[ConfigAlert]: + alerts: List[ConfigAlert] = [] + + mini_app_url = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip() + if not mini_app_url: + alerts.append( + ConfigAlert( + id="mini_app_url_missing", + severity=SEVERITY_WARNING, + sections=(SECTION_SETTINGS,), + ) + ) + elif not mini_app_url.lower().startswith("https://"): + alerts.append( + ConfigAlert( + id="mini_app_url_not_https", + severity=SEVERITY_ERROR, + sections=(SECTION_SETTINGS,), + params={"url": mini_app_url}, + ) + ) + + if not getattr(settings, "REDIS_URL", None): + alerts.append( + ConfigAlert( + id="redis_not_configured", + severity=SEVERITY_WARNING, + sections=(SECTION_SETTINGS,), + ) + ) + + smtp_partial = any( + getattr(settings, key, None) + for key in ("SMTP_USERNAME", "SMTP_PASSWORD", "SMTP_FROM_EMAIL") + ) + if smtp_partial and not getattr(settings, "email_auth_configured", False): + alerts.append( + ConfigAlert( + id="smtp_incomplete", + severity=SEVERITY_WARNING, + sections=(SECTION_SETTINGS,), + ) + ) + + return alerts + + +def proxy_alerts(request: Any, settings: Any) -> List[ConfigAlert]: + """Warn when the admin request itself came through an untrusted proxy. + + In that case provider webhooks with IP allowlists will see the proxy + address instead of the real sender and may reject valid callbacks. + """ + headers = getattr(request, "headers", None) or {} + forwarded = headers.get("X-Forwarded-For") + remote = getattr(request, "remote", None) + if not forwarded or not remote: + return [] + if ip_in_allowlist(remote, getattr(settings, "trusted_proxies", None)): + return [] + return [ + ConfigAlert( + id="proxy_not_trusted", + severity=SEVERITY_WARNING, + sections=(SECTION_SETTINGS,), + params={"remote": str(remote)}, + ) + ] + + +# ─── Network checks (cached) ─────────────────────────────────────── + + +async def telegram_alerts(bot: Any, settings: Any) -> List[ConfigAlert]: + if bot is None: + return [] + try: + info = await asyncio.wait_for(bot.get_webhook_info(), timeout=NETWORK_CHECK_TIMEOUT_SECONDS) + except Exception as exc: + if exc.__class__.__name__ in {"TelegramUnauthorizedError", "TelegramNotFound"}: + return [ + ConfigAlert( + id="bot_token_invalid", + severity=SEVERITY_ERROR, + sections=(SECTION_SETTINGS,), + ) + ] + return [ + ConfigAlert( + id="telegram_api_error", + severity=SEVERITY_WARNING, + sections=(SECTION_SETTINGS,), + params={"error": str(exc)[:300]}, + ) + ] + + alerts: List[ConfigAlert] = [] + actual_url = str(getattr(info, "url", "") or "") + base_url = str(getattr(settings, "WEBHOOK_BASE_URL", "") or "").rstrip("/") + expected_url = ( + f"{base_url}{getattr(settings, 'telegram_webhook_path', '/tg/webhook')}" if base_url else "" + ) + if not actual_url: + alerts.append( + ConfigAlert( + id="telegram_webhook_missing", + severity=SEVERITY_ERROR, + sections=(SECTION_SETTINGS,), + ) + ) + elif expected_url and actual_url != expected_url: + alerts.append( + ConfigAlert( + id="telegram_webhook_mismatch", + severity=SEVERITY_WARNING, + sections=(SECTION_SETTINGS,), + params={"actual": actual_url, "expected": expected_url}, + ) + ) + + pending = int(getattr(info, "pending_update_count", 0) or 0) + last_error_date = getattr(info, "last_error_date", None) + last_error_ts: Optional[float] = None + if last_error_date is not None: + last_error_ts = ( + last_error_date.timestamp() + if hasattr(last_error_date, "timestamp") + else float(last_error_date) + ) + if ( + pending > 0 + and last_error_ts + and (time.time() - last_error_ts) < _WEBHOOK_ERROR_RECENT_SECONDS + ): + alerts.append( + ConfigAlert( + id="telegram_webhook_error", + severity=SEVERITY_WARNING, + sections=(SECTION_SETTINGS,), + params={"error": str(getattr(info, "last_error_message", "") or "")[:300]}, + ) + ) + + if pending > _WEBHOOK_PENDING_THRESHOLD: + alerts.append( + ConfigAlert( + id="telegram_webhook_pending", + severity=SEVERITY_WARNING, + sections=(SECTION_SETTINGS,), + params={"count": pending}, + ) + ) + return alerts + + +async def panel_alerts(panel_service: Any, settings: Any) -> List[ConfigAlert]: + if not getattr(settings, "PANEL_API_URL", None) or not getattr(settings, "PANEL_API_KEY", None): + return [ + ConfigAlert( + id="panel_api_not_configured", + severity=SEVERITY_ERROR, + sections=(SECTION_SETTINGS, SECTION_USERS, SECTION_TARIFFS), + ) + ] + if panel_service is None: + return [] + try: + stats = await asyncio.wait_for( + panel_service.get_system_stats(), timeout=NETWORK_CHECK_TIMEOUT_SECONDS + ) + except Exception as exc: + logger.debug("Panel health check failed: %s", exc) + stats = None + if stats is None: + return [ + ConfigAlert( + id="panel_api_unreachable", + severity=SEVERITY_ERROR, + sections=(SECTION_SETTINGS, SECTION_USERS), + params={"url": str(getattr(settings, "PANEL_API_URL", "") or "")}, + ) + ] + return [] + + +# ─── Aggregation ─────────────────────────────────────────────────── + +_network_cache: Dict[int, Tuple[float, List[ConfigAlert]]] = {} +_network_cache_lock = asyncio.Lock() + + +def local_alerts(request: Any, settings: Any, app: Any) -> List[ConfigAlert]: + alerts: List[ConfigAlert] = [] + for collect in ( + lambda: data_dir_alerts(settings), + lambda: config_file_alerts(settings), + lambda: payment_provider_alerts(settings, app), + lambda: settings_alerts(settings), + lambda: proxy_alerts(request, settings), + ): + try: + alerts.extend(collect()) + except Exception: # pragma: no cover - one broken check must not hide others + logger.exception("Config health check failed") + return alerts + + +async def network_alerts(app: Any, settings: Any, *, refresh: bool = False) -> List[ConfigAlert]: + cache_key = id(settings) + now = time.monotonic() + if not refresh: + cached = _network_cache.get(cache_key) + if cached and (now - cached[0]) < NETWORK_CHECKS_TTL_SECONDS: + return cached[1] + + async with _network_cache_lock: + if not refresh: + cached = _network_cache.get(cache_key) + if cached and (time.monotonic() - cached[0]) < NETWORK_CHECKS_TTL_SECONDS: + return cached[1] + + results = await asyncio.gather( + telegram_alerts(app.get("bot"), settings), + panel_alerts(app.get("panel_service"), settings), + return_exceptions=True, + ) + alerts: List[ConfigAlert] = [] + for result in results: + if isinstance(result, BaseException): + logger.exception("Network config health check failed", exc_info=result) + continue + alerts.extend(result) + _network_cache[cache_key] = (time.monotonic(), alerts) + return alerts + + +async def collect_config_alerts(request: Any, *, refresh: bool = False) -> List[Dict[str, Any]]: + app = request.app + settings = app["settings"] + alerts = local_alerts(request, settings, app) + alerts.extend(await network_alerts(app, settings, refresh=refresh)) + order = {SEVERITY_ERROR: 0, SEVERITY_WARNING: 1} + alerts.sort(key=lambda alert: (order.get(alert.severity, 2), alert.id)) + return [alert.as_payload() for alert in alerts] diff --git a/backend/bot/services/email_auth_service.py b/backend/bot/services/email_auth_service.py index 9391932..82e9ab9 100644 --- a/backend/bot/services/email_auth_service.py +++ b/backend/bot/services/email_auth_service.py @@ -10,13 +10,13 @@ from dataclasses import dataclass from datetime import datetime, timedelta, timezone from email.message import EmailMessage from email.utils import formataddr -from typing import Optional +from typing import Optional, Sequence from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from bot.middlewares.i18n import JsonI18n -from bot.services.email_templates import EmailContent, render_login_code +from bot.services.email_templates import EmailContent, EmailInlineImage, render_login_code from bot.services.message_audit import log_user_message_delivery from config.settings import Settings from db.dal import security_dal, user_dal @@ -61,11 +61,39 @@ def normalize_email(value: str) -> str: return (value or "").strip().lower() +def email_domain(value: Optional[str]) -> str: + email = normalize_email(value or "") + if "@" not in email: + return "" + return email.rsplit("@", 1)[1].strip().lower().rstrip(".") + + def is_valid_email(value: str) -> bool: email = normalize_email(value) return bool(email and len(email) <= 254 and EMAIL_RE.match(email)) +def _split_disposable_domain_values(value: str) -> list[str]: + return [item.strip() for item in re.split(r"[,;\s]+", value or "") if item.strip()] + + +def is_disposable_email(value: Optional[str], settings: Settings) -> bool: + domain = email_domain(value) + if not domain: + return False + blocked_domains = getattr(settings, "disposable_email_domains", None) + if blocked_domains is None: + blocked_domains = _split_disposable_domain_values( + str(getattr(settings, "DISPOSABLE_EMAIL_DOMAINS", "") or "") + ) + blocked_domains = blocked_domains or [] + for blocked in blocked_domains: + normalized = str(blocked or "").strip().lower().lstrip("@.") + if normalized and (domain == normalized or domain.endswith(f".{normalized}")): + return True + return False + + 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}" @@ -453,6 +481,7 @@ class EmailAuthService: subject: str, body: str, html_body: Optional[str] = None, + inline_images: Sequence[EmailInlineImage] = (), ) -> None: await asyncio.to_thread( self._send_custom_email_sync, @@ -460,6 +489,7 @@ class EmailAuthService: subject=subject, body=body, html_body=html_body, + inline_images=inline_images, ) async def send_rendered_email( @@ -473,6 +503,7 @@ class EmailAuthService: subject=content.subject, body=content.text, html_body=content.html, + inline_images=content.inline_images, ) def _send_code_email_sync( @@ -493,17 +524,13 @@ class EmailAuthService: i18n=self.i18n, ) - message = EmailMessage() - message["Subject"] = content.subject - message["From"] = formataddr( - ( - self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE, - self.settings.SMTP_FROM_EMAIL or "", - ) + message = self._build_email_message( + email=email, + subject=content.subject, + body=content.text, + html_body=content.html, + inline_images=content.inline_images, ) - message["To"] = email - message.set_content(content.text) - message.add_alternative(content.html, subtype="html") context = ssl.create_default_context() smtp_host = self.settings.SMTP_HOST @@ -554,19 +581,15 @@ class EmailAuthService: subject: str, body: str, html_body: Optional[str] = None, + inline_images: Sequence[EmailInlineImage] = (), ) -> 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 = self._build_email_message( + email=email, + subject=subject, + body=body, + html_body=html_body, + inline_images=inline_images, ) - message["To"] = email - message.set_content(body) - if html_body: - message.add_alternative(html_body, subtype="html") context = ssl.create_default_context() smtp_host = self.settings.SMTP_HOST @@ -610,6 +633,64 @@ class EmailAuthService: if last_error: raise last_error + def _build_email_message( + self, + *, + email: str, + subject: str, + body: str, + html_body: Optional[str] = None, + inline_images: Sequence[EmailInlineImage] = (), + ) -> EmailMessage: + 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) + if html_body: + message.add_alternative(html_body, subtype="html") + self._attach_inline_images(message, inline_images) + return message + + @staticmethod + def _attach_inline_images( + message: EmailMessage, + inline_images: Sequence[EmailInlineImage], + ) -> None: + if not inline_images: + return + html_part = message.get_body(("html",)) + if html_part is None: + return + + for image in inline_images: + content_type = (image.content_type or "").split(";", 1)[0].strip().lower() + if "/" not in content_type: + continue + maintype, subtype = content_type.split("/", 1) + if maintype != "image" or not subtype: + continue + + body = bytes(image.data or b"") + content_id = (image.content_id or "").strip() + if not body or not content_id: + continue + + cid_header = content_id + if not (cid_header.startswith("<") and cid_header.endswith(">")): + cid_header = f"<{cid_header}>" + html_part.add_related( + body, + maintype=maintype, + subtype=subtype, + cid=cid_header, + ) + def _send_message_via_smtp( self, *, diff --git a/backend/bot/services/email_templates.py b/backend/bot/services/email_templates.py index f8ed96a..f88b059 100644 --- a/backend/bot/services/email_templates.py +++ b/backend/bot/services/email_templates.py @@ -10,8 +10,10 @@ copy goes through the shared `JsonI18n` instance so translations live in from __future__ import annotations import html +import io import re from dataclasses import dataclass +from pathlib import Path from typing import TYPE_CHECKING, Optional, Sequence, Tuple from urllib.parse import urlsplit @@ -27,6 +29,28 @@ _TEXT_MUTED = "#9aa3b2" _TEXT_DIM = "#5d6573" _DEFAULT_ACCENT = "#00fe7a" _HEX_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$") +_EMAIL_LOGO_CONTENT_ID = "webapp-logo" +_WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo" +_WEBAPP_UPLOADED_LOGO_DIR = Path(__file__).resolve().parents[3] / "data" / "webapp-logo" / "uploads" +_WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024 +_UPLOADED_LOGO_RE = re.compile(r"logo-[0-9a-f]{16}\.(?:gif|ico|jpe?g|png|svg|webp)") +_LOGO_CONTENT_TYPES = { + ".gif": "image/gif", + ".ico": "image/x-icon", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webp": "image/webp", +} +_EMAIL_LOGO_PNG_FALLBACK_EXTENSIONS = {".ico", ".webp"} + + +@dataclass(frozen=True) +class EmailInlineImage: + content_id: str + content_type: str + data: bytes @dataclass(frozen=True) @@ -34,6 +58,13 @@ class EmailContent: subject: str text: str html: str + inline_images: Tuple[EmailInlineImage, ...] = () + + +@dataclass(frozen=True) +class _EmailLayout: + html: str + inline_images: Tuple[EmailInlineImage, ...] = () def _safe_color(value: Optional[str]) -> str: @@ -45,11 +76,22 @@ def _safe_color(value: Optional[str]) -> str: return _DEFAULT_ACCENT +def _theme_accent(settings: Settings) -> str: + primary = _safe_color(getattr(settings, "WEBAPP_PRIMARY_COLOR", None)) + try: + catalog = getattr(settings, "webapp_themes_catalog", None) + if catalog is None: + return primary + from config.webapp_themes_config import effective_webapp_theme_accent + + return _safe_color(effective_webapp_theme_accent(catalog, primary)) + except Exception: + return primary + + def _public_logo_url(settings: Settings) -> Optional[str]: """Email recipients can't reach the in-app /webapp-logo proxy, so only a stored public https URL can be used directly. Anything else is dropped.""" - if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): - return None raw = (settings.WEBAPP_LOGO_URL or "").strip() if not raw: return None @@ -59,6 +101,91 @@ def _public_logo_url(settings: Settings) -> Optional[str]: return raw +def _uploaded_logo_filename(url: str) -> Optional[str]: + parsed = urlsplit(str(url or "")) + path = parsed.path if parsed.scheme or parsed.netloc else str(url or "") + prefix = f"{_WEBAPP_UPLOADED_LOGO_PATH}/" + if not path.startswith(prefix): + return None + filename = path.removeprefix(prefix) + return filename if _UPLOADED_LOGO_RE.fullmatch(filename) else None + + +def _inline_uploaded_logo(settings: Settings) -> Optional[EmailInlineImage]: + filename = _uploaded_logo_filename((settings.WEBAPP_LOGO_URL or "").strip()) + if not filename: + return None + + content_type = _LOGO_CONTENT_TYPES.get(Path(filename).suffix.lower()) + if not content_type: + return None + + try: + uploads_dir = _WEBAPP_UPLOADED_LOGO_DIR.resolve() + logo_path = (uploads_dir / filename).resolve() + logo_path.relative_to(uploads_dir) + body = logo_path.read_bytes() + except (OSError, ValueError): + return None + + if not body or len(body) > _WEBAPP_LOGO_MAX_BYTES: + return None + + content_type, body = _email_logo_payload(filename, content_type, body) + + return EmailInlineImage( + content_id=_EMAIL_LOGO_CONTENT_ID, + content_type=content_type, + data=body, + ) + + +def _email_logo_payload(filename: str, content_type: str, body: bytes) -> Tuple[str, bytes]: + suffix = Path(filename).suffix.lower() + if suffix not in _EMAIL_LOGO_PNG_FALLBACK_EXTENSIONS: + return content_type, body + + png_body = _static_raster_logo_to_png(body) + if png_body and len(png_body) <= _WEBAPP_LOGO_MAX_BYTES: + return "image/png", png_body + return content_type, body + + +def _static_raster_logo_to_png(body: bytes) -> Optional[bytes]: + try: + from PIL import Image, ImageOps, UnidentifiedImageError + except ImportError: + return None + + try: + with Image.open(io.BytesIO(body)) as image: + image.seek(0) + if getattr(image, "is_animated", False): + return None + source = ImageOps.exif_transpose(image).convert("RGBA") + except (OSError, UnidentifiedImageError, ValueError, EOFError): + return None + + if source.width < 1 or source.height < 1 or source.width > 8192 or source.height > 8192: + return None + + output = io.BytesIO() + source.save(output, format="PNG", optimize=True) + return output.getvalue() + + +def _email_logo(settings: Settings) -> Tuple[Optional[str], Tuple[EmailInlineImage, ...]]: + inline_logo = _inline_uploaded_logo(settings) + if inline_logo: + return f"cid:{inline_logo.content_id}", (inline_logo,) + + public_url = _public_logo_url(settings) + if public_url: + return public_url, () + + return None, () + + def _brand_title(settings: Settings) -> str: title = (settings.WEBAPP_TITLE or "").strip() return title or "Subscription" @@ -98,20 +225,21 @@ def _layout( intro_html: str, body_html: str, footer_html: str, -) -> str: - accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR) + accent: Optional[str] = None, +) -> _EmailLayout: + accent = _safe_color(accent) if accent else _theme_accent(settings) brand_title = html.escape(_brand_title(settings)) - logo_url = _public_logo_url(settings) + logo_url, inline_images = _email_logo(settings) html_lang = html.escape((language_code or "en").replace("_", "-"), quote=True) logo_block = "" if logo_url: logo_block = ( f'' + f'border-radius:16px;background:transparent;background-color:transparent;">' ) - return f""" + layout_html = f""" @@ -151,6 +279,16 @@ def _layout( """ # noqa: E501 + return _EmailLayout(html=layout_html, inline_images=inline_images) + + +def _email_content(*, subject: str, text: str, layout: _EmailLayout) -> EmailContent: + return EmailContent( + subject=subject, + text=text, + html=layout.html, + inline_images=layout.inline_images, + ) def _info_rows_html(rows: Sequence[Tuple[str, str]]) -> str: @@ -258,7 +396,7 @@ def render_login_code( i18n = _resolve_i18n(i18n) lang = _normalize_lang(language_code, settings) minutes = _format_minutes(settings.EMAIL_CODE_TTL_SECONDS) - accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR) + accent = _theme_accent(settings) brand = _brand_title(settings) template_prefix = "email_set_password_code" if purpose == "set_password" else "email_login_code" safe_magic_link = (magic_link or "").strip() if template_prefix == "email_login_code" else "" @@ -318,8 +456,9 @@ def render_login_code( intro_html=html.escape(intro), body_html=body_html, footer_html=footer, + accent=accent, ) - return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered) + return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered) def render_account_merged( @@ -372,7 +511,7 @@ def render_account_merged( body_html=body_html, footer_html=footer, ) - return EmailContent(subject=subject, text=text, html=rendered) + return _email_content(subject=subject, text=text, layout=rendered) def render_payment_success( @@ -391,7 +530,7 @@ def render_payment_success( ) -> EmailContent: i18n = _resolve_i18n(i18n) lang = _normalize_lang(language_code, settings) - accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR) + accent = _theme_accent(settings) brand = _brand_title(settings) sale_base = (sale_mode or "").split("@", 1)[0].split("|", 1)[0] is_traffic = sale_base in { @@ -505,8 +644,9 @@ def render_payment_success( intro_html=html.escape(intro), body_html="".join(body_parts), footer_html=footer, + accent=accent, ) - return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered) + return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered) def render_user_notification( @@ -523,7 +663,7 @@ def render_user_notification( ) -> EmailContent: i18n = _resolve_i18n(i18n) lang = _normalize_lang(language_code, settings) - accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR) + accent = _theme_accent(settings) brand = _brand_title(settings) safe_dashboard_url = (dashboard_url or "").strip() final_subject = (subject or "").strip() or _t_text( @@ -556,6 +696,7 @@ def render_user_notification( intro_html=html.escape(final_intro), body_html="".join(body_parts), footer_html=footer, + accent=accent, ) text_lines = [final_subject, "", _telegram_html_to_text(message_text)] if safe_dashboard_url: @@ -567,7 +708,7 @@ def render_user_notification( ), ] ) - return EmailContent(subject=final_subject, text="\n".join(text_lines), html=rendered) + return _email_content(subject=final_subject, text="\n".join(text_lines), layout=rendered) def render_subscription_expiring( @@ -581,7 +722,7 @@ def render_subscription_expiring( ) -> EmailContent: i18n = _resolve_i18n(i18n) lang = _normalize_lang(language_code, settings) - accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR) + accent = _theme_accent(settings) brand = _brand_title(settings) safe_dashboard_url = (dashboard_url or "").strip() days = max(0, int(days_left)) @@ -630,8 +771,9 @@ def render_subscription_expiring( intro_html=html.escape(intro), body_html="".join(body_parts), footer_html=footer, + accent=accent, ) - return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered) + return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered) def _subscription_lifecycle_title( @@ -678,7 +820,7 @@ def render_subscription_lifecycle_notification( ) -> EmailContent: i18n = _resolve_i18n(i18n) lang = _normalize_lang(language_code, settings) - accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR) + accent = _theme_accent(settings) brand = _brand_title(settings) safe_dashboard_url = (dashboard_url or "").strip() end_date = end_date_text or "—" @@ -718,6 +860,7 @@ def render_subscription_lifecycle_notification( intro_html=html.escape(intro), body_html="".join(body_parts), footer_html=footer, + accent=accent, ) text_lines = [subject, "", message_text] @@ -733,7 +876,7 @@ def render_subscription_lifecycle_notification( ), ] ) - return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered) + return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered) def _support_email( @@ -752,7 +895,7 @@ def _support_email( i18n = _resolve_i18n(i18n) lang = _normalize_lang(language, settings) brand = _brand_title(settings) - accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR) + accent = _theme_accent(settings) safe_url = (ticket_url or "").strip() footer = _t_html(i18n, lang, "email_footer_auto", brand=brand) localized_rows = [ @@ -775,6 +918,7 @@ def _support_email( intro_html=html.escape(intro), body_html="".join(body_parts), footer_html=footer, + accent=accent, ) text_lines = [ intro, @@ -785,7 +929,7 @@ def _support_email( ] if safe_url: text_lines.extend(["", safe_url]) - return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered) + return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered) def render_support_new_ticket_admin( diff --git a/backend/bot/services/locale_override_service.py b/backend/bot/services/locale_override_service.py index 9318a45..7b50740 100644 --- a/backend/bot/services/locale_override_service.py +++ b/backend/bot/services/locale_override_service.py @@ -108,6 +108,7 @@ LOCALE_GROUPS = [ "prefixes": ( "admin_user_", "admin_users_", + "admin_hwid_", "admin_ban_", "admin_unban_", "admin_banned_", @@ -115,6 +116,7 @@ LOCALE_GROUPS = [ "admin_traffic_grant_", "admin_view_banned_", "user_card_", + "user_hwid_", "user_premium_", "user_regular_", "user_traffic_", @@ -203,7 +205,6 @@ LOCALE_GROUPS = [ "admin_settings_field_subscription_mini_app_url", "admin_settings_field_support_link", "admin_settings_field_server_status_url", - "admin_settings_field_terms_", "admin_settings_field_privacy_", "admin_settings_field_user_agreement_", "appearance_", diff --git a/backend/bot/services/notification_service.py b/backend/bot/services/notification_service.py index 85db43e..dc95b1a 100644 --- a/backend/bot/services/notification_service.py +++ b/backend/bot/services/notification_service.py @@ -237,11 +237,12 @@ class NotificationService: text: str, path: str, fallback_url: str, + web_app_button: bool = True, ) -> InlineKeyboardButton: webapp_url = self._support_webapp_url(path) - if webapp_url: + if webapp_url and web_app_button: return InlineKeyboardButton(text=text, web_app=WebAppInfo(url=webapp_url)) - return InlineKeyboardButton(text=text, url=fallback_url) + return InlineKeyboardButton(text=text, url=webapp_url or fallback_url) def _support_text(self, language: Optional[str], key: str, fallback: str) -> str: if not self.i18n: @@ -316,7 +317,14 @@ class NotificationService: return enabled return self._coerce_bool_setting(raw_value, enabled) - def _support_keyboard(self, ticket, user, *, admin: bool = True) -> InlineKeyboardMarkup: + def _support_keyboard( + self, + ticket, + user, + *, + admin: bool = True, + web_app_buttons: bool = True, + ) -> InlineKeyboardMarkup: ticket_path = ( f"/admin/support/{ticket.ticket_id}" if admin else f"/support/{ticket.ticket_id}" ) @@ -326,6 +334,7 @@ class NotificationService: text="Открыть тикет", path=ticket_path, fallback_url=self._support_ticket_url(ticket.ticket_id, admin=admin), + web_app_button=web_app_buttons, ) ] ] @@ -342,12 +351,35 @@ class NotificationService: text="Карточка пользователя", path=user_card_path, fallback_url=self._support_ticket_url(ticket.ticket_id, admin=True), + web_app_button=web_app_buttons, ) ) if profile_row: rows.append(profile_row) return InlineKeyboardMarkup(inline_keyboard=rows) + def _support_log_thread_id(self) -> Optional[int]: + return getattr(self.settings, "LOG_SUPPORT_THREAD_ID", None) + + def _support_thread_is_configured(self) -> bool: + return bool(getattr(self.settings, "LOG_CHAT_ID", None) and self._support_log_thread_id()) + + async def _send_admin_support_telegram( + self, + message: str, + *, + admin_markup: InlineKeyboardMarkup, + log_markup: InlineKeyboardMarkup, + ) -> None: + thread_id = self._support_log_thread_id() + if not self._support_thread_is_configured(): + await self._send_to_admins(message, reply_markup=admin_markup) + await self._send_to_log_channel( + message, + thread_id=thread_id, + reply_markup=log_markup, + ) + def _support_user_keyboard(self, ticket, user) -> InlineKeyboardMarkup: button_text = self._support_text( getattr(user, "language_code", None), @@ -415,12 +447,12 @@ class NotificationService: f"статус: {hd.quote(str(snapshot.get('panel_status') or '—'))}\n\n" f"Текст обращения\n{hd.quote(preview)}" ) - keyboard = self._support_keyboard(ticket, user, admin=True) - await self._send_to_admins(message, reply_markup=keyboard) - await self._send_to_log_channel( + admin_keyboard = self._support_keyboard(ticket, user, admin=True) + log_keyboard = self._support_keyboard(ticket, user, admin=True, web_app_buttons=False) + await self._send_admin_support_telegram( message, - thread_id=getattr(self.settings, "LOG_SUPPORT_THREAD_ID", None), - reply_markup=keyboard, + admin_markup=admin_keyboard, + log_markup=log_keyboard, ) await self._send_admin_support_email( render_support_new_ticket_admin, @@ -456,13 +488,13 @@ class NotificationService: f"💬 Ответ пользователя в тикете #{ticket.ticket_id}\n" f"{hd.quote(user_display)}{unread_line}\n\n{hd.quote(preview)}" ) - keyboard = self._support_keyboard(ticket, user, admin=True) if send_telegram and getattr(self.settings, "LOG_SUPPORT", True): - await self._send_to_admins(text, reply_markup=keyboard) - await self._send_to_log_channel( + admin_keyboard = self._support_keyboard(ticket, user, admin=True) + log_keyboard = self._support_keyboard(ticket, user, admin=True, web_app_buttons=False) + await self._send_admin_support_telegram( text, - thread_id=getattr(self.settings, "LOG_SUPPORT_THREAD_ID", None), - reply_markup=keyboard, + admin_markup=admin_keyboard, + log_markup=log_keyboard, ) if send_email: await self._send_admin_support_email( diff --git a/backend/bot/services/panel_api_service.py b/backend/bot/services/panel_api_service.py index 4ff5c43..f8c6ce4 100644 --- a/backend/bot/services/panel_api_service.py +++ b/backend/bot/services/panel_api_service.py @@ -15,6 +15,40 @@ from config.settings import Settings from db.dal import panel_sync_dal from db.models import PanelSyncStatus +# Static endpoint prefixes used as log/metric labels instead of the raw request +# path. Endpoints embed user identifiers (telegram id, username, email, uuids), +# so logging the path verbatim would leak private data into log files; the +# label keeps only the constant prefix. Longest prefixes first so e.g. +# "/users/by-email/..." does not collapse into "/users". +_ENDPOINT_LOG_LABELS = ( + "/users/by-telegram-id", + "/users/by-username", + "/users/by-email", + "/users", + "/subscriptions/subpage-config", + "/subscription-page-configs", + "/hwid/devices/delete", + "/hwid/devices", + "/system/stats/bandwidth", + "/system/stats/nodes", + "/system/stats", + "/system/tools/happ/encrypt", + "/bandwidth-stats/users", + "/bandwidth-stats/nodes", + "/internal-squads", + "/hosts", + "/nodes", +) + + +def _endpoint_log_label(endpoint: str) -> str: + """Map a request endpoint to a constant, identifier-free label for logs.""" + path = "/" + endpoint.split("?", 1)[0].strip("/") + for label in _ENDPOINT_LOG_LABELS: + if path == label or path.startswith(label + "/"): + return label + return "/other" + class PanelApiService: # Status codes returned by _request_once for failures we consider transient @@ -22,6 +56,11 @@ class PanelApiService: _TRANSIENT_STATUS_CODES = (-1, -3) _SAFE_METHODS = frozenset({"GET", "HEAD"}) _RETRY_BACKOFF_SECONDS = 0.5 + _MIN_TIMEOUT_SECONDS = 0.1 + _DEFAULT_TOTAL_TIMEOUT_SECONDS = 25.0 + _DEFAULT_CONNECT_TIMEOUT_SECONDS = 8.0 + _DEFAULT_SOCK_CONNECT_TIMEOUT_SECONDS = 8.0 + _DEFAULT_SOCK_READ_TIMEOUT_SECONDS = 15.0 def __init__(self, settings: Settings): self.settings = settings @@ -70,17 +109,46 @@ class PanelApiService: async def _get_session(self) -> aiohttp.ClientSession: if self._session is None or self._session.closed: - # Separate connect/read timeouts so a stuck panel does not hold a - # bot worker for the full window; total caps worst-case latency. - timeout = aiohttp.ClientTimeout( - total=15, - connect=3, - sock_connect=3, - sock_read=10, - ) - self._session = aiohttp.ClientSession(timeout=timeout) + self._session = aiohttp.ClientSession(timeout=self._client_timeout()) return self._session + @classmethod + def _timeout_setting(cls, settings: Settings, name: str, default: float) -> float: + raw_value = getattr(settings, name, default) + try: + value = float(raw_value) + except (TypeError, ValueError): + return default + if value <= 0: + return default + return max(cls._MIN_TIMEOUT_SECONDS, value) + + def _client_timeout(self) -> aiohttp.ClientTimeout: + # Separate connect/read timeouts so a slow panel route has more room, + # while genuinely stuck requests still cannot pin a worker forever. + return aiohttp.ClientTimeout( + total=self._timeout_setting( + self.settings, + "PANEL_API_TOTAL_TIMEOUT_SECONDS", + self._DEFAULT_TOTAL_TIMEOUT_SECONDS, + ), + connect=self._timeout_setting( + self.settings, + "PANEL_API_CONNECT_TIMEOUT_SECONDS", + self._DEFAULT_CONNECT_TIMEOUT_SECONDS, + ), + sock_connect=self._timeout_setting( + self.settings, + "PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS", + self._DEFAULT_SOCK_CONNECT_TIMEOUT_SECONDS, + ), + sock_read=self._timeout_setting( + self.settings, + "PANEL_API_SOCK_READ_TIMEOUT_SECONDS", + self._DEFAULT_SOCK_READ_TIMEOUT_SECONDS, + ), + ) + async def close_session(self): if self._session and not self._session.closed: await self._session.close() @@ -121,6 +189,15 @@ class PanelApiService: for attempt in range(max_attempts): result = await self._request_once(method, endpoint, log_full_response, **kwargs) if attempt + 1 < max_attempts and self._is_transient_error(result): + logging.warning( + "Retrying transient Panel API request method=%s endpoint=%s " + "attempt=%s/%s status_code=%s", + method.upper(), + _endpoint_log_label(endpoint), + attempt + 1, + max_attempts, + result.get("status_code") if isinstance(result, dict) else None, + ) await asyncio.sleep(self._RETRY_BACKOFF_SECONDS) continue return result @@ -137,6 +214,7 @@ class PanelApiService: headers = await self._prepare_headers() url_for_request = f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}" + endpoint_label = _endpoint_log_label(endpoint) current_params = kwargs.get("params") url_with_params_for_log = url_for_request @@ -158,8 +236,8 @@ class PanelApiService: ) except Exception: log_prefix += f" | Payload: {str(json_payload_for_log)[:300]}..." + started = time.monotonic() try: - started = time.monotonic() async with aiohttp_session.request( method.upper(), url_for_request, headers=headers, **kwargs ) as response: @@ -169,7 +247,7 @@ class PanelApiService: "metric panel_latency_seconds=%.3f method=%s endpoint=%s status=%s", time.monotonic() - started, method.upper(), - endpoint, + endpoint_label, response_status, ) @@ -228,17 +306,67 @@ class PanelApiService: return {"error": True, "status_code": response_status, "details": error_details} except aiohttp.ClientConnectorError as e: - logging.error(f"Panel API ClientConnectorError to {url_for_request}: {e}") + logging.info( + "metric panel_latency_seconds=%.3f method=%s endpoint=%s status=connect_error", + time.monotonic() - started, + method.upper(), + endpoint_label, + ) + logging.error( + "Panel API ClientConnectorError method=%s endpoint=%s: %s", + method.upper(), + endpoint_label, + e, + ) return {"error": True, "status_code": -1, "message": f"Connection error: {str(e)}"} + except aiohttp.ServerTimeoutError as e: + logging.info( + "metric panel_latency_seconds=%.3f method=%s endpoint=%s status=timeout", + time.monotonic() - started, + method.upper(), + endpoint_label, + ) + logging.warning( + "Panel API timeout method=%s endpoint=%s: %s", method.upper(), endpoint_label, e + ) + return {"error": True, "status_code": -3, "message": f"Request timed out: {str(e)}"} except aiohttp.ClientError as e: - logging.exception("Panel API ClientError to %s.", url_for_request) + logging.info( + "metric panel_latency_seconds=%.3f method=%s endpoint=%s status=client_error", + time.monotonic() - started, + method.upper(), + endpoint_label, + ) + logging.exception( + "Panel API ClientError method=%s endpoint=%s.", method.upper(), endpoint_label + ) return {"error": True, "status_code": -2, "message": f"Client error: {str(e)}"} except asyncio.TimeoutError: - logging.error(f"Panel API request to {url_for_request} timed out.") + logging.info( + "metric panel_latency_seconds=%.3f method=%s endpoint=%s status=timeout", + time.monotonic() - started, + method.upper(), + endpoint_label, + ) + logging.error( + "Panel API request timed out method=%s endpoint=%s.", + method.upper(), + endpoint_label, + ) return {"error": True, "status_code": -3, "message": "Request timed out"} except Exception as e: + logging.info( + "metric panel_latency_seconds=%.3f method=%s endpoint=%s status=unexpected_error", + time.monotonic() - started, + method.upper(), + endpoint_label, + ) logging.error( - f"Unexpected Panel API request error to {url_for_request}: {e}", exc_info=True + "Unexpected Panel API request error method=%s endpoint=%s: %s", + method.upper(), + endpoint_label, + e, + exc_info=True, ) return {"error": True, "status_code": -4, "message": f"Unexpected error: {str(e)}"} @@ -885,7 +1013,14 @@ class PanelApiService: await self._devices_cache.invalidate_remote(f"user:{user_uuid}") async def get_internal_squads(self) -> Optional[List[Dict[str, Any]]]: - return await self._squads_cache.get_or_load("list", self._get_internal_squads_uncached) + squads = await self._squads_cache.get_or_load("list", self._get_internal_squads_uncached) + if squads is not None: + return squads + stale_squads = self._squads_cache.get_stale("list") + if stale_squads is not None: + logging.warning("Using stale internal squads cache after panel fetch failed.") + return stale_squads + return None async def _get_internal_squads_uncached(self) -> Optional[List[Dict[str, Any]]]: response_data = await self._request("GET", "/internal-squads", log_full_response=False) diff --git a/backend/bot/services/promo_code_service.py b/backend/bot/services/promo_code_service.py index b486595..be96b3b 100644 --- a/backend/bot/services/promo_code_service.py +++ b/backend/bot/services/promo_code_service.py @@ -38,8 +38,12 @@ class PromoCodeService: user_lang: str, ) -> Tuple[bool, datetime | str]: _ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw) - code_input_upper = (code_input or "").strip().upper()[:100] - code_display = html_escape(code_input_upper[:100], quote=False) + preserve_case = bool( + getattr(self.settings, "MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED", False) + ) + code_input_clean = (code_input or "").strip()[:100] + lookup_code = code_input_clean if preserve_case else code_input_clean.upper() + code_display = html_escape(lookup_code[:100], quote=False) throttle_identifier = self._throttle_identifier(user_id) throttle = await security_dal.check_throttle( @@ -54,7 +58,7 @@ class PromoCodeService: ) promo_data = await promo_code_dal.get_active_promo_code_by_code_str( - session, code_input_upper + session, lookup_code, preserve_case=preserve_case ) if not promo_data: @@ -74,6 +78,8 @@ class PromoCodeService: ) return False, _("promo_code_not_found", code=code_display) + applied_code = str(promo_data.code or lookup_code) + code_display = html_escape(applied_code[:100], quote=False) existing_activation = await promo_code_dal.get_user_activation_for_promo( session, promo_data.promo_code_id, user_id ) @@ -81,12 +87,17 @@ class PromoCodeService: return False, _("promo_code_already_used_by_user", code=code_display) bonus_days = promo_data.bonus_days + default_tariff_key = None + tariffs_config = getattr(self.settings, "tariffs_config", None) + if tariffs_config: + default_tariff_key = getattr(tariffs_config, "default_tariff", None) new_end_date = await self.subscription_service.extend_active_subscription_days( session=session, user_id=user_id, bonus_days=bonus_days, - reason=f"promo code {code_input_upper}", + reason=f"promo code {applied_code}", + tariff_key=default_tariff_key, ) if new_end_date: @@ -109,7 +120,7 @@ class PromoCodeService: user = await user_dal.get_user_by_id(session, user_id) await notification_service.notify_promo_activation( user_id=user_id, - promo_code=code_input_upper, + promo_code=applied_code, bonus_days=bonus_days, username=user.username if user else None, email=getattr(user, "email", None) if user else None, diff --git a/backend/bot/services/referral_service.py b/backend/bot/services/referral_service.py index 53efd36..28c2d0e 100644 --- a/backend/bot/services/referral_service.py +++ b/backend/bot/services/referral_service.py @@ -197,6 +197,9 @@ class ReferralService: "status_from_panel": "ACTIVE_BONUS", "traffic_limit_bytes": self.settings.user_traffic_limit_bytes, "auto_renew_enabled": False, + # Short bonus grant: warn only hours before it + # ends, not days ahead. A real payment clears this. + "suppress_early_expiry_notifications": True, } try: await subscription_dal.deactivate_other_active_subscriptions( diff --git a/backend/bot/services/settings_override_service.py b/backend/bot/services/settings_override_service.py index 822b0ec..7702a32 100644 --- a/backend/bot/services/settings_override_service.py +++ b/backend/bot/services/settings_override_service.py @@ -28,10 +28,7 @@ from db.dal import app_settings_dal logger = logging.getLogger(__name__) APPEARANCE_OVERRIDE_KEYS = { - "WEBAPP_LOGO_USE_EMOJI", "WEBAPP_LOGO_URL", - "WEBAPP_LOGO_EMOJI", - "WEBAPP_LOGO_EMOJI_FONT", "WEBAPP_FAVICON_USE_CUSTOM", "WEBAPP_FAVICON_URL", "WEBAPP_LOGO_FAVICON_URL", @@ -167,12 +164,6 @@ def _appearance_snapshot(settings: Settings) -> Dict[str, Any]: snapshot["WEBAPP_FAVICON_URL"] = favicon_url if getattr(settings, "WEBAPP_FAVICON_USE_CUSTOM", False): snapshot["WEBAPP_FAVICON_USE_CUSTOM"] = True - if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): - snapshot["WEBAPP_LOGO_USE_EMOJI"] = True - snapshot["WEBAPP_LOGO_EMOJI"] = getattr(settings, "WEBAPP_LOGO_EMOJI", "") - emoji_font = getattr(settings, "WEBAPP_LOGO_EMOJI_FONT", "") - if emoji_font and emoji_font != "system": - snapshot["WEBAPP_LOGO_EMOJI_FONT"] = emoji_font primary_color = getattr(settings, "WEBAPP_PRIMARY_COLOR", None) if primary_color and primary_color != "#00fe7a": snapshot["WEBAPP_PRIMARY_COLOR"] = primary_color diff --git a/backend/bot/services/subscription_notification_worker.py b/backend/bot/services/subscription_notification_worker.py index 674a5d9..74d4c83 100644 --- a/backend/bot/services/subscription_notification_worker.py +++ b/backend/bot/services/subscription_notification_worker.py @@ -153,6 +153,15 @@ class SubscriptionNotificationWorker: hours_before=hours_before, ) + # Trial and registration/referral-bonus subscriptions last only a + # few days, so a multi-day "ending soon" reminder would fire almost + # the moment they are granted and needlessly alarm newcomers. Skip + # the day-before stages for them — they still get the hours-before + # reminder above and the expiry/after-expiry notices below. Paying + # for a real subscription clears the flag and restores all stages. + if bool(getattr(sub, "suppress_early_expiry_notifications", False)): + return None + days_before_limit = max( 0, int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_DAYS_BEFORE", 0) or 0), diff --git a/backend/bot/services/subscription_service_impl/devices.py b/backend/bot/services/subscription_service_impl/devices.py index 5c48e07..917e137 100644 --- a/backend/bot/services/subscription_service_impl/devices.py +++ b/backend/bot/services/subscription_service_impl/devices.py @@ -31,6 +31,49 @@ class HwidDeviceMixin: ) return int(getattr(sub, "extra_hwid_devices", 0) or 0) + async def sync_hwid_device_limit_to_panel( + self, + session: AsyncSession, + user_id: int, + ) -> Optional[int]: + """Push the current local HWID device limit override to the panel.""" + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or not db_user.panel_user_uuid: + return None + sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, db_user.panel_user_uuid + ) + if not sub: + return None + + tariff = self._resolve_tariff(sub.tariff_key) if sub.tariff_key else None + base_hwid_limit = ( + int(sub.hwid_device_limit) + if sub.hwid_device_limit is not None + else self._base_hwid_limit_for_tariff(tariff) + ) + extra_hwid_devices = await self._active_hwid_extra_devices_for_sub(session, sub) + sub.extra_hwid_devices = extra_hwid_devices + effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices) + if effective_hwid_limit is None: + return None + + panel_payload = self._build_panel_update_payload( + panel_user_uuid=db_user.panel_user_uuid, + expire_at=sub.end_date, + status="ACTIVE", + hwid_device_limit=effective_hwid_limit, + include_default_squads=False, + ) + panel_payload.update(self._panel_identity_payload_for_user(db_user)) + try: + await self.panel_service.update_user_details_on_panel( + db_user.panel_user_uuid, panel_payload + ) + except Exception: + logging.exception("sync_hwid_device_limit_to_panel failed for user %s", user_id) + return effective_hwid_limit + async def _hwid_topup_validity_window( self, session: AsyncSession, @@ -73,6 +116,64 @@ class HwidDeviceMixin: packages = package_set.for_currency(currency) return next((pkg for pkg in packages if int(pkg.count) == int(device_count)), None) + @staticmethod + def _quote_hwid_full_period_package_price( + tariff: Tariff, + *, + device_count: int, + period_months: int, + currency: str, + ) -> Optional[Dict[str, Any]]: + package_set = tariff.hwid_device_packages + if not package_set: + return None + try: + target_count = int(device_count) + months = max(1, int(period_months)) + except (TypeError, ValueError): + return None + if target_count <= 0: + return None + + packages = [ + package + for package in package_set.for_currency(currency) + if int(getattr(package, "count", 0) or 0) > 0 + ] + if not packages: + return None + + best: Dict[int, tuple[float, List[Any]]] = {0: (0.0, [])} + for count in range(1, target_count + 1): + best_for_count: Optional[tuple[float, List[Any]]] = None + for package in packages: + package_count = int(package.count) + previous = best.get(count - package_count) + if previous is None: + continue + price = previous[0] + float(package.price_for_period(months)) + selected = [*previous[1], package] + if best_for_count is None or price < best_for_count[0]: + best_for_count = (price, selected) + if best_for_count is not None: + best[count] = best_for_count + + resolved = best.get(target_count) + if resolved is None: + return None + full_price, selected_packages = resolved + rounded_price = HwidDeviceMixin._round_hwid_price(full_price, currency=currency) + if currency == "stars": + rounded_price = float(int(math.ceil(rounded_price))) + return { + "price": rounded_price, + "full_price": float(full_price), + "pricing_period_months": months, + "proration_ratio": 1.0, + "currency": currency, + "package_counts": [int(package.count) for package in selected_packages], + } + def _quote_hwid_package_price( self, *, @@ -85,16 +186,10 @@ class HwidDeviceMixin: ) -> Dict[str, Any]: period_months = max(1, int(getattr(sub, "duration_months", None) or 1)) full_price = float(package.price_for_period(period_months)) - period_start = self._as_aware_utc(getattr(sub, "start_date", None)) - period_end = self._as_aware_utc(getattr(sub, "end_date", None)) or valid_until - inferred_period_start = add_months(period_end, -period_months) - if not period_start or period_start >= period_end or period_start < inferred_period_start: - period_start = inferred_period_start - - basis_seconds = max(1.0, (period_end - period_start).total_seconds()) + basis_seconds = max(1.0, float(period_months * 30 * 24 * 60 * 60)) billable_start = max(now, valid_from) billable_seconds = max(0.0, (valid_until - billable_start).total_seconds()) - ratio = billable_seconds / basis_seconds + ratio = min(1.0, billable_seconds / basis_seconds) raw_price = full_price * ratio price = self._round_hwid_price(raw_price, currency=currency) min_price = getattr(package, "min_price", None) @@ -150,7 +245,7 @@ class HwidDeviceMixin: if sub.hwid_device_limit is not None else self._base_hwid_limit_for_tariff(tariff) ) - if base_hwid_limit == 0: + if base_hwid_limit in (None, 0): return None package = self._find_hwid_package(tariff, purchased_devices, currency) @@ -187,6 +282,80 @@ class HwidDeviceMixin: ) return quote + async def quote_hwid_device_renewal_for_subscription( + self, + session: AsyncSession, + *, + user_id: int, + target_tariff_key: str, + months: int, + currency: str = "rub", + now: Optional[datetime] = None, + ) -> Optional[Dict[str, Any]]: + try: + period_months = int(months) + except (TypeError, ValueError): + return None + if period_months <= 0: + return None + + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or not db_user.panel_user_uuid: + return None + sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, db_user.panel_user_uuid + ) + if not sub or not sub.end_date: + return None + + now = now or datetime.now(timezone.utc) + subscription_end = self._as_aware_utc(sub.end_date) + if not subscription_end or subscription_end <= now: + return None + + try: + tariff = self._resolve_tariff(target_tariff_key) + except Exception: + return None + if not tariff or tariff.billing_model != "period": + return None + base_hwid_limit = self._base_hwid_limit_for_tariff(tariff) + if base_hwid_limit in (None, 0): + return None + + entitlement_summary = await tariff_dal.get_hwid_device_entitlement_summary( + session, + subscription_id=sub.subscription_id, + at=now, + ) + active_devices = int(entitlement_summary.get("active_devices") or 0) + if active_devices <= 0: + return None + + price_quote = self._quote_hwid_full_period_package_price( + tariff, + device_count=active_devices, + period_months=period_months, + currency=currency, + ) + if not price_quote: + return None + + valid_from = subscription_end + valid_until = add_months(valid_from, period_months) + price_quote.update( + { + "subscription_id": sub.subscription_id, + "tariff_key": tariff.key, + "device_count": active_devices, + "renewal": True, + "valid_from": valid_from, + "valid_until": valid_until, + "active_until": entitlement_summary.get("active_until"), + } + ) + return price_quote + async def activate_hwid_device_topup( self, session: AsyncSession, @@ -248,7 +417,7 @@ class HwidDeviceMixin: if sub.hwid_device_limit is not None else self._base_hwid_limit_for_tariff(tariff) ) - if base_hwid_limit == 0: + if base_hwid_limit in (None, 0): logging.info( "Skipping HWID top-up for user %s because current limit is unlimited", user_id ) diff --git a/backend/bot/services/subscription_service_impl/lifecycle.py b/backend/bot/services/subscription_service_impl/lifecycle.py index 4a4eea3..cdc1a94 100644 --- a/backend/bot/services/subscription_service_impl/lifecycle.py +++ b/backend/bot/services/subscription_service_impl/lifecycle.py @@ -178,6 +178,7 @@ class SubscriptionLifecycleMixin: user_id: int, target_tariff_key: str, mode: str, + payment_id: Optional[int] = None, ) -> Optional[Dict[str, Any]]: config = self._tariffs_config() if not config: @@ -336,7 +337,7 @@ class SubscriptionLifecycleMixin: "from_tariff_key": before_tariff_key, "to_tariff_key": target.key, "mode": mode, - "payment_id": None, + "payment_id": payment_id, "days_before": options.get("remaining_days"), "days_after": (updated.end_date - now).days if updated.end_date and target.billing_model == "period" @@ -454,27 +455,11 @@ class SubscriptionLifecycleMixin: user_id, tariff_key, "paid_diff", + payment_id=payment_db_id, ) if result: sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id) if sub: - await tariff_dal.create_tariff_change( - session, - { - "subscription_id": sub.subscription_id, - "from_tariff_key": None, - "to_tariff_key": tariff_key, - "mode": "paid_diff", - "payment_id": payment_db_id, - "days_before": None, - "days_after": (sub.end_date - datetime.now(timezone.utc)).days - if sub.end_date - else None, - "converted_bytes": None, - "eff_price_before": None, - "eff_price_after": sub.effective_monthly_price_rub, - }, - ) result["end_date"] = sub.end_date result["is_active"] = sub.is_active db_user = await user_dal.get_user_by_id(session, user_id) @@ -494,10 +479,29 @@ class SubscriptionLifecycleMixin: await self._record_payment_context( session, payment_db_id, - sale_mode=sale_mode_base, + sale_mode=sale_mode, tariff_key=tariff.key if tariff else tariff_key, purchased_gb=None, ) + payment = await payment_dal.get_payment_by_db_id(session, payment_db_id) + try: + hwid_renewal_devices = int(getattr(payment, "purchased_hwid_devices", 0) or 0) + except (TypeError, ValueError): + hwid_renewal_devices = 0 + try: + hwid_renewal_price = ( + float(getattr(payment, "hwid_full_price", 0) or 0) + if hwid_renewal_devices > 0 + else 0.0 + ) + except (TypeError, ValueError): + hwid_renewal_price = 0.0 + hwid_renewal_valid_from = self._as_aware_utc( + getattr(payment, "hwid_valid_from", None) if payment else None + ) + hwid_renewal_valid_until = self._as_aware_utc( + getattr(payment, "hwid_valid_until", None) if payment else None + ) db_user = await user_dal.get_user_by_id(session, user_id) if not db_user: @@ -569,6 +573,26 @@ class SubscriptionLifecycleMixin: promo_code_id_from_payment = None final_end_date = start_date + timedelta(days=duration_days_total) + if hwid_renewal_devices > 0 and hwid_renewal_valid_until and applied_promo_bonus_days: + hwid_renewal_valid_until = hwid_renewal_valid_until + timedelta( + days=applied_promo_bonus_days + ) + if payment: + payment.hwid_valid_until = hwid_renewal_valid_until + elif applied_promo_bonus_days > 0 and current_active_sub: + try: + await tariff_dal.extend_hwid_device_purchases_for_subscription_bonus( + session, + subscription_id=current_active_sub.subscription_id, + at=datetime.now(timezone.utc), + subscription_end_before=start_date, + delta=timedelta(days=applied_promo_bonus_days), + ) + except Exception: + logging.exception( + "Failed to extend HWID device purchases for promo payment bonus of user %s", + user_id, + ) await subscription_dal.deactivate_other_active_subscriptions( session, panel_user_uuid, panel_sub_link_id ) @@ -614,7 +638,8 @@ class SubscriptionLifecycleMixin: premium_topup_balance_bytes, premium_topup_used_bytes, ) - effective_monthly_price = float(payment_amount) / max(1, months_int) + subscription_amount_for_pricing = max(0.0, float(payment_amount) - hwid_renewal_price) + effective_monthly_price = subscription_amount_for_pricing / max(1, months_int) regular_bonus_carry = int(getattr(current_active_sub, "regular_bonus_bytes", 0) or 0) regular_unl_carry = bool(getattr(current_active_sub, "regular_unlimited_override", False)) traffic_limit_bytes = self._traffic_limit_for_period_tariff( @@ -641,6 +666,9 @@ class SubscriptionLifecycleMixin: "traffic_limit_bytes": traffic_limit_bytes, "provider": provider, "skip_notifications": False, + # A real payment restores the full reminder spectrum, clearing any + # trial/bonus suppression carried over on this panel subscription. + "suppress_early_expiry_notifications": False, "auto_renew_enabled": auto_renew_should_enable, "tariff_key": tariff.key if tariff else None, "tier_baseline_bytes": tier_baseline_bytes, @@ -695,6 +723,31 @@ class SubscriptionLifecycleMixin: final_subscription_url = updated_panel_user.get("subscriptionUrl") final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid) + hwid_devices_renewed_count = 0 + hwid_devices_renewed_until = None + if hwid_renewal_devices > 0: + if ( + hwid_renewal_valid_from + and hwid_renewal_valid_until + and hwid_renewal_valid_from < hwid_renewal_valid_until + ): + await tariff_dal.create_hwid_device_purchase( + session, + subscription_id=new_or_updated_sub.subscription_id, + payment_id=payment_db_id, + purchased_devices=hwid_renewal_devices, + valid_from=hwid_renewal_valid_from, + valid_until=hwid_renewal_valid_until, + ) + hwid_devices_renewed_count = hwid_renewal_devices + hwid_devices_renewed_until = hwid_renewal_valid_until + else: + logging.warning( + "Skipping HWID renewal purchase for payment %s: invalid window %s -> %s", + payment_db_id, + hwid_renewal_valid_from, + hwid_renewal_valid_until, + ) await self._send_payment_success_email( db_user=db_user, @@ -715,8 +768,12 @@ class SubscriptionLifecycleMixin: "subscription_url": final_subscription_url, "applied_promo_bonus_days": applied_promo_bonus_days, "tariff_key": tariff.key if tariff else None, - "hwid_devices_renewal_recommended_count": extra_hwid_devices, - "hwid_devices_valid_until": hwid_devices_valid_until, + "hwid_devices_renewal_recommended_count": 0 + if hwid_devices_renewed_count + else extra_hwid_devices, + "hwid_devices_valid_until": hwid_devices_renewed_until or hwid_devices_valid_until, + "hwid_devices_renewed_count": hwid_devices_renewed_count, + "hwid_devices_renewed_until": hwid_devices_renewed_until, } async def extend_active_subscription_days( @@ -725,6 +782,8 @@ class SubscriptionLifecycleMixin: user_id: int, bonus_days: int, reason: str = "bonus", + extend_hwid_devices: bool = True, + tariff_key: Optional[str] = None, ) -> Optional[datetime]: reason_lower = (reason or "").lower() apply_main_traffic_limit = any( @@ -751,6 +810,17 @@ class SubscriptionLifecycleMixin: preserve_tariff_limits = bool( active_sub and active_sub.tariff_key and self._tariffs_config() ) + bonus_tariff = None + if not active_sub and tariff_key and self._tariffs_config(): + try: + bonus_tariff = self._resolve_tariff(tariff_key) + except Exception: + logging.warning( + "Unable to resolve bonus tariff %s for user %s.", + tariff_key, + user_id, + exc_info=True, + ) if not active_sub or not active_sub.end_date: logging.info( f"No active subscription found for user {user_id}. Creating new one for {bonus_days} days." # noqa: E501 @@ -760,10 +830,16 @@ class SubscriptionLifecycleMixin: # Apply main traffic limit for admin/referral/promo bonuses, fallback to trial limit otherwise # noqa: E501 traffic_limit = ( - self.settings.user_traffic_limit_bytes + self._traffic_limit_for_period_tariff(bonus_tariff) + if bonus_tariff + else self.settings.user_traffic_limit_bytes if apply_main_traffic_limit else self.settings.trial_traffic_limit_bytes ) + premium_baseline_bytes = bonus_tariff.premium_monthly_bytes if bonus_tariff else 0 + base_hwid_limit = ( + self._base_hwid_limit_for_tariff(bonus_tariff) if bonus_tariff else None + ) bonus_sub_payload = { "user_id": user_id, @@ -776,6 +852,24 @@ class SubscriptionLifecycleMixin: "status_from_panel": "ACTIVE_BONUS", "traffic_limit_bytes": traffic_limit, "auto_renew_enabled": False, + "tariff_key": bonus_tariff.key if bonus_tariff else None, + "tier_baseline_bytes": bonus_tariff.monthly_bytes if bonus_tariff else None, + "topup_balance_bytes": 0, + "regular_bonus_bytes": 0, + "regular_unlimited_override": False, + "premium_baseline_bytes": premium_baseline_bytes, + "premium_topup_balance_bytes": 0, + "premium_topup_used_bytes": 0, + "premium_used_bytes": 0, + "premium_is_limited": False, + "premium_period_start_at": None, + "period_start_at": None, + "is_throttled": False, + "hwid_device_limit": base_hwid_limit, + "extra_hwid_devices": 0, + # Registration/referral bonus grants are short-lived, like a + # trial: only warn a few hours before they end, not days ahead. + "suppress_early_expiry_notifications": True, } await subscription_dal.deactivate_other_active_subscriptions( session, panel_uuid, panel_sub_uuid @@ -792,6 +886,21 @@ class SubscriptionLifecycleMixin: updated_sub_model = await subscription_dal.update_subscription_end_date( session, active_sub.subscription_id, new_end_date_obj ) + if updated_sub_model and extend_hwid_devices: + try: + await tariff_dal.extend_hwid_device_purchases_for_subscription_bonus( + session, + subscription_id=active_sub.subscription_id, + at=now_utc, + subscription_end_before=current_end_date, + delta=timedelta(days=bonus_days), + ) + except Exception: + logging.exception( + "Failed to extend HWID device purchases for %s bonus of user %s", + reason, + user_id, + ) if ( apply_main_traffic_limit @@ -810,13 +919,35 @@ class SubscriptionLifecycleMixin: panel_update_payload = self._build_panel_update_payload( expire_at=new_end_date_obj, traffic_limit_bytes=( - self.settings.user_traffic_limit_bytes + updated_sub_model.traffic_limit_bytes + if bonus_tariff + else self.settings.user_traffic_limit_bytes if apply_main_traffic_limit and not preserve_tariff_limits else None ), + traffic_limit_strategy=( + "MONTH" + if bonus_tariff and bonus_tariff.billing_model == "period" + else self.settings.USER_TRAFFIC_STRATEGY + if bonus_tariff + else None + ), + hwid_device_limit=( + self._effective_hwid_limit(updated_sub_model.hwid_device_limit, 0) + if bonus_tariff + else None + ), include_uuid=False, include_default_squads=False, ) + if bonus_tariff: + panel_update_payload["activeInternalSquads"] = self._panel_squads_for_tariff( + bonus_tariff + ) + if self.settings.parsed_user_external_squad_uuid: + panel_update_payload["externalSquadUuid"] = ( + self.settings.parsed_user_external_squad_uuid + ) panel_update_success = await self.panel_service.update_user_details_on_panel( panel_uuid, diff --git a/backend/bot/services/subscription_service_impl/payments.py b/backend/bot/services/subscription_service_impl/payments.py index 012c4c4..419bc2a 100644 --- a/backend/bot/services/subscription_service_impl/payments.py +++ b/backend/bot/services/subscription_service_impl/payments.py @@ -14,6 +14,7 @@ class PaymentContextMixin: "severpay": "SeverPay", "wata": "Wata", "cryptopay": "Crypto Pay", + "paykilla": "PayKilla", "telegram_stars": "Telegram Stars", } @@ -38,7 +39,8 @@ class PaymentContextMixin: payment.sale_mode = sale_mode payment.tariff_key = tariff_key payment.purchased_gb = purchased_gb - payment.purchased_hwid_devices = purchased_hwid_devices + if purchased_hwid_devices is not None: + payment.purchased_hwid_devices = purchased_hwid_devices if hwid_valid_from is not None: payment.hwid_valid_from = hwid_valid_from if hwid_valid_until is not None: diff --git a/backend/bot/services/subscription_service_impl/renewal.py b/backend/bot/services/subscription_service_impl/renewal.py index f58e8eb..ed9596d 100644 --- a/backend/bot/services/subscription_service_impl/renewal.py +++ b/backend/bot/services/subscription_service_impl/renewal.py @@ -42,6 +42,8 @@ class RenewalMixin: months = sub.duration_months or 1 currency = default_payment_currency_code_for_settings(self.settings) + tariff_key = str(getattr(sub, "tariff_key", "") or "").strip() or None + sale_mode = f"subscription@{tariff_key}" if tariff_key else "subscription" amount = None tariffs_config = ( self._tariffs_config() if callable(getattr(self, "_tariffs_config", None)) else None @@ -62,11 +64,55 @@ class RenewalMixin: logging.error(f"Auto-renew price missing for {months} months") return False + hwid_quote = None + quote_hwid_renewal = getattr( + self, + "quote_hwid_device_renewal_for_subscription", + None, + ) + if tariff_key and callable(quote_hwid_renewal): + try: + hwid_quote = await quote_hwid_renewal( + session, + user_id=sub.user_id, + target_tariff_key=tariff_key, + months=int(months), + currency=default_currency_key_for_settings(self.settings), + ) + except Exception: + logging.exception( + "Failed to quote HWID devices for auto-renew user %s", + sub.user_id, + ) + hwid_quote = None + if hwid_quote: + amount = float(amount) + float(hwid_quote.get("price") or 0) + metadata = { "user_id": str(sub.user_id), "auto_renew_for_subscription_id": str(sub.subscription_id), "subscription_months": str(months), + "sale_mode": sale_mode, } + if hwid_quote: + metadata["hwid_devices"] = str(int(hwid_quote.get("device_count") or 0)) + for source_key, metadata_key in ( + ("valid_from", "hwid_valid_from"), + ("valid_until", "hwid_valid_until"), + ): + value = hwid_quote.get(source_key) + if value: + metadata[metadata_key] = ( + value.isoformat() if hasattr(value, "isoformat") else str(value) + ) + for key in ( + "pricing_period_months", + "proration_ratio", + "full_price", + ): + value = hwid_quote.get(key) + if value is not None: + metadata[f"hwid_{key}"] = str(value) resp = await yk.create_payment( amount=float(amount), currency=currency, diff --git a/backend/bot/services/subscription_service_impl/tariffs.py b/backend/bot/services/subscription_service_impl/tariffs.py index fcf2df6..a1c9958 100644 --- a/backend/bot/services/subscription_service_impl/tariffs.py +++ b/backend/bot/services/subscription_service_impl/tariffs.py @@ -314,7 +314,7 @@ class TariffMixin: @staticmethod def _effective_hwid_limit(base_limit: Optional[int], extra_devices: int = 0) -> Optional[int]: if base_limit is None: - return None + return 0 base_int = max(0, int(base_limit)) if base_int == 0: return 0 diff --git a/backend/bot/services/subscription_service_impl/trial.py b/backend/bot/services/subscription_service_impl/trial.py index a963a85..1940e4b 100644 --- a/backend/bot/services/subscription_service_impl/trial.py +++ b/backend/bot/services/subscription_service_impl/trial.py @@ -63,6 +63,8 @@ class TrialSubscriptionMixin: "traffic_limit_bytes": self.settings.trial_traffic_limit_bytes, "auto_renew_enabled": False, "provider": "trial", + # Short trial: only warn a few hours before it ends, not days ahead. + "suppress_early_expiry_notifications": True, } try: await subscription_dal.upsert_subscription(session, trial_sub_data) diff --git a/backend/bot/services/telemetry_worker.py b/backend/bot/services/telemetry_worker.py index c6b6b54..7d9e966 100644 --- a/backend/bot/services/telemetry_worker.py +++ b/backend/bot/services/telemetry_worker.py @@ -29,7 +29,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import sessionmaker from bot.infra.redis import redis_lock -from bot.utils.app_version import resolve_app_version, resolve_app_version_tag +from bot.utils.app_version import ( + resolve_app_version, + resolve_app_version_tag, + resolve_build_provenance, + resolve_image_modified, +) from config.settings import Settings from db.dal import app_settings_dal, user_dal @@ -169,11 +174,15 @@ class TelemetryWorker: version = resolve_app_version() version_tag = resolve_app_version_tag() + build_provenance = resolve_build_provenance() + image_modified = resolve_image_modified() # Person properties (``$set``) snapshot the latest state per install, so # "version breakdown" in PostHog is a person-property breakdown. person_props = { "app_version": version, "app_version_tag": version_tag, + "build_provenance": build_provenance, + "image_modified": image_modified, "os": platform.system().lower() or "unknown", "arch": platform.machine().lower() or "unknown", "python_version": platform.python_version(), diff --git a/backend/bot/states/admin_states.py b/backend/bot/states/admin_states.py index 870770a..389241f 100644 --- a/backend/bot/states/admin_states.py +++ b/backend/bot/states/admin_states.py @@ -30,6 +30,7 @@ class AdminStates(StatesGroup): waiting_for_user_delete_confirmation = State() waiting_for_premium_override_bonus_gb = State() waiting_for_traffic_grant_gb = State() + waiting_for_hwid_device_limit = State() # Ads campaigns waiting_for_ad_source = State() diff --git a/backend/bot/utils/app_version.py b/backend/bot/utils/app_version.py index 3695062..7f78655 100644 --- a/backend/bot/utils/app_version.py +++ b/backend/bot/utils/app_version.py @@ -6,6 +6,9 @@ the runtime container agree on the value: REMNAWAVE_MINISHOP_VERSION env > .build-version file > live ``git describe`` > ``dev+unknown`` +Build provenance is intentionally separate from the version: official release +automation stamps official images, while local/fork builds default to custom. + The same value powers the admin sidebar (web process) and the anonymous telemetry beacon (worker process), so "active installs" and version breakdowns line up across both. @@ -24,6 +27,16 @@ from typing import Optional APP_ROOT = Path(__file__).resolve().parents[3] _APP_VERSION_CACHE: Optional[str] = None +_APP_BUILD_PROVENANCE_CACHE: Optional[str] = None + +BUILD_PROVENANCE_OFFICIAL = "official" +BUILD_PROVENANCE_CUSTOM = "custom" +BUILD_PROVENANCE_UNKNOWN = "unknown" +_BUILD_PROVENANCE_VALUES = { + BUILD_PROVENANCE_OFFICIAL, + BUILD_PROVENANCE_CUSTOM, + BUILD_PROVENANCE_UNKNOWN, +} def _run_git_command(*args: str) -> str: @@ -87,6 +100,29 @@ def _read_build_file(name: str) -> str: return "" +def _normalize_build_provenance(raw: str) -> str: + value = str(raw or "").strip().lower() + if not value: + return "" + aliases = { + "true": BUILD_PROVENANCE_OFFICIAL, + "1": BUILD_PROVENANCE_OFFICIAL, + "yes": BUILD_PROVENANCE_OFFICIAL, + "upstream": BUILD_PROVENANCE_OFFICIAL, + "release": BUILD_PROVENANCE_OFFICIAL, + "false": BUILD_PROVENANCE_CUSTOM, + "0": BUILD_PROVENANCE_CUSTOM, + "no": BUILD_PROVENANCE_CUSTOM, + "fork": BUILD_PROVENANCE_CUSTOM, + "modified": BUILD_PROVENANCE_CUSTOM, + "local": BUILD_PROVENANCE_CUSTOM, + } + value = aliases.get(value, value) + if value in _BUILD_PROVENANCE_VALUES: + return value + return BUILD_PROVENANCE_CUSTOM + + def resolve_app_version() -> str: """Full version string (cached), e.g. ``v3.4.6+gabc1234``.""" global _APP_VERSION_CACHE @@ -124,3 +160,28 @@ def resolve_app_version_tag() -> str: if tag: return tag return resolve_app_version() + + +def resolve_build_provenance() -> str: + """Low-cardinality image provenance: ``official``, ``custom`` or ``unknown``.""" + global _APP_BUILD_PROVENANCE_CACHE + if _APP_BUILD_PROVENANCE_CACHE: + return _APP_BUILD_PROVENANCE_CACHE + + env_value = _normalize_build_provenance(os.getenv("REMNAWAVE_MINISHOP_BUILD_PROVENANCE", "")) + if env_value: + _APP_BUILD_PROVENANCE_CACHE = env_value + return env_value + + build_value = _normalize_build_provenance(_read_build_file(".build-provenance")) + if build_value: + _APP_BUILD_PROVENANCE_CACHE = build_value + return build_value + + _APP_BUILD_PROVENANCE_CACHE = BUILD_PROVENANCE_CUSTOM + return _APP_BUILD_PROVENANCE_CACHE + + +def resolve_image_modified() -> bool: + """True for non-official builds, including forks and local rebuilds.""" + return resolve_build_provenance() != BUILD_PROVENANCE_OFFICIAL diff --git a/backend/bot/utils/channel_subscription.py b/backend/bot/utils/channel_subscription.py index 72ba65c..4f08287 100644 --- a/backend/bot/utils/channel_subscription.py +++ b/backend/bot/utils/channel_subscription.py @@ -1,4 +1,9 @@ -from typing import Optional +import logging +import re +from typing import Any, Optional + +_TELEGRAM_LINK_RE = re.compile(r"^(?:https?://|tg://)", re.IGNORECASE) +_TELEGRAM_USERNAME_RE = re.compile(r"^[A-Za-z0-9_]{5,64}$") def normalize_required_channel_id(value: object) -> Optional[int]: @@ -28,6 +33,66 @@ def normalize_required_channel_id(value: object) -> Optional[int]: return -int(f"100{raw_abs}") +def normalize_required_channel_link(value: object) -> Optional[str]: + if value is None: + return None + + raw = str(value).strip() + if not raw: + return None + + if _TELEGRAM_LINK_RE.match(raw): + return raw + + raw = raw.lstrip("@").strip() + if not raw or re.search(r"\s", raw): + return None + + if raw.startswith(("t.me/", "telegram.me/")): + return f"https://{raw}" + + if raw.startswith(("+", "joinchat/", "c/")): + return f"https://t.me/{raw}" + + if _TELEGRAM_USERNAME_RE.fullmatch(raw): + return f"https://t.me/{raw}" + + return None + + +def _required_channel_link_from_chat(chat: Any) -> Optional[str]: + username = str(getattr(chat, "username", "") or "").strip().lstrip("@") + if username: + return f"https://t.me/{username}" + + invite_link = normalize_required_channel_link(getattr(chat, "invite_link", None)) + if invite_link: + return invite_link + + return None + + +async def resolve_required_channel_link( + bot: Any, + required_channel_id: Optional[int], + configured_link: object, +) -> Optional[str]: + if bot is not None and required_channel_id: + try: + chat = await bot.get_chat(required_channel_id) + resolved_link = _required_channel_link_from_chat(chat) + if resolved_link: + return resolved_link + except Exception as error: + logging.warning( + "Failed to resolve required channel link from chat %s: %s", + required_channel_id, + error, + ) + + return normalize_required_channel_link(configured_link) + + def is_required_channel_access_error(error: BaseException) -> bool: message = str(error).lower() configuration_markers = ( diff --git a/backend/bot/utils/request_security.py b/backend/bot/utils/request_security.py index bba0bc5..8b2a3e9 100644 --- a/backend/bot/utils/request_security.py +++ b/backend/bot/utils/request_security.py @@ -34,12 +34,26 @@ def _parse_ip(value: Optional[str]) -> Optional[ipaddress._BaseAddress]: return None -def _last_forwarded_ip(header_value: str) -> Optional[str]: +def _forwarded_ips(header_value: str) -> list[ipaddress._BaseAddress]: 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 + parsed: list[ipaddress._BaseAddress] = [] + for candidate in candidates: + parsed_ip = _parse_ip(candidate) + if parsed_ip is not None: + parsed.append(parsed_ip) + return parsed + + +def _forwarded_client_ip( + forwarded_ips: Sequence[ipaddress._BaseAddress], + trusted_networks: Sequence[ipaddress._BaseNetwork], +) -> Optional[str]: + for forwarded_ip in reversed(forwarded_ips): + if not any(forwarded_ip in network for network in trusted_networks): + return str(forwarded_ip) + if forwarded_ips: + return str(forwarded_ips[0]) + return None def request_client_ip( @@ -48,20 +62,21 @@ def request_client_ip( trusted_proxies: Optional[Sequence[str] | str] = None, ) -> Optional[str]: remote_ip = _parse_ip(request.remote or "") - forwarded_for = request.headers.get("X-Forwarded-For", "") + forwarded_ips = _forwarded_ips(request.headers.get("X-Forwarded-For", "")) - if remote_ip and forwarded_for: + if remote_ip and forwarded_ips: 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) + forwarded_ip = _forwarded_client_ip(forwarded_ips, trusted_networks) if forwarded_ip: return forwarded_ip if remote_ip: return str(remote_ip) - forwarded_ip = _last_forwarded_ip(forwarded_for) - return forwarded_ip + if forwarded_ips: + return str(forwarded_ips[-1]) + return None def ip_in_allowlist( diff --git a/backend/bot/utils/ttl_cache.py b/backend/bot/utils/ttl_cache.py index 18408fd..d182ae7 100644 --- a/backend/bot/utils/ttl_cache.py +++ b/backend/bot/utils/ttl_cache.py @@ -29,6 +29,15 @@ class AsyncTTLCache: return None return value + def get_stale(self, key: str) -> Optional[Any]: + entry = self._data.get(key) + if entry is None: + return None + _, value = entry + if not self._is_cacheable(value): + return None + return value + @staticmethod def _is_cacheable(value: Any) -> bool: if value is None: diff --git a/backend/config/settings.py b/backend/config/settings.py index bd09406..c848bdf 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -1,5 +1,6 @@ import logging import os +import re import secrets from typing import Any, Dict, List, Optional @@ -25,7 +26,128 @@ DEFAULT_SUBSCRIPTION_PURCHASE_DESCRIPTION_EN = ( def _split_csv(value: Optional[str]) -> List[str]: if not value: return [] - return [item.strip() for item in value.split(",") if item.strip()] + return [item.strip() for item in re.split(r"[,;\r\n]+", value) if item.strip()] + + +DEFAULT_DISPOSABLE_EMAIL_DOMAINS = "\n".join( + [ + "10minutemail.com", + "10minutemail.net", + "10minutemail.org", + "20minutemail.com", + "33mail.com", + "anonbox.net", + "anonymbox.com", + "armyspy.com", + "byom.de", + "crazymailing.com", + "cuvox.de", + "dayrep.com", + "deadaddress.com", + "dispostable.com", + "dodgeit.com", + "dodgit.com", + "dropmail.me", + "easytrashmail.com", + "emailfake.com", + "emailondeck.com", + "emailtemporanea.com", + "emailtemporanea.net", + "einrot.com", + "fakeinbox.com", + "filzmail.com", + "fleckens.hu", + "generator.email", + "getairmail.com", + "getnada.com", + "grr.la", + "guerrillamail.biz", + "guerrillamail.com", + "guerrillamail.de", + "guerrillamail.info", + "guerrillamail.net", + "guerrillamail.org", + "guerrillamailblock.com", + "gustr.com", + "hmamail.com", + "incognitomail.org", + "inboxbear.com", + "jetable.org", + "jourrapide.com", + "kasmail.com", + "mail-temp.com", + "mailcatch.com", + "maildrop.cc", + "mailexpire.com", + "mailinator.com", + "mailinator.net", + "mailinator.org", + "mailmetrash.com", + "mailnesia.com", + "mailnull.com", + "mailpoof.com", + "mailtothis.com", + "mail.tm", + "mintemail.com", + "mohmal.com", + "moakt.com", + "mytemp.email", + "mytrashmail.com", + "nada.email", + "no-spam.ws", + "pookmail.com", + "rhyta.com", + "sharklasers.com", + "sofort-mail.de", + "spam4.me", + "spambog.com", + "spamdecoy.net", + "spamfree24.org", + "spamgourmet.com", + "spamhole.com", + "spam.la", + "spammotel.com", + "superrito.com", + "teleworm.us", + "tempail.com", + "temp-mail.io", + "temp-mail.org", + "tempmail.com", + "tempmail.dev", + "tempmail.net", + "tempmailo.com", + "temporaryemail.net", + "temporary-mail.net", + "tempr.email", + "throwawaymail.com", + "trash-mail.com", + "trash-mail.de", + "trashmail.com", + "trashmail.me", + "trashmail.net", + "trashmailer.com", + "trashymail.com", + "weg-werf-email.de", + "wegwerfmail.de", + "wegwerfmail.net", + "wegwerfmail.org", + "yomail.info", + "yopmail.com", + "yopmail.fr", + "yopmail.net", + ] +) + +DEFAULT_TRUSTED_PROXIES = ",".join( + [ + "127.0.0.1", + "::1", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "fc00::/7", + ] +) class DBSettings(BaseModel): @@ -59,9 +181,6 @@ class WebAppSettings(BaseModel): title: str primary_color: str logo_url: Optional[str] - logo_use_emoji: bool - logo_emoji: str - logo_emoji_font: str favicon_use_custom: bool favicon_url: Optional[str] logo_favicon_url: Optional[str] @@ -100,14 +219,31 @@ class Settings(BaseSettings): PANEL_DEVICES_CACHE_TTL_SECONDS: int = Field(default=5) PANEL_ALL_USERS_CACHE_TTL_SECONDS: int = Field(default=5) PANEL_ALL_USERS_PAGE_SIZE: int = Field(default=1000) + PANEL_API_TOTAL_TIMEOUT_SECONDS: float = Field(default=25) + PANEL_API_CONNECT_TIMEOUT_SECONDS: float = Field(default=8) + PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS: float = Field(default=8) + PANEL_API_SOCK_READ_TIMEOUT_SECONDS: float = Field(default=15) ADMIN_PANEL_STATS_CACHE_TTL_SECONDS: int = Field(default=15) ADMIN_DB_STATS_CACHE_TTL_SECONDS: int = Field(default=5) ADMIN_USERS_LIST_CACHE_TTL_SECONDS: int = Field(default=3) + ADMIN_BROADCAST_AUDIENCE_COUNTS_CACHE_TTL_SECONDS: int = Field(default=30) PROFILE_SYNC_CACHE_TTL_SECONDS: int = Field(default=900) PANEL_SYNC_LIFETIME_TRAFFIC_MIN_INTERVAL_SECONDS: int = Field(default=3600) PANEL_SYNC_LIFETIME_TRAFFIC_MIN_DELTA_BYTES: int = Field(default=104857600) WEBAPP_RATE_LIMIT_TTL_SECONDS: int = Field(default=60) WEBAPP_RATE_LIMIT_MAX_REQUESTS: int = Field(default=30) + TELEGRAM_DROP_NON_PRIVATE_UPDATES: bool = Field(default=True) + TELEGRAM_ANTIFLOOD_ENABLED: bool = Field(default=True) + TELEGRAM_ANTIFLOOD_WINDOW_SECONDS: int = Field(default=60) + TELEGRAM_ANTIFLOOD_MAX_UPDATES_PER_WINDOW: int = Field(default=180) + TELEGRAM_ANTIFLOOD_MESSAGE_MAX_PER_WINDOW: int = Field(default=120) + TELEGRAM_ANTIFLOOD_CALLBACK_MAX_PER_WINDOW: int = Field(default=240) + TELEGRAM_ANTIFLOOD_INLINE_MAX_PER_WINDOW: int = Field(default=60) + TELEGRAM_ANTIFLOOD_START_MAX_PER_WINDOW: int = Field(default=30) + TELEGRAM_ANTIFLOOD_EXPENSIVE_CALLBACK_MAX_PER_WINDOW: int = Field(default=60) + TELEGRAM_ACTION_COOLDOWN_ENABLED: bool = Field(default=True) + TELEGRAM_PAYMENT_CALLBACK_COOLDOWN_SECONDS: int = Field(default=20) + TELEGRAM_TRIAL_CALLBACK_COOLDOWN_SECONDS: int = Field(default=30) WEBHOOK_QUEUE_NAME: str = Field(default="webhook-events") WEBHOOK_QUEUE_CONCURRENCY: int = Field(default=4) WORKER_PANEL_SYNC_INTERVAL_SECONDS: int = Field(default=900) @@ -147,7 +283,6 @@ class Settings(BaseSettings): SUPPORT_LINK: Optional[str] = Field(default=None) SERVER_STATUS_URL: Optional[str] = Field(default=None) - TERMS_OF_SERVICE_URL: Optional[str] = Field(default=None) PRIVACY_POLICY_URL: Optional[str] = Field(default=None) USER_AGREEMENT_URL: Optional[str] = Field(default=None) REQUIRED_CHANNEL_ID: Optional[int] = Field( @@ -186,7 +321,7 @@ class Settings(BaseSettings): WEBHOOK_BASE_URL: Optional[str] = None TRUSTED_PROXIES: Optional[str] = Field( - default="127.0.0.1,::1", + default=DEFAULT_TRUSTED_PROXIES, description="Comma-separated list of reverse proxy IPs or CIDRs trusted to forward X-Forwarded-For.", # noqa: E501 ) @@ -194,7 +329,7 @@ class Settings(BaseSettings): STARS_ADMIN_ONLY_ENABLED: bool = Field(default=False) PAYMENT_METHODS_ORDER: Optional[str] = Field( default=None, - description="Comma-separated list of payment methods to show (e.g., severpay,wata,freekassa,yookassa,platega,stars,cryptopay)", # noqa: E501 + description="Comma-separated list of payment methods to show (e.g., severpay,wata,freekassa,yookassa,platega,stars,cryptopay,heleket,paykilla)", # noqa: E501 ) SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED: bool = Field( default=True, @@ -208,6 +343,11 @@ class Settings(BaseSettings): default=DEFAULT_SUBSCRIPTION_PURCHASE_DESCRIPTION_EN, description="English subscription description shown before purchase/renewal options.", ) + PAYMENT_REQUEST_TIMEOUT_SECONDS: float = Field( + default=20, + ge=1, + description="Maximum total time for one payment provider API request, in seconds.", + ) MONTH_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED") MONTH_3_ENABLED: bool = Field(default=True, alias="3_MONTHS_ENABLED") @@ -282,10 +422,35 @@ class Settings(BaseSettings): default=3, description="Welcome bonus days granted to a newly registered user who joined via referral link.", # noqa: E501 ) + REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED: bool = Field( + default=True, + description=( + "Allow referral welcome bonus grants for users who have not linked Telegram. " + "Disposable email domains are still blocked until Telegram is linked." + ), + ) LEGACY_REFS: bool = Field( default=True, description="Allow legacy referral links like ref_ to continue working. Defaults to True when unset.", # noqa: E501 ) + MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED: bool = Field( + default=False, + description=( + "Accept referral links imported from snoups/remnashop via legacy_referral_codes." + ), + ) + MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED: bool = Field( + default=False, + description="Try exact legacy Remnashop promo codes before uppercase normalization.", + ) + MIGRATION_REMNASHOP_IMPORTED_AT: Optional[str] = Field( + default=None, + description="Timestamp of the latest Remnashop import run, managed by the import script.", + ) + MIGRATION_REMNASHOP_NOTES: Optional[str] = Field( + default=None, + description="Operator notes for instances migrated from Remnashop.", + ) APP_RUNTIME_MODE: str = Field( default="production", @@ -328,6 +493,13 @@ class Settings(BaseSettings): TRIAL_DURATION_DAYS: int = Field(default=3) TRIAL_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=5.0) TRIAL_TRAFFIC_STRATEGY: str = Field(default="NO_RESET") + TRIAL_WITHOUT_TELEGRAM_ENABLED: bool = Field( + default=True, + description=( + "Allow trial activation for users who have not linked Telegram. " + "Disposable email domains are still blocked until Telegram is linked." + ), + ) TRIAL_SQUAD_UUIDS: Optional[str] = Field( default=None, description=( @@ -373,15 +545,6 @@ class Settings(BaseSettings): ), ) WEBAPP_LOGO_URL: Optional[str] = Field(default=None) - WEBAPP_LOGO_USE_EMOJI: bool = Field(default=False) - WEBAPP_LOGO_EMOJI: str = Field(default="🫥") - WEBAPP_LOGO_EMOJI_FONT: str = Field( - default="system", - description=( - "Emoji font for logo fallback: system, noto-color, noto-color-animated, " - "noto-emoji, twemoji, openmoji, apple, segoe, noto-local" - ), - ) WEBAPP_FAVICON_USE_CUSTOM: bool = Field(default=False) WEBAPP_FAVICON_URL: Optional[str] = Field(default=None) WEBAPP_LOGO_FAVICON_URL: Optional[str] = Field(default=None) @@ -440,6 +603,13 @@ class Settings(BaseSettings): SMTP_PASSWORD: Optional[str] = Field(default=None) SMTP_FROM_EMAIL: Optional[str] = Field(default=None) SMTP_FROM_NAME: Optional[str] = Field(default=None) + DISPOSABLE_EMAIL_DOMAINS: str = Field( + default=DEFAULT_DISPOSABLE_EMAIL_DOMAINS, + description=( + "Disposable email domains treated as requiring Telegram for trial and " + "referral welcome bonus abuse protection. Accepts commas or one domain per line." + ), + ) SMTP_STARTTLS: bool = Field(default=True) SMTP_USE_SSL: bool = Field(default=False) EMAIL_CODE_TTL_SECONDS: int = Field(default=10 * 60) @@ -544,9 +714,6 @@ class Settings(BaseSettings): title=self.WEBAPP_TITLE, primary_color=self.WEBAPP_PRIMARY_COLOR, logo_url=self.WEBAPP_LOGO_URL, - logo_use_emoji=self.WEBAPP_LOGO_USE_EMOJI, - logo_emoji=self.WEBAPP_LOGO_EMOJI, - logo_emoji_font=self.WEBAPP_LOGO_EMOJI_FONT, favicon_use_custom=self.WEBAPP_FAVICON_USE_CUSTOM, favicon_url=self.WEBAPP_FAVICON_URL, logo_favicon_url=self.WEBAPP_LOGO_FAVICON_URL, @@ -627,6 +794,16 @@ class Settings(BaseSettings): return trial_squads return self.parsed_user_squad_uuids + @computed_field + @property + def disposable_email_domains(self) -> List[str]: + domains: List[str] = [] + for domain in _split_csv(self.DISPOSABLE_EMAIL_DOMAINS): + normalized = domain.strip().lower().lstrip("@.") + if normalized and normalized not in domains: + domains.append(normalized) + return domains + @computed_field @property def parsed_user_external_squad_uuid(self) -> Optional[str]: @@ -788,21 +965,6 @@ class Settings(BaseSettings): def ignore_deprecated_webapp_logo_url_env(cls, _value): return None - @field_validator("WEBAPP_LOGO_USE_EMOJI", mode="before") - @classmethod - def ignore_deprecated_webapp_logo_use_emoji_env(cls, _value): - return False - - @field_validator("WEBAPP_LOGO_EMOJI", mode="before") - @classmethod - def ignore_deprecated_webapp_logo_emoji_env(cls, _value): - return "🫥" - - @field_validator("WEBAPP_LOGO_EMOJI_FONT", mode="before") - @classmethod - def ignore_deprecated_webapp_logo_emoji_font_env(cls, _value): - return "system" - @field_validator("WEBAPP_FAVICON_USE_CUSTOM", mode="before") @classmethod def ignore_deprecated_webapp_favicon_use_custom_env(cls, _value): @@ -893,6 +1055,7 @@ class Settings(BaseSettings): "stars", "cryptopay", "heleket", + "paykilla", ] # Make sure default_order itself includes every registered spec. for sid in spec_ids: @@ -1105,9 +1268,10 @@ class Settings(BaseSettings): TELEMETRY_ENABLED: bool = Field( default=True, description=( - "Send an anonymous daily install heartbeat (version, OS, locale, " - "user-count range). No personal data. Opt out here, via the web " - "admin, or by clearing TELEMETRY_ENDPOINT/TELEMETRY_API_KEY." + "Send an anonymous daily install heartbeat (version, official/custom " + "image provenance, OS, locale, user-count range). No personal data. " + "Opt out here, via the web admin, or by clearing " + "TELEMETRY_ENDPOINT/TELEMETRY_API_KEY." ), ) TELEMETRY_ENDPOINT: str = Field( diff --git a/backend/config/webapp_themes_config.py b/backend/config/webapp_themes_config.py index 243697b..2dc3415 100644 --- a/backend/config/webapp_themes_config.py +++ b/backend/config/webapp_themes_config.py @@ -54,6 +54,8 @@ class ThemeTokens(BaseModel): font_logo: Optional[str] = None font_mono: Optional[str] = None home_logo_scale: Optional[int] = None + home_logo_scale_desktop: Optional[int] = None + home_logo_scale_mobile: Optional[int] = None admin_bg: Optional[str] = None admin_surface: Optional[str] = None admin_surface_2: Optional[str] = None @@ -80,14 +82,14 @@ class ThemeTokens(BaseModel): hex_value = "".join(char * 2 for char in hex_value) return f"#{hex_value}" - @field_validator("home_logo_scale") + @field_validator("home_logo_scale", "home_logo_scale_desktop", "home_logo_scale_mobile") @classmethod def _normalize_home_logo_scale(cls, value: Optional[int]) -> Optional[int]: if value is None: return None scale = int(value) if scale < 50 or scale > 300: - raise ValueError("home_logo_scale must be between 50 and 300 percent") + raise ValueError("home logo scale must be between 50 and 300 percent") return scale @@ -369,6 +371,8 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool: "--success-text" not in style or ".theme-key-light.app-shell" not in style or "Install guide theme surfaces" not in style + or "Admin controls: range sliders and sortable rows" not in style + or "Admin health config alerts" not in style ) if key == "ascii": return ( @@ -379,6 +383,8 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool: or "Console-style tables" not in style or "New webapp surfaces: support, purchase info, password login" not in style or "Install guide theme surfaces" not in style + or "Admin controls: range sliders and sortable rows" not in style + or "Admin health config alerts" not in style ) if key != "windows95": return False @@ -402,6 +408,8 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool: or "lucide-qr-code" not in style or "New webapp surfaces: support, purchase info, password login" not in style or "Install guide theme surfaces" not in style + or "Admin controls: range sliders and sortable rows" not in style + or "Admin health config alerts" not in style or any(not (target_dir / "icons" / icon).exists() for icon in required_icons) ) @@ -614,6 +622,38 @@ def merge_primary_accent_into_theme_tokens( return base +def effective_webapp_theme_accent( + config: WebappThemesConfig, + primary_accent: str, + *, + theme_key: Optional[str] = None, +) -> str: + """Return the accent color users see for the selected/default Web App theme.""" + try: + fallback = ThemeTokens(accent=primary_accent or "#00fe7a").accent or "#00fe7a" + except ValueError: + fallback = "#00fe7a" + theme: Optional[WebappTheme] = None + if theme_key: + theme = config.theme_by_key(theme_key) + if theme is not None and not theme.enabled: + theme = None + if theme is None: + theme = config.theme_by_key(config.default_theme) + if theme is None: + enabled = config.enabled_themes() + theme = enabled[0] if enabled else None + if theme is None: + return fallback + + tokens = ( + merge_primary_accent_into_theme_tokens(theme, fallback) + if theme.use_primary_accent + else theme.tokens + ) + return tokens.accent or fallback + + def public_theme_payload(theme: WebappTheme, primary_accent: str) -> Dict[str, object]: tokens = ( merge_primary_accent_into_theme_tokens(theme, primary_accent) diff --git a/backend/db/dal/payment_dal.py b/backend/db/dal/payment_dal.py index eec2f4e..32f32b6 100644 --- a/backend/db/dal/payment_dal.py +++ b/backend/db/dal/payment_dal.py @@ -1,7 +1,7 @@ import logging from typing import Any, Dict, List, Optional -from sqlalchemy import Date, and_, case, cast, func +from sqlalchemy import Date, and_, case, cast, func, or_ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from sqlalchemy.orm import joinedload, selectinload @@ -51,6 +51,15 @@ async def ensure_payment_with_provider_id( description: str, provider: str, provider_payment_id: str, + sale_mode: Optional[str] = None, + tariff_key: Optional[str] = None, + purchased_gb: Optional[float] = None, + purchased_hwid_devices: Optional[int] = None, + hwid_valid_from: Optional[Any] = None, + hwid_valid_until: Optional[Any] = None, + hwid_pricing_period_months: Optional[int] = None, + hwid_proration_ratio: Optional[float] = None, + hwid_full_price: Optional[float] = None, ) -> Payment: """Idempotently create a payment record for a provider event. @@ -72,6 +81,20 @@ async def ensure_payment_with_provider_id( "provider_payment_id": provider_payment_id, "provider": provider, } + optional_fields = { + "sale_mode": sale_mode, + "tariff_key": tariff_key, + "purchased_gb": purchased_gb, + "purchased_hwid_devices": purchased_hwid_devices, + "hwid_valid_from": hwid_valid_from, + "hwid_valid_until": hwid_valid_until, + "hwid_pricing_period_months": hwid_pricing_period_months, + "hwid_proration_ratio": hwid_proration_ratio, + "hwid_full_price": hwid_full_price, + } + payment_payload.update( + {field: value for field, value in optional_fields.items() if value is not None} + ) return await create_payment_record(session, payment_payload) @@ -93,31 +116,41 @@ async def find_recent_pending_provider_payment( provider: str, pending_status: str, amount: float, + currency: Optional[str], sale_mode: Optional[str], months: Optional[int], purchased_gb: Optional[float], purchased_hwid_devices: Optional[int], tariff_key: Optional[str] = None, - since_minutes: int = 60, + since_minutes: Optional[int] = None, ) -> Optional[Payment]: """Return the most recent pending payment matching the given tariff parameters. Used to reuse an existing provider payment link instead of creating a new one - on repeated user clicks. Only payments with a populated ``provider_payment_id`` - are returned — without it, there's no link to reuse. + on repeated user clicks. A generic or provider-specific payment id must be + populated so the caller can verify the remote payment link. + + Status matching is case-insensitive and also accepts the generic ``pending`` + alias so legacy rows (e.g. Platega ``PENDING`` or YooKassa ``pending``) stay + reusable after provider APIs overwrite the internal pending status. """ from datetime import datetime, timedelta, timezone - cutoff = datetime.now(timezone.utc) - timedelta(minutes=max(1, since_minutes)) - conditions = [ Payment.user_id == user_id, Payment.provider == provider, - Payment.status == pending_status, - Payment.provider_payment_id.isnot(None), - Payment.created_at >= cutoff, + func.lower(Payment.status).in_(tuple({str(pending_status).lower(), "pending"})), + or_( + Payment.provider_payment_id.isnot(None), + Payment.yookassa_payment_id.isnot(None), + ), func.abs(Payment.amount - float(amount)) < 0.01, ] + if since_minutes is not None: + cutoff = datetime.now(timezone.utc) - timedelta(minutes=max(1, since_minutes)) + conditions.append(Payment.created_at >= cutoff) + if currency is not None: + conditions.append(func.upper(Payment.currency) == str(currency).strip().upper()) if sale_mode is not None: conditions.append(Payment.sale_mode == sale_mode) if tariff_key is not None: @@ -215,12 +248,18 @@ async def count_user_succeeded_payments( async def update_provider_payment_and_status( - session: AsyncSession, payment_db_id: int, provider_payment_id: str, new_status: str + session: AsyncSession, + payment_db_id: int, + provider_payment_id: str, + new_status: str, + provider_payment_url: Optional[str] = None, ) -> Optional[Payment]: payment = await get_payment_by_db_id(session, payment_db_id) if payment: payment.status = new_status payment.provider_payment_id = provider_payment_id + if provider_payment_url: + payment.provider_payment_url = provider_payment_url payment.updated_at = func.now() await session.flush() await session.refresh(payment) diff --git a/backend/db/dal/promo_code_dal.py b/backend/db/dal/promo_code_dal.py index 8047792..41a376b 100644 --- a/backend/db/dal/promo_code_dal.py +++ b/backend/db/dal/promo_code_dal.py @@ -22,24 +22,46 @@ async def get_promo_code_by_id(session: AsyncSession, promo_code_id: int) -> Opt return await session.get(PromoCode, promo_code_id) -async def get_promo_code_by_code(session: AsyncSession, code_str: str) -> Optional[PromoCode]: +def _promo_lookup_candidates(code_str: str, *, preserve_case: bool) -> List[str]: + code = str(code_str or "").strip() + if not code: + return [] + candidates = [code] if preserve_case else [] + upper_code = code.upper() + if upper_code not in candidates: + candidates.append(upper_code) + return candidates + + +async def get_promo_code_by_code( + session: AsyncSession, code_str: str, *, preserve_case: bool = False +) -> Optional[PromoCode]: """Get promo code by code string (regardless of active status)""" - stmt = select(PromoCode).where(PromoCode.code == code_str.upper()) - result = await session.execute(stmt) - return result.scalar_one_or_none() + for candidate in _promo_lookup_candidates(code_str, preserve_case=preserve_case): + stmt = select(PromoCode).where(PromoCode.code == candidate) + result = await session.execute(stmt) + promo = result.scalar_one_or_none() + if promo: + return promo + return None async def get_active_promo_code_by_code_str( - session: AsyncSession, code_str: str + session: AsyncSession, code_str: str, *, preserve_case: bool = False ) -> Optional[PromoCode]: - stmt = select(PromoCode).where( - PromoCode.code == code_str.upper(), - PromoCode.is_active == True, - PromoCode.current_activations < PromoCode.max_activations, - or_(PromoCode.valid_until == None, PromoCode.valid_until > datetime.now(timezone.utc)), - ) - result = await session.execute(stmt) - return result.scalar_one_or_none() + now = datetime.now(timezone.utc) + for candidate in _promo_lookup_candidates(code_str, preserve_case=preserve_case): + stmt = select(PromoCode).where( + PromoCode.code == candidate, + PromoCode.is_active == True, + PromoCode.current_activations < PromoCode.max_activations, + or_(PromoCode.valid_until == None, PromoCode.valid_until > now), + ) + result = await session.execute(stmt) + promo = result.scalar_one_or_none() + if promo: + return promo + return None async def get_all_active_promo_codes( diff --git a/backend/db/dal/tariff_dal.py b/backend/db/dal/tariff_dal.py index 0d2c6de..1cd1036 100644 --- a/backend/db/dal/tariff_dal.py +++ b/backend/db/dal/tariff_dal.py @@ -1,5 +1,5 @@ import inspect -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional from sqlalchemy import and_, delete, func, or_, select, update @@ -189,6 +189,63 @@ async def expire_hwid_device_purchases( return result.rowcount or 0 +def _normalize_aware_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value + + +async def extend_hwid_device_purchases_for_subscription_bonus( + session: AsyncSession, + *, + subscription_id: int, + at: Optional[datetime] = None, + subscription_end_before: Optional[datetime] = None, + delta: timedelta, +) -> int: + if delta.total_seconds() <= 0: + return 0 + at = _normalize_aware_utc(at or datetime.now(timezone.utc)) + end_before = _normalize_aware_utc(subscription_end_before) if subscription_end_before else None + + target_records: List[HwidDevicePurchase] = [] + if end_before: + tail_result = await session.execute( + select(HwidDevicePurchase).where( + and_( + HwidDevicePurchase.subscription_id == subscription_id, + HwidDevicePurchase.purchased_devices > 0, + HwidDevicePurchase.valid_until.is_not(None), + HwidDevicePurchase.valid_until >= end_before, + HwidDevicePurchase.valid_until > at, + or_( + HwidDevicePurchase.valid_from.is_(None), + HwidDevicePurchase.valid_from < end_before, + ), + ) + ) + ) + target_records = list(tail_result.scalars().all()) + + if not target_records: + active_result = await session.execute( + select(HwidDevicePurchase).where( + and_( + *_hwid_active_conditions(subscription_id, at), + HwidDevicePurchase.valid_until.is_not(None), + ) + ) + ) + target_records = list(active_result.scalars().all()) + + for record in target_records: + if record.valid_until is not None: + record.valid_until = _normalize_aware_utc(record.valid_until) + delta + if target_records: + await session.flush() + return len(target_records) + + async def create_tariff_change( session: AsyncSession, change_data: Dict[str, Any], diff --git a/backend/db/dal/user_dal.py b/backend/db/dal/user_dal.py index 6a3c1af..6467d89 100644 --- a/backend/db/dal/user_dal.py +++ b/backend/db/dal/user_dal.py @@ -4,7 +4,7 @@ import string from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional, Tuple -from sqlalchemy import and_, case, delete, desc, func, or_, update +from sqlalchemy import String, and_, case, cast, delete, desc, func, or_, update from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select @@ -14,6 +14,8 @@ from ..models import ( AdAttribution, EmailVerificationCode, HwidDevicePurchase, + LegacyImportMapping, + LegacyReferralCode, MessageLog, Payment, PromoCodeActivation, @@ -76,7 +78,7 @@ async def ensure_referral_code(session: AsyncSession, user: User) -> str: Returns the existing or newly generated code. """ if user.referral_code: - normalized = user.referral_code.strip().upper() + normalized = user.referral_code.strip() if normalized != user.referral_code: user.referral_code = normalized await session.flush() @@ -210,7 +212,7 @@ async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> Tuple if not user_data.get("referral_code"): user_data["referral_code"] = await generate_unique_referral_code(session) else: - user_data["referral_code"] = user_data["referral_code"].strip().upper() + user_data["referral_code"] = user_data["referral_code"].strip() # Use PostgreSQL upsert to avoid IntegrityError on concurrent inserts stmt = ( @@ -567,6 +569,19 @@ async def merge_users( await session.execute( update(model).where(model.user_id == source_user_id).values(user_id=target_user_id) ) + await session.execute( + update(LegacyReferralCode) + .where(LegacyReferralCode.user_id == source_user_id) + .values(user_id=target_user_id) + ) + await session.execute( + update(LegacyImportMapping) + .where( + LegacyImportMapping.target_table == "users", + LegacyImportMapping.target_id == str(source_user_id), + ) + .values(target_id=str(target_user_id)) + ) await session.execute( update(MessageLog) @@ -590,13 +605,60 @@ async def merge_users( return target -async def get_user_by_referral_code(session: AsyncSession, referral_code: str) -> Optional[User]: - normalized = referral_code.strip().upper() +async def get_user_by_referral_code( + session: AsyncSession, + referral_code: str, + *, + include_legacy: bool = False, +) -> Optional[User]: + normalized = referral_code.strip() if not normalized: return None + stmt = select(User).where(User.referral_code == normalized) result = await session.execute(stmt) - return result.scalar_one_or_none() + user = result.scalar_one_or_none() + if user: + return user + + upper_normalized = normalized.upper() + if upper_normalized != normalized: + stmt = select(User).where(User.referral_code == upper_normalized) + result = await session.execute(stmt) + user = result.scalar_one_or_none() + if user: + return user + + if not include_legacy: + return None + + stmt = ( + select(User) + .join(LegacyReferralCode, LegacyReferralCode.user_id == User.user_id) + .where(LegacyReferralCode.code == normalized, LegacyReferralCode.is_active == True) + .limit(1) + ) + result = await session.execute(stmt) + user = result.scalar_one_or_none() + if user: + return user + + if upper_normalized != normalized: + stmt = ( + select(User) + .join(LegacyReferralCode, LegacyReferralCode.user_id == User.user_id) + .where( + LegacyReferralCode.code == upper_normalized, + LegacyReferralCode.is_active == True, + ) + .limit(1) + ) + result = await session.execute(stmt) + user = result.scalar_one_or_none() + if user: + return user + + return None async def update_user( @@ -653,6 +715,12 @@ async def get_all_active_user_ids_for_broadcast(session: AsyncSession) -> List[i return result.scalars().all() +async def count_all_active_users_for_broadcast(session: AsyncSession) -> int: + stmt = select(func.count(User.user_id)).where(User.is_banned == False) + result = await session.execute(stmt) + return int(result.scalar_one() or 0) + + async def get_all_users_with_panel_uuid(session: AsyncSession) -> List[User]: stmt = select(User).where(User.panel_user_uuid.is_not(None)) result = await session.execute(stmt) @@ -828,6 +896,27 @@ async def get_user_ids_with_active_subscription(session: AsyncSession) -> List[i return result.scalars().all() +async def count_users_with_active_subscription_for_broadcast(session: AsyncSession) -> int: + """Count non-banned users who have any active subscription.""" + from datetime import datetime, timezone + + now = datetime.now(timezone.utc) + + stmt = ( + select(func.count(func.distinct(Subscription.user_id))) + .join(User, Subscription.user_id == User.user_id) + .where( + and_( + User.is_banned == False, + Subscription.is_active == True, + Subscription.end_date > now, + ) + ) + ) + result = await session.execute(stmt) + return int(result.scalar_one() or 0) + + async def get_user_ids_without_active_subscription(session: AsyncSession) -> List[int]: """Return non-banned user IDs who do NOT have any active subscription.""" from datetime import datetime, timezone @@ -857,6 +946,61 @@ async def get_user_ids_without_active_subscription(session: AsyncSession) -> Lis return result.scalars().all() +async def count_users_without_active_subscription_for_broadcast(session: AsyncSession) -> int: + """Count non-banned users who do NOT have any active subscription.""" + from datetime import datetime, timezone + + now = datetime.now(timezone.utc) + + stmt = select(func.count(User.user_id)).where( + User.is_banned == False, + ~_active_subscription_exists_for_user(now), + ) + result = await session.execute(stmt) + return int(result.scalar_one() or 0) + + +async def get_user_ids_without_any_subscription(session: AsyncSession) -> List[int]: + """Return non-banned user IDs who never had any subscription or trial. + + These are users who registered but have no ``Subscription`` rows at all — + no active, no expired and no trial history. In other words, accounts that + signed up and never did anything. + """ + any_sub = aliased(Subscription) + + stmt = ( + select(User.user_id) + .outerjoin(any_sub, any_sub.user_id == User.user_id) + .where( + and_( + User.is_banned == False, + any_sub.user_id.is_(None), + ) + ) + ) + result = await session.execute(stmt) + return result.scalars().all() + + +async def count_users_without_any_subscription_for_broadcast(session: AsyncSession) -> int: + """Count non-banned users who never had any subscription or trial.""" + any_sub = aliased(Subscription) + + stmt = ( + select(func.count(User.user_id)) + .outerjoin(any_sub, any_sub.user_id == User.user_id) + .where( + and_( + User.is_banned == False, + any_sub.user_id.is_(None), + ) + ) + ) + result = await session.execute(stmt) + return int(result.scalar_one() or 0) + + def _expired_subscription_exists_for_user(now: datetime): expired_subs = aliased(Subscription) normalized_status = func.lower(func.coalesce(expired_subs.status_from_panel, "")) @@ -903,6 +1047,20 @@ async def count_users_with_expired_subscription(session: AsyncSession) -> int: return int(result.scalar_one() or 0) +async def count_users_with_expired_subscription_for_broadcast(session: AsyncSession) -> int: + """Count non-banned users with an expired subscription and no active one.""" + from datetime import datetime, timezone + + now = datetime.now(timezone.utc) + stmt = select(func.count(User.user_id)).where( + User.is_banned == False, + _expired_subscription_exists_for_user(now), + ~_active_subscription_exists_for_user(now), + ) + result = await session.execute(stmt) + return int(result.scalar_one() or 0) + + async def get_user_ids_with_expired_subscription(session: AsyncSession) -> List[int]: """Return non-banned user IDs with an expired subscription and no active one.""" from datetime import datetime, timezone @@ -997,6 +1155,31 @@ async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool await session.execute(delete(UserBilling).where(UserBilling.user_id == user_id)) await session.execute(delete(AdAttribution).where(AdAttribution.user_id == user_id)) await session.execute(delete(UserTelegramAvatar).where(UserTelegramAvatar.user_id == user_id)) + await session.execute(delete(LegacyReferralCode).where(LegacyReferralCode.user_id == user_id)) + await session.execute( + delete(LegacyImportMapping).where( + or_( + and_( + LegacyImportMapping.target_table == "users", + LegacyImportMapping.target_id == str(user_id), + ), + and_( + LegacyImportMapping.target_table == "subscriptions", + LegacyImportMapping.target_id.in_( + select(cast(Subscription.subscription_id, String)).where( + Subscription.user_id == user_id + ) + ), + ), + and_( + LegacyImportMapping.target_table == "payments", + LegacyImportMapping.target_id.in_( + select(cast(Payment.payment_id, String)).where(Payment.user_id == user_id) + ), + ), + ) + ) + ) await session.execute(delete(Payment).where(Payment.user_id == user_id)) await session.execute(delete(Subscription).where(Subscription.user_id == user_id)) diff --git a/backend/db/migrator.py b/backend/db/migrator.py index 73b3e3c..e55fad8 100644 --- a/backend/db/migrator.py +++ b/backend/db/migrator.py @@ -1070,6 +1070,98 @@ def _migration_0033_add_trial_eligibility_reset_marker(connection: Connection) - ) +def _migration_0034_add_legacy_import_compatibility(connection: Connection) -> None: + inspector = inspect(connection) + table_names = set(inspector.get_table_names()) + + if "users" in table_names: + columns = {col["name"]: col for col in inspector.get_columns("users")} + referral_column = columns.get("referral_code") + length = getattr(referral_column.get("type"), "length", None) if referral_column else None + if referral_column and (length is None or int(length) < 64): + connection.execute( + text("ALTER TABLE users ALTER COLUMN referral_code TYPE VARCHAR(64)") + ) + + connection.execute( + text( + """ + CREATE TABLE IF NOT EXISTS legacy_referral_codes ( + legacy_code_id SERIAL PRIMARY KEY, + source VARCHAR(64) NOT NULL DEFAULT 'remnashop', + code VARCHAR(128) NOT NULL, + user_id BIGINT NOT NULL REFERENCES users(user_id), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NULL, + CONSTRAINT uq_legacy_referral_source_code UNIQUE (source, code) + ) + """ + ) + ) + for stmt in [ + ( + "CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_source " + "ON legacy_referral_codes (source)" + ), + "CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_code ON legacy_referral_codes (code)", + ( + "CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_user_id " + "ON legacy_referral_codes (user_id)" + ), + ( + "CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_is_active " + "ON legacy_referral_codes (is_active)" + ), + ]: + connection.execute(text(stmt)) + + connection.execute( + text( + """ + CREATE TABLE IF NOT EXISTS legacy_import_mappings ( + source VARCHAR(64) NOT NULL, + entity_type VARCHAR(64) NOT NULL, + source_id VARCHAR(128) NOT NULL, + target_table VARCHAR(128) NOT NULL, + target_id VARCHAR(128) NOT NULL, + metadata_json TEXT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NULL, + PRIMARY KEY (source, entity_type, source_id) + ) + """ + ) + ) + connection.execute( + text( + """ + CREATE INDEX IF NOT EXISTS ix_legacy_import_mappings_target + ON legacy_import_mappings (target_table, target_id) + """ + ) + ) + + +def _migration_0035_add_subscription_promo_expiry_flag(connection: Connection) -> None: + inspector = inspect(connection) + columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")} + if "suppress_early_expiry_notifications" not in columns: + connection.execute( + text( + "ALTER TABLE subscriptions ADD COLUMN suppress_early_expiry_notifications " + "BOOLEAN NOT NULL DEFAULT FALSE" + ) + ) + + +def _migration_0036_add_provider_payment_url(connection: Connection) -> None: + inspector = inspect(connection) + columns: Set[str] = {col["name"] for col in inspector.get_columns("payments")} + if "provider_payment_url" not in columns: + connection.execute(text("ALTER TABLE payments ADD COLUMN provider_payment_url VARCHAR")) + + MIGRATIONS: List[Migration] = [ Migration( id="0001_add_channel_subscription_fields", @@ -1247,6 +1339,21 @@ MIGRATIONS: List[Migration] = [ description="Track admin resets of per-user trial eligibility without deleting history", upgrade=_migration_0033_add_trial_eligibility_reset_marker, ), + Migration( + id="0034_add_legacy_import_compatibility", + description="Store legacy import mappings and referral codes for source-bot migrations", + upgrade=_migration_0034_add_legacy_import_compatibility, + ), + Migration( + id="0035_add_subscription_promo_expiry_flag", + description="Suppress multi-day expiry reminders for trial and bonus subscriptions", + upgrade=_migration_0035_add_subscription_promo_expiry_flag, + ), + Migration( + id="0036_add_provider_payment_url", + description="Persist provider payment links for reusable pending payments", + upgrade=_migration_0036_add_provider_payment_url, + ), ] diff --git a/backend/db/models.py b/backend/db/models.py index 78bb8c3..348d2df 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -43,7 +43,7 @@ class User(Base): registration_date = Column(DateTime(timezone=True), server_default=func.now()) is_banned = Column(Boolean, default=False) panel_user_uuid = Column(String, nullable=True, unique=True, index=True) - referral_code = Column(String(16), nullable=True, unique=True, index=True) + referral_code = Column(String(64), nullable=True, unique=True, index=True) referred_by_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=True) lifetime_used_traffic_bytes = Column(BigInteger, nullable=True) lifetime_used_traffic_synced_at = Column(DateTime(timezone=True), nullable=True) @@ -122,6 +122,12 @@ class Subscription(Base): last_notification_sent = Column(DateTime(timezone=True), nullable=True) provider = Column(String, nullable=True) skip_notifications = Column(Boolean, default=False) + # Trial and registration/referral-bonus subscriptions are only a few days + # long, so the multi-day "ending soon" reminders would fire almost as soon + # as they are granted. While this is set the worker keeps only the + # hours-before reminder plus the expiry/after-expiry notices; a real payment + # clears it so the full reminder spectrum resumes. + suppress_early_expiry_notifications = Column(Boolean, nullable=False, default=False) auto_renew_enabled = Column(Boolean, default=True, index=True) tariff_key = Column(String, nullable=True, index=True) tier_baseline_bytes = Column(BigInteger, nullable=True) @@ -197,6 +203,7 @@ class Payment(Base): user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True) yookassa_payment_id = Column(String, unique=True, index=True, nullable=True) provider_payment_id = Column(String, unique=True, nullable=True) + provider_payment_url = Column(String, nullable=True) provider = Column(String, nullable=False, default="yookassa", index=True) idempotence_key = Column(String, unique=True, nullable=True) amount = Column(Float, nullable=False) @@ -396,6 +403,35 @@ class PromoCodeActivation(Base): ) +class LegacyReferralCode(Base): + __tablename__ = "legacy_referral_codes" + + legacy_code_id = Column(Integer, primary_key=True, autoincrement=True) + source = Column(String(64), nullable=False, default="remnashop", index=True) + code = Column(String(128), nullable=False, index=True) + user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True) + is_active = Column(Boolean, nullable=False, default=True, index=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True) + + user = relationship("User") + + __table_args__ = (UniqueConstraint("source", "code", name="uq_legacy_referral_source_code"),) + + +class LegacyImportMapping(Base): + __tablename__ = "legacy_import_mappings" + + source = Column(String(64), primary_key=True) + entity_type = Column(String(64), primary_key=True) + source_id = Column(String(128), primary_key=True) + target_table = Column(String(128), nullable=False) + target_id = Column(String(128), nullable=False) + metadata_json = Column(Text, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True) + + class MessageLog(Base): __tablename__ = "message_logs" diff --git a/backend/requirements.txt b/backend/requirements.txt index b1aacda..7bda3c0 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,6 +1,7 @@ -aiogram==3.28.2 +# Upstream aiogram 3.28.2 commit with aiohttp<3.15 support. +aiogram @ https://github.com/aiogram/aiogram/archive/8c2f78d51f10f3e2189d3828ba80ca3c1f094b5d.zip python-dotenv==1.2.2 -aiohttp==3.13.5 +aiohttp>=3.14.0,<4 pydantic==2.13.4 yookassa==3.10.1 httpx>=0.27.0 @@ -9,6 +10,6 @@ email-validator==2.3.0 sqlalchemy[asyncio]==2.0.49 asyncpg==0.31.0 aiocryptopay==0.4.8 -PyJWT[crypto]==2.12.1 +PyJWT[crypto]==2.13.0 Pillow==12.2.0 redis==6.4.0 diff --git a/backend/scripts/__init__.py b/backend/scripts/__init__.py new file mode 100644 index 0000000..51b3887 --- /dev/null +++ b/backend/scripts/__init__.py @@ -0,0 +1 @@ +"""Operational one-shot scripts shipped with the backend image.""" diff --git a/backend/scripts/import_legacy.py b/backend/scripts/import_legacy.py new file mode 100644 index 0000000..d2352a2 --- /dev/null +++ b/backend/scripts/import_legacy.py @@ -0,0 +1,1796 @@ +"""Import data from legacy source bots into the current shop database. + +Currently supported source: + remnashop + +Example: + python backend/scripts/import_legacy.py \ + --source-type remnashop \ + --source-dsn postgresql://user:pass@localhost:5432/remnashop \ + --dry-run +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import logging +import re +import shlex +import sys +from collections import defaultdict +from datetime import datetime, timedelta, timezone +from decimal import Decimal, InvalidOperation +from pathlib import Path +from typing import Any, Iterable, Optional + +from sqlalchemy import inspect, select, text +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.ext.asyncio import ( + AsyncConnection, + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +BACKEND_ROOT = Path(__file__).resolve().parents[1] +if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) + +from config.settings import Settings # noqa: E402 +from db.dal import user_dal # noqa: E402 +from db.migrator import run_database_migrations # noqa: E402 +from db.models import ( # noqa: E402 + AppSettingOverride, + Base, + LegacyImportMapping, + LegacyReferralCode, + MessageLog, + Payment, + PromoCode, + PromoCodeActivation, + Subscription, + User, +) + +try: # cryptography is already used by the app for payment webhook validation. + from cryptography.fernet import Fernet +except Exception: # pragma: no cover - defensive fallback for minimal tooling. + Fernet = None # type: ignore[assignment] + +SOURCE = "remnashop" +REMNASHOP_ENCRYPTED_PREFIX = "enc_" +PLACEHOLDER_SETTING_VALUES = {"change_me", "changeme"} +GIB = 1024**3 +UUID_RE = re.compile( + r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" + r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b" +) +SAFE_SCHEMA_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +REMNASHOP_PAYMENT_WEBHOOK_PATH = "/api/v1/payments/{gateway}" +REMNASHOP_PANEL_WEBHOOK_PATH = "/api/v1/remnawave" + +SUPPORTED_REMNASHOP_PROVIDER_TYPES = { + "TELEGRAM_STARS", + "YOOKASSA", + "HELEKET", + "PAYKILLA", + "CRYPTOPAY", + "FREEKASSA", + "PLATEGA", + "WATA", +} +UNSUPPORTED_REMNASHOP_PROVIDER_TYPES = { + "YOOMONEY", + "CRYPTOMUS", + "MULENPAY", + "PAYMASTER", + "ROBOKASSA", + "URLPAY", +} +PAYMENT_WEBHOOK_PATHS = { + "yookassa": "/webhook/yookassa", + "wata": "/webhook/wata", + "cryptopay": "/webhook/cryptopay", + "heleket": "/webhook/heleket", + "paykilla": "/webhook/paykilla", + "freekassa": "/webhook/freekassa", + "platega": "/webhook/platega", +} + +logger = logging.getLogger(__name__) + + +def normalize_async_postgres_dsn(dsn: str) -> str: + value = str(dsn or "").strip() + if value.startswith("postgresql+asyncpg://"): + return value + if value.startswith("postgresql://"): + return "postgresql+asyncpg://" + value.removeprefix("postgresql://") + if value.startswith("postgres://"): + return "postgresql+asyncpg://" + value.removeprefix("postgres://") + return value + + +def _json_default(value: Any) -> str: + if isinstance(value, (datetime, Decimal)): + return str(value) + return str(value) + + +def _json_dumps(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, sort_keys=True, default=_json_default) + + +def _safe_schema_name(schema: str) -> str: + value = str(schema or "public").strip() + if not SAFE_SCHEMA_RE.fullmatch(value): + raise ValueError(f"Unsafe PostgreSQL schema name: {schema!r}") + return value + + +def _qtable(schema: str, table: str) -> str: + schema = _safe_schema_name(schema) + return f'"{schema}"."{table}"' + + +def _as_mapping(row: Any) -> dict[str, Any]: + return dict(row._mapping if hasattr(row, "_mapping") else row) + + +def _as_utc(value: Any) -> Optional[datetime]: + if value is None: + return None + if isinstance(value, datetime): + result = value + else: + text_value = str(value).strip() + if not text_value: + return None + try: + result = datetime.fromisoformat(text_value.replace("Z", "+00:00")) + except ValueError: + return None + if result.tzinfo is None: + return result.replace(tzinfo=timezone.utc) + return result.astimezone(timezone.utc) + + +def _to_decimal(value: Any) -> Optional[Decimal]: + if value is None: + return None + try: + return Decimal(str(value)) + except (InvalidOperation, ValueError): + return None + + +def _to_int(value: Any) -> Optional[int]: + number = _to_decimal(value) + if number is None: + return None + try: + return int(number) + except (OverflowError, ValueError): + return None + + +def _split_name(name: Any) -> tuple[Optional[str], Optional[str]]: + value = str(name or "").strip() + if not value: + return None, None + parts = value.split(maxsplit=1) + if len(parts) == 1: + return parts[0][:255], None + return parts[0][:255], parts[1][:255] + + +def _jsonish(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + if isinstance(value, str) and value.strip(): + try: + decoded = json.loads(value) + except ValueError: + return {} + return decoded if isinstance(decoded, dict) else {} + return {} + + +def _strip_env_value(value: str) -> str: + lexer = shlex.shlex(value, posix=True) + lexer.whitespace_split = True + lexer.commenters = "#" + try: + tokens = list(lexer) + except ValueError: + return value.strip().strip("\"'") + return " ".join(tokens).strip() + + +def parse_remnashop_env_text(text_value: str) -> dict[str, str]: + env: dict[str, str] = {} + for raw_line in str(text_value or "").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line[len("export ") :].strip() + if "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + if not key or not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", key): + continue + env[key] = _strip_env_value(value) + return env + + +def read_remnashop_env_file(path: Optional[str]) -> dict[str, str]: + if not path: + return {} + return parse_remnashop_env_text(Path(path).read_text(encoding="utf-8")) + + +def _is_placeholder_setting_value(value: Any) -> bool: + return isinstance(value, str) and value.strip().lower() in PLACEHOLDER_SETTING_VALUES + + +def _clean_url(value: Any) -> Optional[str]: + if _is_placeholder_setting_value(value): + return None + text_value = str(value or "").strip().rstrip("/") + return text_value or None + + +def _remnashop_panel_api_url(value: Any) -> Optional[str]: + host = _clean_url(value) + if not host: + return None + if "://" not in host: + if "." in host: + host = f"https://{host}" + else: + host = f"http://{host}:3000" + if not host.rstrip("/").endswith("/api"): + host = f"{host.rstrip('/')}/api" + return host + + +def _source_public_base_from_env(env: dict[str, str]) -> Optional[str]: + domain = _clean_url(env.get("APP_DOMAIN")) + if not domain: + return None + if "://" not in domain: + domain = f"https://{domain}" + return domain + + +def _support_link_from_username(value: Any) -> Optional[str]: + if _is_placeholder_setting_value(value): + return None + username = str(value or "").strip().lstrip("@") + if not username: + return None + return f"https://t.me/{username}" + + +def _add_override(overrides: dict[str, Any], key: str, value: Any) -> None: + if value is None: + return + if isinstance(value, str): + value = value.strip() + if not value: + return + if value.lower() in PLACEHOLDER_SETTING_VALUES: + return + overrides[key] = value + + +def remnashop_env_overrides(env: dict[str, str]) -> dict[str, Any]: + overrides: dict[str, Any] = {} + _add_override(overrides, "PANEL_API_URL", _remnashop_panel_api_url(env.get("REMNAWAVE_HOST"))) + _add_override(overrides, "PANEL_API_KEY", env.get("REMNAWAVE_TOKEN")) + _add_override(overrides, "PANEL_WEBHOOK_SECRET", env.get("REMNAWAVE_WEBHOOK_SECRET")) + _add_override( + overrides, + "SUPPORT_LINK", + _support_link_from_username(env.get("BOT_SUPPORT_USERNAME")), + ) + _add_override(overrides, "DEFAULT_LANGUAGE", env.get("APP_DEFAULT_LOCALE")) + return overrides + + +def remnashop_source_urls_from_env(env: dict[str, str]) -> dict[str, str]: + base = _source_public_base_from_env(env) + if not base: + return {} + return { + "telegram": f"{base}/api/v1/telegram", + "remnawave_panel": f"{base}{REMNASHOP_PANEL_WEBHOOK_PATH}", + "payments": f"{base}/api/v1/payments/", + } + + +def _normalize_gateway_type(value: Any) -> str: + if hasattr(value, "value"): + value = value.value + text_value = str(value or "").strip().upper() + if "." in text_value: + text_value = text_value.rsplit(".", 1)[-1] + return re.sub(r"[^A-Z0-9_]+", "_", text_value).strip("_") + + +def _normalize_currency(value: Any) -> Optional[str]: + text_value = str(value or "").strip().upper() + if "." in text_value: + text_value = text_value.rsplit(".", 1)[-1] + aliases = {"RUR": "RUB", "STARS": "XTR", "STAR": "XTR"} + normalized = aliases.get(text_value, text_value) + return normalized or None + + +def _truthy(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + return str(value or "").strip().lower() in {"1", "true", "yes", "on", "active"} + + +def _is_encrypted_remnashop_value(value: Any) -> bool: + return isinstance(value, str) and value.startswith(REMNASHOP_ENCRYPTED_PREFIX) + + +def remnashop_decrypt_value(value: Any, crypt_key: Optional[str]) -> tuple[Any, bool]: + if not _is_encrypted_remnashop_value(value): + return value, False + if not crypt_key or Fernet is None: + return None, True + try: + token = str(value).removeprefix(REMNASHOP_ENCRYPTED_PREFIX).encode() + return Fernet(crypt_key.encode()).decrypt(token).decode(), False + except Exception: + return None, True + + +def remnashop_decrypt_recursive( + value: Any, + crypt_key: Optional[str], + *, + skipped_paths: Optional[list[str]] = None, + path: str = "", +) -> Any: + if isinstance(value, dict): + return { + key: remnashop_decrypt_recursive( + item, + crypt_key, + skipped_paths=skipped_paths, + path=f"{path}.{key}" if path else str(key), + ) + for key, item in value.items() + } + if isinstance(value, list): + return [ + remnashop_decrypt_recursive( + item, + crypt_key, + skipped_paths=skipped_paths, + path=f"{path}[{index}]", + ) + for index, item in enumerate(value) + ] + decrypted, skipped = remnashop_decrypt_value(value, crypt_key) + if skipped and skipped_paths is not None: + skipped_paths.append(path or "") + return decrypted + + +def _provider_mapping_result( + gateway_type: str, + provider_ids: Iterable[str], + overrides: dict[str, Any], + warnings: Optional[list[str]] = None, +) -> dict[str, Any]: + return { + "source_type": gateway_type, + "provider_ids": [provider for provider in provider_ids if provider], + "overrides": overrides, + "warnings": warnings or [], + "supported": True, + } + + +def remnashop_payment_gateway_overrides( + row: dict[str, Any], + *, + crypt_key: Optional[str] = None, +) -> dict[str, Any]: + gateway_type = _normalize_gateway_type(row.get("type")) + if gateway_type not in SUPPORTED_REMNASHOP_PROVIDER_TYPES: + return { + "source_type": gateway_type, + "provider_ids": [], + "overrides": {}, + "warnings": [], + "supported": False, + } + + skipped_secret_paths: list[str] = [] + settings = remnashop_decrypt_recursive( + _jsonish(row.get("settings")), + crypt_key, + skipped_paths=skipped_secret_paths, + ) + active = _truthy(row.get("is_active")) + currency = _normalize_currency(row.get("currency")) + overrides: dict[str, Any] = {} + warnings = [ + ( + f"Skipped encrypted Remnashop {gateway_type} setting '{path}': " + "APP_CRYPT_KEY is missing or invalid" + ) + for path in skipped_secret_paths + ] + + if gateway_type == "TELEGRAM_STARS": + _add_override(overrides, "STARS_ENABLED", active) + return _provider_mapping_result(gateway_type, ["stars"], overrides, warnings) + + if gateway_type == "YOOKASSA": + _add_override(overrides, "YOOKASSA_ENABLED", active) + _add_override(overrides, "YOOKASSA_SHOP_ID", settings.get("shop_id")) + _add_override(overrides, "YOOKASSA_SECRET_KEY", settings.get("api_key")) + _add_override(overrides, "YOOKASSA_DEFAULT_RECEIPT_EMAIL", settings.get("customer")) + _add_override(overrides, "YOOKASSA_VAT_CODE", settings.get("vat_code")) + if currency and currency != "RUB": + warnings.append( + f"YooKassa supports RUB only in this shop; source currency was {currency}" + ) + return _provider_mapping_result(gateway_type, ["yookassa"], overrides, warnings) + + if gateway_type == "WATA": + _add_override(overrides, "WATA_ENABLED", active) + _add_override(overrides, "WATA_API_TOKEN", settings.get("api_key")) + return _provider_mapping_result(gateway_type, ["wata"], overrides, warnings) + + if gateway_type == "CRYPTOPAY": + _add_override(overrides, "CRYPTOPAY_ENABLED", active) + _add_override(overrides, "CRYPTOPAY_TOKEN", settings.get("api_key")) + if currency and currency != "RUB": + warnings.append( + f"CryptoPay source currency was {currency}; Minishop keeps payment currency " + "controlled by tariffs/default currency. Configure CRYPTOPAY_ASSET manually " + "if this instance needs a different default." + ) + return _provider_mapping_result(gateway_type, ["cryptopay"], overrides, warnings) + + if gateway_type == "HELEKET": + _add_override(overrides, "HELEKET_ENABLED", active) + _add_override(overrides, "HELEKET_MERCHANT_ID", settings.get("merchant_id")) + _add_override(overrides, "HELEKET_API_KEY", settings.get("api_key")) + if currency and currency != "RUB": + warnings.append( + f"Heleket source currency was {currency}; Minishop keeps payment currency " + "controlled by tariffs/default currency. Configure HELEKET_CURRENCY manually " + "if this instance needs a different default." + ) + return _provider_mapping_result(gateway_type, ["heleket"], overrides, warnings) + + if gateway_type == "PAYKILLA": + _add_override(overrides, "PAYKILLA_ENABLED", active) + _add_override( + overrides, + "PAYKILLA_API_KEY", + settings.get("api_key") or settings.get("public_key") or settings.get("publicKey"), + ) + _add_override( + overrides, + "PAYKILLA_SECRET_KEY", + settings.get("secret_key") or settings.get("secretKey"), + ) + if currency and currency != "RUB": + warnings.append( + f"PayKilla source currency was {currency}; Minishop keeps payment currency " + "controlled by tariffs/default currency. Configure PAYKILLA_CURRENCY and " + "PAYKILLA_PAYMENT_CURRENCIES manually if this instance needs a different default." + ) + return _provider_mapping_result(gateway_type, ["paykilla"], overrides, warnings) + + if gateway_type == "FREEKASSA": + _add_override(overrides, "FREEKASSA_ENABLED", active) + _add_override(overrides, "FREEKASSA_MERCHANT_ID", settings.get("shop_id")) + _add_override(overrides, "FREEKASSA_API_KEY", settings.get("api_key")) + _add_override(overrides, "FREEKASSA_SECOND_SECRET", settings.get("secret_word_2")) + _add_override(overrides, "FREEKASSA_PAYMENT_METHOD_ID", settings.get("payment_system_id")) + _add_override(overrides, "FREEKASSA_PAYMENT_IP", settings.get("customer_ip")) + if settings.get("customer_email"): + warnings.append( + "FreeKassa customer_email was captured by Remnashop but is not a " + "Minishop provider setting" + ) + return _provider_mapping_result(gateway_type, ["freekassa"], overrides, warnings) + + if gateway_type == "PLATEGA": + _add_override(overrides, "PLATEGA_ENABLED", active) + _add_override(overrides, "PLATEGA_SBP_ENABLED", active) + _add_override(overrides, "PLATEGA_MERCHANT_ID", settings.get("merchant_id")) + _add_override(overrides, "PLATEGA_SECRET", settings.get("api_key")) + _add_override(overrides, "PLATEGA_PAYMENT_METHOD", settings.get("payment_method")) + _add_override(overrides, "PLATEGA_SBP_METHOD", settings.get("payment_method")) + if currency and currency != "RUB": + warnings.append( + f"Platega source currency was {currency}; Minishop keeps payment currency " + "controlled by tariffs/default currency. Configure PLATEGA_SUPPORTED_CURRENCIES " + "manually if this instance needs a different currency." + ) + return _provider_mapping_result(gateway_type, ["platega_sbp"], overrides, warnings) + + return _provider_mapping_result(gateway_type, [], overrides, warnings) + + +def _target_webhook_url(base_url: Optional[str], path: str) -> Optional[str]: + base = _clean_url(base_url) + if not base: + return None + return f"{base}{path if path.startswith('/') else '/' + path}" + + +def remnashop_post_migration_actions( + *, + target_webhook_base_url: Optional[str], + imported_provider_ids: Iterable[str], + source_env: Optional[dict[str, str]] = None, +) -> dict[str, Any]: + provider_ids = list(dict.fromkeys(imported_provider_ids)) + payment_actions = [] + seen_paths: set[str] = set() + for provider_id in provider_ids: + path = PAYMENT_WEBHOOK_PATHS.get(provider_id) + if not path or path in seen_paths: + continue + seen_paths.add(path) + payment_actions.append( + { + "provider": provider_id, + "new_url": _target_webhook_url(target_webhook_base_url, path), + "where": { + "yookassa": "YooKassa merchant cabinet -> HTTP notifications URL", + "wata": "WATA merchant dashboard -> webhook/callback URL", + "cryptopay": "CryptoBot/Crypto Pay app -> webhook URL", + "heleket": "Heleket merchant dashboard -> payment webhook/callback URL", + "paykilla": "PayKilla Dashboard -> Settings -> Webhooks", + "freekassa": "FreeKassa shop settings -> notification/result URL", + "platega": "Platega merchant/project settings -> webhook URL", + }.get(provider_id, "Payment provider dashboard -> webhook/callback URL"), + } + ) + + return { + "webhook_base_url_configured": bool(_clean_url(target_webhook_base_url)), + "source_urls": remnashop_source_urls_from_env(source_env or {}), + "remnawave_panel": { + "new_url": _target_webhook_url(target_webhook_base_url, "/webhook/panel"), + "where": "Remnawave Panel -> WEBHOOK_URL", + "secret": ( + "Set the Remnawave webhook secret to the value stored in PANEL_WEBHOOK_SECRET." + ), + }, + "payment_providers": payment_actions, + "telegram": { + "new_url": _target_webhook_url(target_webhook_base_url, "/tg/webhook"), + "where": "Telegram webhook is set automatically by Minishop on startup.", + }, + } + + +def _listish(value: Any) -> list[Any]: + if value is None: + return [] + if isinstance(value, list): + return value + if isinstance(value, tuple): + return list(value) + return [value] + + +def remnashop_traffic_gb_to_bytes(value: Any) -> Optional[int]: + number = _to_decimal(value) + if number is None: + return None + return int(number * GIB) + + +def remnashop_pricing_amount(pricing: Any) -> float: + data = _jsonish(pricing) + for key in ("final_amount", "total_amount", "amount", "price"): + number = _to_decimal(data.get(key)) + if number is not None: + return float(number) + return 0.0 + + +def remnashop_pricing_currency(pricing: Any, fallback: Any = None) -> str: + data = _jsonish(pricing) + currency = str(data.get("currency") or fallback or "RUB").strip().upper() + return currency or "RUB" + + +def remnashop_transaction_status(status: Any, gateway_type: Any = None) -> str: + source_status = str(status or "").strip().upper() + provider = str(gateway_type or "").strip().lower() + if source_status == "COMPLETED": + return "succeeded" + if source_status == "PENDING": + return f"pending_{provider}" if provider else "pending" + if source_status == "CANCELED": + return "canceled" + if source_status == "REFUNDED": + return "refunded" + if source_status == "FAILED": + return "failed" + return source_status.lower() or "unknown" + + +def remnashop_sale_mode(purchase_type: Any) -> str: + source_type = str(purchase_type or "").strip().upper() + if source_type in {"NEW", "RENEW"}: + return "subscription" + if source_type == "CHANGE": + return "tariff_upgrade" + return source_type.lower() or "subscription" + + +def remnashop_months_from_plan_snapshot( + plan_snapshot: Any, + *, + created_at: Any = None, + expire_at: Any = None, +) -> Optional[int]: + data = _jsonish(plan_snapshot) + for key in ("duration_months", "months", "month"): + months = _to_int(data.get(key)) + if months and months > 0: + return months + + for key in ("duration_days", "days", "duration"): + days = _to_int(data.get(key)) + if days and days > 0: + return max(1, round(days / 30)) + + start = _as_utc(created_at) + end = _as_utc(expire_at) + if start and end and end > start: + return max(1, round((end - start).days / 30)) + return None + + +def remnashop_tariff_key(plan_snapshot: Any, tariff_map: dict[str, str]) -> Optional[str]: + data = _jsonish(plan_snapshot) + candidates = [ + data.get("id"), + data.get("name"), + data.get("tag"), + data.get("public_code"), + ] + for candidate in candidates: + key = str(candidate or "").strip() + if key and key in tariff_map: + return tariff_map[key] + return None + + +def _provider_value(gateway_type: Any) -> str: + value = str(gateway_type or "remnashop").strip().lower() + if value == "telegram_stars": + return "stars" + return value or "remnashop" + + +def _extract_panel_subscription_uuid(url: Any, panel_user_uuid: Optional[str]) -> Optional[str]: + value = str(url or "") + if not value: + return None + panel_user_uuid = str(panel_user_uuid or "").lower() + for match in UUID_RE.finditer(value): + candidate = match.group(0).lower() + if candidate != panel_user_uuid: + return candidate + return None + + +def _legacy_user_metadata(row: dict[str, Any]) -> dict[str, Any]: + keys = ( + "id", + "points", + "personal_discount", + "purchase_discount", + "role", + "is_rules_accepted", + "is_trial_available", + "language", + "current_subscription_id", + ) + return {key: row.get(key) for key in keys if row.get(key) is not None} + + +def _counter() -> dict[str, int]: + return defaultdict(int) + + +class RemnashopImporter: + def __init__( + self, + *, + source: AsyncConnection, + target: AsyncSession, + source_schema: str, + only: set[str], + on_conflict: str, + dry_run: bool, + created_by_admin_id: int, + tariff_map: dict[str, str], + write_admin_compat_overrides: bool, + source_env: Optional[dict[str, str]] = None, + source_crypt_key: Optional[str] = None, + target_webhook_base_url: Optional[str] = None, + ) -> None: + self.source = source + self.target = target + self.source_schema = _safe_schema_name(source_schema) + self.only = only + self.on_conflict = on_conflict + self.dry_run = dry_run + self.created_by_admin_id = created_by_admin_id + self.tariff_map = tariff_map + self.write_admin_compat_overrides = write_admin_compat_overrides + self.source_env = source_env or {} + self.source_crypt_key = source_crypt_key or self.source_env.get("APP_CRYPT_KEY") + self.target_webhook_base_url = target_webhook_base_url + self.tables: set[str] = set() + self.user_map: dict[int, int] = {} + self.imported_payment_provider_ids: list[str] = [] + self.summary: dict[str, Any] = { + "source": SOURCE, + "dry_run": dry_run, + "on_conflict": on_conflict, + "users": _counter(), + "referrals": _counter(), + "subscriptions": _counter(), + "payments": _counter(), + "promocodes": _counter(), + "payment_provider_settings": _counter(), + "settings": _counter(), + "warnings": [], + } + + async def run(self) -> dict[str, Any]: + self.tables = await self._source_tables() + await self._warn_missing_tables() + + if self._should_run("users"): + await self.import_users() + if self._should_run("referrals"): + await self.import_referrals() + if self._should_run("subscriptions"): + await self.import_subscriptions() + if self._should_run("payments"): + await self.import_payments() + if self._should_run("promocodes"): + await self.import_promocodes() + if self._should_run("settings"): + await self.import_settings() + + self.summary["post_migration_actions"] = remnashop_post_migration_actions( + target_webhook_base_url=self.target_webhook_base_url, + imported_provider_ids=self.imported_payment_provider_ids, + source_env=self.source_env, + ) + + if self.write_admin_compat_overrides: + await self._write_admin_overrides() + + return self._plain_summary() + + def _plain_summary(self) -> dict[str, Any]: + result = dict(self.summary) + for key, value in list(result.items()): + if isinstance(value, defaultdict): + result[key] = dict(value) + return result + + def _should_run(self, key: str) -> bool: + return not self.only or key in self.only or "all" in self.only + + async def _source_tables(self) -> set[str]: + def load_tables(sync_connection: Any) -> set[str]: + return set(inspect(sync_connection).get_table_names(schema=self.source_schema)) + + return await self.source.run_sync(load_tables) + + async def _warn_missing_tables(self) -> None: + required = {"users", "subscriptions", "transactions", "referrals", "settings"} + missing = sorted(required - self.tables) + if missing: + self.summary["warnings"].append(f"Missing source tables: {', '.join(missing)}") + + async def _fetch_rows(self, table: str, *, order_by: str = "id") -> list[dict[str, Any]]: + if table not in self.tables: + return [] + order_sql = f" ORDER BY {order_by}" if order_by else "" + result = await self.source.execute( + text(f"SELECT * FROM {_qtable(self.source_schema, table)}{order_sql}") + ) + return [_as_mapping(row) for row in result.mappings().all()] + + async def _fetch_one(self, table: str) -> Optional[dict[str, Any]]: + rows = await self._fetch_rows(table, order_by="") + return rows[0] if rows else None + + async def _latest_panel_uuid_by_telegram(self) -> dict[int, str]: + if "subscriptions" not in self.tables: + return {} + result = await self.source.execute( + text( + f""" + SELECT DISTINCT ON (user_telegram_id) + user_telegram_id, + user_remna_id + FROM {_qtable(self.source_schema, "subscriptions")} + WHERE user_remna_id IS NOT NULL + ORDER BY user_telegram_id, updated_at DESC NULLS LAST, id DESC + """ + ) + ) + panel_by_tg: dict[int, str] = {} + for row in result.mappings().all(): + telegram_id = _to_int(row.get("user_telegram_id")) + panel_uuid = str(row.get("user_remna_id") or "").strip() + if telegram_id and panel_uuid: + panel_by_tg[telegram_id] = panel_uuid + return panel_by_tg + + async def _target_user_for_telegram(self, telegram_id: Any) -> Optional[User]: + normalized = _to_int(telegram_id) + if normalized is None: + return None + user = await user_dal.get_user_by_telegram_id(self.target, normalized) + if not user: + user = await user_dal.get_user_by_id(self.target, normalized) + if user: + self.user_map[normalized] = int(user.user_id) + return user + + def _can_overwrite(self) -> bool: + return self.on_conflict == "overwrite" + + def _can_merge_existing(self) -> bool: + return self.on_conflict in {"merge", "overwrite"} + + def _assign_if_allowed(self, model: Any, attr: str, value: Any) -> bool: + if value is None: + return False + current = getattr(model, attr, None) + if self._can_overwrite() or current in (None, ""): + setattr(model, attr, value) + return True + return False + + async def _upsert_mapping( + self, + *, + entity_type: str, + source_id: Any, + target_table: str, + target_id: Any, + metadata: Optional[dict[str, Any]] = None, + ) -> None: + now = datetime.now(timezone.utc) + source_id_value = str(source_id) + target_id_value = str(target_id) + stmt = ( + pg_insert(LegacyImportMapping) + .values( + source=SOURCE, + entity_type=entity_type, + source_id=source_id_value, + target_table=target_table, + target_id=target_id_value, + metadata_json=_json_dumps(metadata or {}), + updated_at=now, + ) + .on_conflict_do_update( + index_elements=[ + LegacyImportMapping.source, + LegacyImportMapping.entity_type, + LegacyImportMapping.source_id, + ], + set_={ + "target_table": target_table, + "target_id": target_id_value, + "metadata_json": _json_dumps(metadata or {}), + "updated_at": now, + }, + ) + ) + await self.target.execute(stmt) + + async def _get_mapping(self, entity_type: str, source_id: Any) -> Optional[LegacyImportMapping]: + stmt = select(LegacyImportMapping).where( + LegacyImportMapping.source == SOURCE, + LegacyImportMapping.entity_type == entity_type, + LegacyImportMapping.source_id == str(source_id), + ) + result = await self.target.execute(stmt) + return result.scalar_one_or_none() + + async def _upsert_setting_override(self, key: str, value: Any) -> bool: + from bot.app.web.admin_settings_manifest import coerce_value, get_field_by_key + + field = get_field_by_key(key) + if field is None: + self.summary["warnings"].append(f"Skipped unknown admin setting override: {key}") + return False + try: + value = coerce_value(field, value) + except ValueError as exc: + self.summary["warnings"].append(f"Skipped invalid admin setting override {key}: {exc}") + return False + + now = datetime.now(timezone.utc) + encoded = json.dumps(value, ensure_ascii=False, separators=(",", ":")) + stmt = ( + pg_insert(AppSettingOverride) + .values( + key=key, + value=encoded, + updated_at=now, + updated_by=self.created_by_admin_id or None, + ) + .on_conflict_do_update( + index_elements=[AppSettingOverride.key], + set_={ + "value": encoded, + "updated_at": now, + "updated_by": self.created_by_admin_id or None, + }, + ) + ) + await self.target.execute(stmt) + return True + + async def _write_setting_overrides( + self, + overrides: dict[str, Any], + *, + summary_key: str, + ) -> list[str]: + written: list[str] = [] + for key, value in overrides.items(): + if await self._upsert_setting_override(key, value): + written.append(key) + self.summary[summary_key]["overrides_written"] += 1 + else: + self.summary[summary_key]["overrides_skipped"] += 1 + return written + + async def import_env_settings(self) -> list[str]: + if not self.source_env: + self.summary["settings"]["source_env_missing"] += 1 + return [] + + overrides = remnashop_env_overrides(self.source_env) + if not overrides: + self.summary["settings"]["source_env_no_supported_values"] += 1 + return [] + + written = await self._write_setting_overrides(overrides, summary_key="settings") + if written: + self.summary["settings"]["source_env_overrides_written"] += 1 + await self._upsert_mapping( + entity_type="settings_env", + source_id="remnashop.env", + target_table="app_setting_overrides", + target_id=",".join(written) if written else "none", + metadata={ + "override_keys": written, + "source_keys_used": sorted( + key + for key in ( + "REMNAWAVE_HOST", + "REMNAWAVE_TOKEN", + "REMNAWAVE_WEBHOOK_SECRET", + "BOT_SUPPORT_USERNAME", + "APP_DEFAULT_LOCALE", + ) + if self.source_env.get(key) + and not _is_placeholder_setting_value(self.source_env.get(key)) + ), + "has_app_crypt_key": bool(self.source_env.get("APP_CRYPT_KEY")), + "source_urls": remnashop_source_urls_from_env(self.source_env), + "ignored_keys_present": sorted( + key for key in ("BOT_MINI_APP",) if self.source_env.get(key) + ), + }, + ) + return written + + async def import_payment_provider_settings(self) -> None: + if "payment_gateways" not in self.tables: + self.summary["payment_provider_settings"]["missing_source_table"] += 1 + return + + rows = await self._fetch_rows("payment_gateways", order_by="order_index, id") + if not rows: + self.summary["payment_provider_settings"]["empty_source_table"] += 1 + return + + active_provider_ids: list[str] = [] + for index, row in enumerate(rows): + source_id = row.get("id") or row.get("type") or f"row:{index}" + mapping = remnashop_payment_gateway_overrides( + row, + crypt_key=self.source_crypt_key, + ) + gateway_type = mapping["source_type"] + self.summary["payment_provider_settings"]["seen"] += 1 + + for warning in mapping["warnings"]: + self.summary["warnings"].append(warning) + + if not mapping["supported"]: + self.summary["payment_provider_settings"]["unsupported"] += 1 + display_type = gateway_type or str(row.get("type") or "unknown") + self.summary["warnings"].append( + f"Remnashop payment provider {display_type} is not supported by " + "Minishop; configure it manually if it is still needed." + ) + await self._upsert_mapping( + entity_type="payment_provider_settings", + source_id=source_id, + target_table="manual_configuration_required", + target_id=display_type, + metadata={ + "source_type": display_type, + "active": _truthy(row.get("is_active")), + "currency": _normalize_currency(row.get("currency")), + "supported": False, + }, + ) + continue + + written = await self._write_setting_overrides( + mapping["overrides"], + summary_key="payment_provider_settings", + ) + if written: + self.summary["payment_provider_settings"]["providers_mapped"] += 1 + else: + self.summary["payment_provider_settings"]["providers_without_overrides"] += 1 + + if _truthy(row.get("is_active")): + for provider_id in mapping["provider_ids"]: + if provider_id and provider_id not in active_provider_ids: + active_provider_ids.append(provider_id) + if provider_id and provider_id not in self.imported_payment_provider_ids: + self.imported_payment_provider_ids.append(provider_id) + + await self._upsert_mapping( + entity_type="payment_provider_settings", + source_id=source_id, + target_table="app_setting_overrides", + target_id=",".join(written) if written else "none", + metadata={ + "source_type": gateway_type, + "provider_ids": mapping["provider_ids"], + "active": _truthy(row.get("is_active")), + "currency": _normalize_currency(row.get("currency")), + "override_keys": written, + "source_settings_keys": sorted(_jsonish(row.get("settings")).keys()), + "warnings_count": len(mapping["warnings"]), + "supported": True, + }, + ) + + if active_provider_ids: + order_value = ",".join(active_provider_ids) + if await self._upsert_setting_override("PAYMENT_METHODS_ORDER", order_value): + self.summary["payment_provider_settings"]["payment_order_written"] += 1 + + async def _upsert_legacy_referral_code(self, *, code: str, user_id: int) -> None: + if len(code) > 128: + self.summary["warnings"].append( + f"Skipped overlong legacy referral code for user {user_id}: {len(code)} chars" + ) + return + now = datetime.now(timezone.utc) + stmt = ( + pg_insert(LegacyReferralCode) + .values( + source=SOURCE, + code=code, + user_id=user_id, + is_active=True, + updated_at=now, + ) + .on_conflict_do_update( + index_elements=[LegacyReferralCode.source, LegacyReferralCode.code], + set_={"user_id": user_id, "is_active": True, "updated_at": now}, + ) + ) + await self.target.execute(stmt) + + async def _record_user_state_note( + self, + *, + telegram_id: int, + user_id: int, + metadata: dict[str, Any], + ) -> None: + if not metadata: + return + if await self._get_mapping("user_state", telegram_id): + return + log = MessageLog( + user_id=None, + target_user_id=user_id, + event_type="legacy_remnashop_user_state", + content=_json_dumps(metadata), + is_admin_event=True, + ) + self.target.add(log) + await self.target.flush() + await self._upsert_mapping( + entity_type="user_state", + source_id=telegram_id, + target_table="message_logs", + target_id=log.log_id, + metadata=metadata, + ) + + async def _source_referral_code_conflicts(self, code: str, user_id: int) -> bool: + existing = await user_dal.get_user_by_referral_code( + self.target, + code, + include_legacy=False, + ) + return bool(existing and int(existing.user_id) != int(user_id)) + + async def import_users(self) -> None: + rows = await self._fetch_rows("users", order_by="telegram_id") + panel_by_tg = await self._latest_panel_uuid_by_telegram() + for row in rows: + telegram_id = _to_int(row.get("telegram_id")) + if telegram_id is None: + self.summary["users"]["skipped"] += 1 + continue + + first_name, last_name = _split_name(row.get("name")) + panel_uuid = panel_by_tg.get(telegram_id) + referral_code = str(row.get("referral_code") or "").strip() or None + created_at = _as_utc(row.get("created_at")) or datetime.now(timezone.utc) + language = str(row.get("language") or "ru").strip().lower()[:8] or "ru" + + existing = await self._target_user_for_telegram(telegram_id) + if existing and self.on_conflict == "skip": + target = existing + self.summary["users"]["skipped"] += 1 + elif existing: + target = existing + if self._can_merge_existing(): + self._assign_if_allowed(target, "username", row.get("username")) + self._assign_if_allowed(target, "first_name", first_name) + self._assign_if_allowed(target, "last_name", last_name) + self._assign_if_allowed(target, "language_code", language) + self._assign_if_allowed(target, "panel_user_uuid", panel_uuid) + if bool(row.get("is_blocked")): + target.is_banned = True + elif self._can_overwrite(): + target.is_banned = False + if bool(row.get("is_bot_blocked")): + target.telegram_notifications_status = "blocked" + target.telegram_notifications_checked_at = datetime.now(timezone.utc) + target.telegram_notifications_blocked_at = datetime.now(timezone.utc) + if referral_code and len(referral_code) <= 64 and not target.referral_code: + if not await self._source_referral_code_conflicts( + referral_code, + int(target.user_id), + ): + target.referral_code = referral_code + self.summary["users"]["updated"] += 1 + else: + new_referral_code = None + if referral_code and len(referral_code) <= 64: + conflict = await self._source_referral_code_conflicts( + referral_code, + telegram_id, + ) + if not conflict: + new_referral_code = referral_code + + target, created = await user_dal.create_user( + self.target, + { + "user_id": telegram_id, + "telegram_id": telegram_id, + "username": row.get("username"), + "first_name": first_name, + "last_name": last_name, + "language_code": language, + "registration_date": created_at, + "is_banned": bool(row.get("is_blocked")), + "panel_user_uuid": panel_uuid, + "referral_code": new_referral_code, + "telegram_notifications_status": "blocked" + if bool(row.get("is_bot_blocked")) + else "unknown", + "telegram_notifications_checked_at": datetime.now(timezone.utc) + if bool(row.get("is_bot_blocked")) + else None, + "telegram_notifications_blocked_at": datetime.now(timezone.utc) + if bool(row.get("is_bot_blocked")) + else None, + }, + ) + self.summary["users"]["created" if created else "updated"] += 1 + + if not target: + self.summary["users"]["skipped"] += 1 + continue + + self.user_map[telegram_id] = int(target.user_id) + if referral_code: + await self._upsert_legacy_referral_code(code=referral_code, user_id=target.user_id) + + metadata = _legacy_user_metadata(row) + if panel_uuid: + metadata["panel_user_uuid"] = panel_uuid + await self._upsert_mapping( + entity_type="user", + source_id=telegram_id, + target_table="users", + target_id=target.user_id, + metadata=metadata, + ) + await self._record_user_state_note( + telegram_id=telegram_id, + user_id=int(target.user_id), + metadata=metadata, + ) + + await self.target.flush() + + async def import_referrals(self) -> None: + rows = await self._fetch_rows("referrals", order_by="id") + for row in rows: + referrer = await self._target_user_for_telegram(row.get("referrer_telegram_id")) + referred = await self._target_user_for_telegram(row.get("referred_telegram_id")) + if not referrer or not referred or referrer.user_id == referred.user_id: + self.summary["referrals"]["skipped"] += 1 + continue + if referred.referred_by_id and not self._can_overwrite(): + self.summary["referrals"]["skipped"] += 1 + continue + referred.referred_by_id = int(referrer.user_id) + self.summary["referrals"]["updated"] += 1 + await self._upsert_mapping( + entity_type="referral", + source_id=row.get("id") or f"{referrer.user_id}:{referred.user_id}", + target_table="users", + target_id=referred.user_id, + metadata={ + "referrer_user_id": referrer.user_id, + "referred_user_id": referred.user_id, + }, + ) + await self.target.flush() + + async def import_subscriptions(self) -> None: + rows = await self._fetch_rows("subscriptions", order_by="id") + now = datetime.now(timezone.utc) + for row in rows: + user = await self._target_user_for_telegram(row.get("user_telegram_id")) + if not user: + self.summary["subscriptions"]["skipped"] += 1 + continue + + panel_user_uuid = str(row.get("user_remna_id") or user.panel_user_uuid or "").strip() + if not panel_user_uuid: + self.summary["subscriptions"]["skipped"] += 1 + continue + if not user.panel_user_uuid or self._can_overwrite(): + user.panel_user_uuid = panel_user_uuid + + source_id = row.get("id") + mapping = await self._get_mapping("subscription", source_id) + existing: Optional[Subscription] = None + if mapping and str(mapping.target_id).isdigit(): + existing = await self.target.get(Subscription, int(mapping.target_id)) + + panel_sub_uuid = _extract_panel_subscription_uuid(row.get("url"), panel_user_uuid) + if not existing and panel_sub_uuid: + existing = ( + await self.target.execute( + select(Subscription).where( + Subscription.panel_subscription_uuid == panel_sub_uuid + ) + ) + ).scalar_one_or_none() + + status = str(row.get("status") or "UNKNOWN").strip().upper() + expire_at = _as_utc(row.get("expire_at")) or now + created_at = _as_utc(row.get("created_at")) or now + plan_snapshot = _jsonish(row.get("plan_snapshot")) + traffic_limit_bytes = remnashop_traffic_gb_to_bytes(row.get("traffic_limit")) + payload = { + "user_id": int(user.user_id), + "panel_user_uuid": panel_user_uuid, + "panel_subscription_uuid": panel_sub_uuid, + "start_date": created_at, + "end_date": expire_at, + "duration_months": remnashop_months_from_plan_snapshot( + plan_snapshot, + created_at=created_at, + expire_at=expire_at, + ), + "is_active": status in {"ACTIVE", "LIMITED"} and expire_at > now, + "status_from_panel": status, + "traffic_limit_bytes": traffic_limit_bytes, + "provider": "trial" if bool(row.get("is_trial")) else SOURCE, + "skip_notifications": True, + "auto_renew_enabled": False, + "tariff_key": remnashop_tariff_key(plan_snapshot, self.tariff_map), + "tier_baseline_bytes": traffic_limit_bytes, + "period_start_at": created_at, + "hwid_device_limit": _to_int(row.get("device_limit")), + } + metadata = { + "source": SOURCE, + "source_subscription_id": source_id, + "traffic_limit_strategy": str(row.get("traffic_limit_strategy") or ""), + "tag": row.get("tag"), + "internal_squads": [str(item) for item in _listish(row.get("internal_squads"))], + "external_squad": str(row.get("external_squad") or "") or None, + "url": row.get("url"), + "plan_snapshot": plan_snapshot, + } + + if existing: + if self.on_conflict == "skip": + self.summary["subscriptions"]["skipped"] += 1 + else: + for key, value in payload.items(): + self._assign_if_allowed(existing, key, value) + self.summary["subscriptions"]["updated"] += 1 + target_subscription_id = existing.subscription_id + else: + subscription = Subscription(**payload) + self.target.add(subscription) + await self.target.flush() + target_subscription_id = subscription.subscription_id + self.summary["subscriptions"]["created"] += 1 + + await self._upsert_mapping( + entity_type="subscription", + source_id=source_id, + target_table="subscriptions", + target_id=target_subscription_id, + metadata=metadata, + ) + + await self.target.flush() + + async def import_payments(self) -> None: + rows = await self._fetch_rows("transactions", order_by="id") + for row in rows: + user = await self._target_user_for_telegram(row.get("user_telegram_id")) + if not user: + self.summary["payments"]["skipped"] += 1 + continue + + provider_payment_id = f"{SOURCE}:{row.get('payment_id') or row.get('id')}" + existing = ( + await self.target.execute( + select(Payment).where(Payment.provider_payment_id == provider_payment_id) + ) + ).scalar_one_or_none() + + provider = _provider_value(row.get("gateway_type")) + plan_snapshot = _jsonish(row.get("plan_snapshot")) + created_at = _as_utc(row.get("created_at")) + payload = { + "user_id": int(user.user_id), + "provider_payment_id": provider_payment_id, + "provider": provider, + "amount": remnashop_pricing_amount(row.get("pricing")), + "currency": remnashop_pricing_currency(row.get("pricing"), row.get("currency")), + "status": remnashop_transaction_status(row.get("status"), provider), + "description": self._payment_description(row), + "subscription_duration_months": remnashop_months_from_plan_snapshot( + plan_snapshot, + created_at=row.get("created_at"), + expire_at=None, + ), + "sale_mode": remnashop_sale_mode(row.get("purchase_type")), + "tariff_key": remnashop_tariff_key(plan_snapshot, self.tariff_map), + "created_at": created_at, + } + payload = {key: value for key, value in payload.items() if value is not None} + + if existing: + if self.on_conflict == "skip": + self.summary["payments"]["skipped"] += 1 + else: + for key, value in payload.items(): + self._assign_if_allowed(existing, key, value) + self.summary["payments"]["updated"] += 1 + target_payment_id = existing.payment_id + else: + payment = Payment(**payload) + self.target.add(payment) + await self.target.flush() + target_payment_id = payment.payment_id + self.summary["payments"]["created"] += 1 + + await self._upsert_mapping( + entity_type="payment", + source_id=row.get("payment_id") or row.get("id"), + target_table="payments", + target_id=target_payment_id, + metadata={ + "source_transaction_id": row.get("id"), + "is_test": row.get("is_test"), + "purchase_type": str(row.get("purchase_type") or ""), + "gateway_type": str(row.get("gateway_type") or ""), + "plan_snapshot": plan_snapshot, + }, + ) + + await self.target.flush() + + def _payment_description(self, row: dict[str, Any]) -> str: + snapshot = _jsonish(row.get("plan_snapshot")) + plan_name = str(snapshot.get("name") or snapshot.get("tag") or "").strip() + purchase_type = str(row.get("purchase_type") or "").strip().upper() + if plan_name: + return f"Remnashop import: {purchase_type} {plan_name}".strip() + return f"Remnashop import: {purchase_type}".strip() + + async def import_promocodes(self) -> None: + if "promocodes" not in self.tables: + self.summary["promocodes"]["missing_source_table"] += 1 + return + + activation_rows_by_code = await self._source_promocode_activation_rows() + rows = await self._fetch_rows("promocodes", order_by="id") + for row in rows: + code = str(row.get("code") or "").strip() + if not code: + self.summary["promocodes"]["skipped"] += 1 + continue + + bonus_days = self._promo_bonus_days(row) + if bonus_days is None or bonus_days <= 0: + self.summary["promocodes"]["unsupported_reward"] += 1 + continue + + existing = ( + await self.target.execute(select(PromoCode).where(PromoCode.code == code)) + ).scalar_one_or_none() + activations = activation_rows_by_code.get(code, []) + valid_until = None + lifetime_days = _to_int(row.get("lifetime")) + if lifetime_days and _as_utc(row.get("created_at")): + valid_until = _as_utc(row.get("created_at")) + if valid_until: + valid_until = valid_until + timedelta(days=lifetime_days) + + payload = { + "code": code, + "bonus_days": int(bonus_days), + "max_activations": _to_int(row.get("max_activations")) or 1_000_000, + "current_activations": len(activations), + "is_active": bool(row.get("is_active")), + "created_by_admin_id": self.created_by_admin_id, + "created_at": _as_utc(row.get("created_at")), + "valid_until": valid_until, + } + payload = {key: value for key, value in payload.items() if value is not None} + + if existing: + if self.on_conflict == "skip": + self.summary["promocodes"]["skipped"] += 1 + else: + for key, value in payload.items(): + self._assign_if_allowed(existing, key, value) + self.summary["promocodes"]["updated"] += 1 + promo = existing + else: + promo = PromoCode(**payload) + self.target.add(promo) + await self.target.flush() + self.summary["promocodes"]["created"] += 1 + + await self._upsert_mapping( + entity_type="promocode", + source_id=row.get("id") or code, + target_table="promo_codes", + target_id=promo.promo_code_id, + metadata={ + "reward_type": str(row.get("reward_type") or ""), + "reward": row.get("reward"), + "plan": _jsonish(row.get("plan")), + "lifetime": row.get("lifetime"), + }, + ) + await self._import_promocode_activations(promo, activations) + + await self.target.flush() + + async def _source_promocode_activation_rows(self) -> dict[str, list[dict[str, Any]]]: + if "promocode_activations" not in self.tables: + return {} + result = await self.source.execute( + text( + f""" + SELECT a.*, p.code + FROM {_qtable(self.source_schema, "promocode_activations")} a + JOIN {_qtable(self.source_schema, "promocodes")} p + ON p.id = a.promocode_id + ORDER BY a.id + """ + ) + ) + by_code: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in result.mappings().all(): + mapping = _as_mapping(row) + code = str(mapping.get("code") or "").strip() + if code: + by_code[code].append(mapping) + return by_code + + def _promo_bonus_days(self, row: dict[str, Any]) -> Optional[int]: + reward_type = str(row.get("reward_type") or "").strip().upper() + if reward_type == "DURATION": + return _to_int(row.get("reward")) + if reward_type == "SUBSCRIPTION": + plan = _jsonish(row.get("plan")) + return ( + _to_int(plan.get("duration_days")) + or _to_int(plan.get("days")) + or _to_int(row.get("reward")) + ) + return None + + async def _import_promocode_activations( + self, + promo: PromoCode, + activations: Iterable[dict[str, Any]], + ) -> None: + for activation in activations: + user = await self._target_user_for_telegram(activation.get("user_telegram_id")) + if not user: + self.summary["promocodes"]["activation_skipped"] += 1 + continue + stmt = ( + pg_insert(PromoCodeActivation) + .values( + promo_code_id=promo.promo_code_id, + user_id=user.user_id, + activated_at=_as_utc(activation.get("activated_at")) + or datetime.now(timezone.utc), + ) + .on_conflict_do_nothing( + index_elements=[ + PromoCodeActivation.promo_code_id, + PromoCodeActivation.user_id, + ] + ) + ) + await self.target.execute(stmt) + self.summary["promocodes"]["activation_imported"] += 1 + + async def import_settings(self) -> None: + source_settings = await self._fetch_one("settings") + plans = ( + await self._fetch_rows("plans", order_by="order_index") + if "plans" in self.tables + else [] + ) + notes = { + "default_currency": ( + source_settings.get("default_currency") if source_settings else None + ), + "settings": { + key: source_settings.get(key) + for key in ("access", "requirements", "notifications", "referral", "menu") + if source_settings and source_settings.get(key) is not None + }, + "plans_count": len(plans), + "plans": [ + { + "id": plan.get("id"), + "name": plan.get("name"), + "type": str(plan.get("type") or ""), + "traffic_limit": plan.get("traffic_limit"), + "device_limit": plan.get("device_limit"), + "tag": plan.get("tag"), + } + for plan in plans[:100] + ], + "source_env": { + "provided": bool(self.source_env), + "supported_keys_present": sorted( + key + for key in ( + "REMNAWAVE_HOST", + "REMNAWAVE_TOKEN", + "REMNAWAVE_WEBHOOK_SECRET", + "BOT_SUPPORT_USERNAME", + "APP_DEFAULT_LOCALE", + "APP_DOMAIN", + "APP_CRYPT_KEY", + ) + if self.source_env.get(key) + and not _is_placeholder_setting_value(self.source_env.get(key)) + ), + "source_urls": remnashop_source_urls_from_env(self.source_env), + "ignored_keys_present": sorted( + key for key in ("BOT_MINI_APP",) if self.source_env.get(key) + ), + }, + } + env_override_keys = await self.import_env_settings() + await self.import_payment_provider_settings() + notes["env_override_keys"] = env_override_keys + notes["payment_provider_ids"] = list(dict.fromkeys(self.imported_payment_provider_ids)) + await self._upsert_mapping( + entity_type="settings", + source_id="singleton", + target_table="app_setting_overrides", + target_id="MIGRATION_REMNASHOP_NOTES", + metadata=notes, + ) + self.summary["settings"]["captured"] += 1 + + async def _write_admin_overrides(self) -> None: + now = datetime.now(timezone.utc).isoformat() + plain_summary = self._plain_summary() + await self._upsert_setting_override( + "MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED", + True, + ) + await self._upsert_setting_override( + "MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED", + "promocodes" in self.tables, + ) + await self._upsert_setting_override("MIGRATION_REMNASHOP_IMPORTED_AT", now) + await self._upsert_setting_override( + "MIGRATION_REMNASHOP_NOTES", + _json_dumps(plain_summary), + ) + self.summary["settings"]["admin_overrides_written"] += 1 + + +def parse_only(value: str) -> set[str]: + if not value: + return set() + return {item.strip().lower() for item in value.split(",") if item.strip()} + + +def parse_tariff_map(value: Optional[str]) -> dict[str, str]: + if not value: + return {} + path = Path(value) + raw = path.read_text(encoding="utf-8") if path.exists() else value + decoded = json.loads(raw) + if not isinstance(decoded, dict): + raise ValueError("--tariff-map-json must be a JSON object or a path to one") + return {str(key): str(mapped) for key, mapped in decoded.items()} + + +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Import legacy bot data into this shop.") + parser.add_argument("--source-type", choices=[SOURCE], default=SOURCE) + parser.add_argument("--source-dsn", required=True) + parser.add_argument("--source-schema", default="public") + parser.add_argument( + "--source-env-file", + help=( + "Path to the source Remnashop .env. Used for APP_CRYPT_KEY, Remnawave " + "API settings and selected safe compatibility values." + ), + ) + parser.add_argument( + "--source-crypt-key", + help="Explicit Remnashop APP_CRYPT_KEY. Overrides the value from --source-env-file.", + ) + parser.add_argument("--target-dsn") + parser.add_argument( + "--only", + default="all", + help=( + "Comma-separated sections: " + "all,users,referrals,subscriptions,payments,promocodes,settings" + ), + ) + parser.add_argument( + "--on-conflict", + choices=["merge", "skip", "overwrite"], + default="merge", + ) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--created-by-admin-id", type=int, default=0) + parser.add_argument( + "--tariff-map-json", + help="JSON object or path mapping remnashop plan id/name/tag to local tariff_key.", + ) + parser.add_argument( + "--no-admin-compat-overrides", + action="store_true", + help="Do not enable migration compatibility toggles in admin settings.", + ) + return parser + + +async def _prepare_target_schema(engine: Any) -> None: + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + await connection.run_sync(run_database_migrations) + + +async def run_import(args: argparse.Namespace) -> dict[str, Any]: + settings = Settings() + source_env = read_remnashop_env_file(args.source_env_file) + source_crypt_key = args.source_crypt_key or source_env.get("APP_CRYPT_KEY") + source_engine = create_async_engine(normalize_async_postgres_dsn(args.source_dsn)) + target_engine = create_async_engine( + normalize_async_postgres_dsn(args.target_dsn or settings.DATABASE_URL) + ) + await _prepare_target_schema(target_engine) + + session_factory = async_sessionmaker( + bind=target_engine, + class_=AsyncSession, + expire_on_commit=False, + autocommit=False, + autoflush=False, + ) + + async with source_engine.connect() as source, session_factory() as target: + importer = RemnashopImporter( + source=source, + target=target, + source_schema=args.source_schema, + only=parse_only(args.only), + on_conflict=args.on_conflict, + dry_run=bool(args.dry_run), + created_by_admin_id=args.created_by_admin_id, + tariff_map=parse_tariff_map(args.tariff_map_json), + write_admin_compat_overrides=not args.no_admin_compat_overrides, + source_env=source_env, + source_crypt_key=source_crypt_key, + target_webhook_base_url=settings.WEBHOOK_BASE_URL, + ) + summary = await importer.run() + if args.dry_run: + await target.rollback() + else: + await target.commit() + + await source_engine.dispose() + await target_engine.dispose() + return summary + + +def main() -> None: + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") + args = build_arg_parser().parse_args() + summary = asyncio.run(run_import(args)) + print(_json_dumps(summary)) + + +if __name__ == "__main__": + main() diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index 21d5085..6f46da8 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -1,11 +1,12 @@ # Resolve the application version from .git at build time and emit a tiny # .build-version file. The .git tree is consumed in this throwaway stage and -# never copied into the runtime image; only the tag + commit version string -# ships. Non-main builds include the branch name so they are visibly distinct -# from release builds. This matches the runtime fallback chain in _resolve_app_version -# (REMNAWAVE_MINISHOP_VERSION env > .build-version file > live git > -# "dev+unknown") so the admin sidebar always shows a tag / sha even though the -# runtime images have no git tooling and no .git tree. +# never copied into the runtime image; only the tag + commit version string and +# a low-cardinality build provenance marker ship. Non-main builds include the +# branch name so they are visibly distinct from release builds. This matches the +# runtime fallback chain in _resolve_app_version (REMNAWAVE_MINISHOP_VERSION env +# > .build-version file > live git > "dev+unknown") so the admin sidebar always +# shows a tag / sha even though the runtime images have no git tooling and no +# .git tree. FROM alpine:3.20 AS version-builder RUN apk add --no-cache git WORKDIR /repo @@ -14,6 +15,7 @@ ARG GIT_BRANCH="" ARG BRANCH_NAME="" ARG GITHUB_REF_NAME="" ARG CI_COMMIT_REF_NAME="" +ARG REMNAWAVE_MINISHOP_BUILD_PROVENANCE="custom" COPY .git ./.git RUN set -eu; \ git config --global --add safe.directory /repo; \ @@ -40,9 +42,17 @@ RUN set -eu; \ else \ version="dev${branch_suffix}+unknown"; \ fi; \ + provenance=$(printf '%s' "$REMNAWAVE_MINISHOP_BUILD_PROVENANCE" | tr '[:upper:]' '[:lower:]'); \ + case "$provenance" in \ + official|custom|unknown) ;; \ + true|1|yes|upstream|release) provenance="official" ;; \ + false|0|no|fork|modified|local|"") provenance="custom" ;; \ + *) provenance="custom" ;; \ + esac; \ printf '%s' "$version" > /build-version; \ printf '%s' "${tag:-unknown}" > /build-tag; \ - printf '%s' "${sha:-unknown}" > /build-commit + printf '%s' "${sha:-unknown}" > /build-commit; \ + printf '%s' "$provenance" > /build-provenance FROM python:3.12-slim AS python-base @@ -82,12 +92,40 @@ RUN mkdir -p /app/logs /app/data \ COPY --from=version-builder /build-version /app/.build-version COPY --from=version-builder /build-tag /app/.build-tag COPY --from=version-builder /build-commit /app/.build-commit +COPY --from=version-builder /build-provenance /app/.build-provenance USER appuser +FROM node:22-slim AS frontend-builder + +WORKDIR /app/frontend + +COPY frontend/package.json frontend/package-lock.json* ./ +RUN --mount=type=cache,target=/root/.npm \ + if [ -f package-lock.json ]; then npm ci; else npm install; fi + +COPY frontend ./ +RUN mkdir -p ../backend/bot/app/web/templates + +RUN npm run build:webapp +COPY backend/bot/app/web/templates/subscription_webapp.html ../backend/bot/app/web/templates/subscription_webapp.html +RUN node ./scripts/prepare_nginx_assets.mjs --out ../frontend-nginx-dist + + FROM python-base AS backend +# The Mini App shell is rendered by the backend, which rewrites the stylesheet +# and script tags to the content-hashed asset names (subscription_webapp..css). +# Those hashed files are build artifacts and gitignored, so a clean checkout has +# none of them: without this copy the asset resolver falls back to the bare +# /subscription_webapp.css URL. That URL never changes between deploys and is +# served no-store, which iOS WebViews (WKWebView) cache aggressively and refuse to +# revalidate -> stale CSS and a broken-looking Mini App on iOS only. Pull the +# freshly built assets (the same hashes the nginx image serves) into the image so +# the shell emits immutable, cache-busting URLs. +COPY --from=frontend-builder /app/backend/bot/app/web/templates/ ./backend/bot/app/web/templates/ + LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minishop" \ org.opencontainers.image.title="remnawave-minishop-backend" \ org.opencontainers.image.description="Remnawave Minishop backend (Telegram bot API, web app, webhooks)." \ @@ -107,20 +145,6 @@ LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minis CMD ["python", "backend/main_worker.py"] -FROM node:22-slim AS frontend-builder - -WORKDIR /app/frontend - -COPY frontend/package.json frontend/package-lock.json* ./ -RUN --mount=type=cache,target=/root/.npm \ - if [ -f package-lock.json ]; then npm ci; else npm install; fi - -COPY frontend ./ -RUN mkdir -p ../backend/bot/app/web/templates - -RUN npm run build:webapp - - FROM nginx:1.27-alpine AS frontend LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minishop" \ @@ -131,57 +155,10 @@ LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minis COPY deploy/docker/frontend/nginx.conf /etc/nginx/conf.d/default.conf COPY deploy/docker/frontend/00-startup-banner.sh /docker-entrypoint.d/00-startup-banner.sh COPY deploy/docker/frontend/robots.txt /usr/share/nginx/html/robots.txt -COPY backend/bot/app/web/templates/subscription_webapp.html /usr/share/nginx/html/index.html -COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.css /usr/share/nginx/html/subscription_webapp.css -COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.*.css /usr/share/nginx/html/ -COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.*.css.gz /usr/share/nginx/html/ -COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp_admin.css /usr/share/nginx/html/subscription_webapp_admin.css -COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp_admin.*.css /usr/share/nginx/html/ -COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp_admin.*.css.gz /usr/share/nginx/html/ -COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.js /usr/share/nginx/html/subscription_webapp.js -COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.min.*.js /usr/share/nginx/html/ -COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.min.*.js.gz /usr/share/nginx/html/ -COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp_admin.js /usr/share/nginx/html/subscription_webapp_admin.js -COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp_admin.min.*.js /usr/share/nginx/html/ -COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.gz /usr/share/nginx/html/ - RUN set -eu; \ find /docker-entrypoint.d -type f -name '*.sh' -exec sed -i 's/\r$//' {} +; \ - HASHED=$(ls /usr/share/nginx/html/subscription_webapp.min.*.js 2>/dev/null | sort | tail -n1 | xargs -n1 basename || true); \ - JS_NAME="${HASHED:-subscription_webapp.js}"; \ - CSS_NAME="subscription_webapp.css"; \ - ADMIN_HASHED=$(ls /usr/share/nginx/html/subscription_webapp_admin.min.*.js 2>/dev/null | sort | tail -n1 | xargs -n1 basename || true); \ - ADMIN_CSS_NAME="subscription_webapp_admin.css"; \ - for candidate in /usr/share/nginx/html/subscription_webapp.*.css; do \ - name="$(basename "$candidate")"; \ - case "$name" in \ - subscription_webapp.[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].css) CSS_NAME="$name" ;; \ - esac; \ - done; \ - for candidate in /usr/share/nginx/html/subscription_webapp_admin.*.css; do \ - name="$(basename "$candidate")"; \ - case "$name" in \ - subscription_webapp_admin.[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].css) ADMIN_CSS_NAME="$name" ;; \ - esac; \ - done; \ - if [ -n "$ADMIN_HASHED" ]; then \ - cp "/usr/share/nginx/html/${ADMIN_HASHED}" /usr/share/nginx/html/subscription_webapp_admin.js; \ - if [ -f "/usr/share/nginx/html/${ADMIN_HASHED}.gz" ]; then cp "/usr/share/nginx/html/${ADMIN_HASHED}.gz" /usr/share/nginx/html/subscription_webapp_admin.js.gz; fi; \ - fi; \ - if [ "$ADMIN_CSS_NAME" != "subscription_webapp_admin.css" ]; then \ - cp "/usr/share/nginx/html/${ADMIN_CSS_NAME}" /usr/share/nginx/html/subscription_webapp_admin.css; \ - if [ -f "/usr/share/nginx/html/${ADMIN_CSS_NAME}.gz" ]; then cp "/usr/share/nginx/html/${ADMIN_CSS_NAME}.gz" /usr/share/nginx/html/subscription_webapp_admin.css.gz; fi; \ - fi; \ - sed -i \ - -e '/WEBAPP_I18N_SCRIPT/d' \ - -e '/WEBAPP_CONFIG_SCRIPT/d' \ - -e "s|href=\"/subscription_webapp.css\"|href=\"/${CSS_NAME}\"|" \ - -e "/WEBAPP_JS_SCRIPT/c\\ " \ - -e '/WEBAPP_DEV_MOCK_START/d' \ - -e '/WEBAPP_DEV_MOCK_END/d' \ - -e '/subscription_webapp.js" defer/d' \ - /usr/share/nginx/html/index.html; \ chmod +x /docker-entrypoint.d/00-startup-banner.sh +COPY --from=frontend-builder /app/frontend-nginx-dist/ /usr/share/nginx/html/ COPY --from=version-builder /build-tag /build-tag COPY --from=version-builder /build-commit /build-commit diff --git a/deploy/docker/caddy/Caddyfile b/deploy/docker/caddy/Caddyfile index 53afd2d..ddd0820 100644 --- a/deploy/docker/caddy/Caddyfile +++ b/deploy/docker/caddy/Caddyfile @@ -2,11 +2,14 @@ app.example.com { encode zstd gzip + # Caddy sets X-Forwarded-For/Host/Proto for reverse_proxy by default. + # The backend uses X-Forwarded-For for payment provider IP allowlists. reverse_proxy backend:8080 } web.example.com { encode zstd gzip + # Keep forwarded headers for Web App URL generation and request logging. reverse_proxy frontend:80 } diff --git a/deploy/docker/frontend/nginx.conf b/deploy/docker/frontend/nginx.conf index 00bcfab..f1a7f78 100644 --- a/deploy/docker/frontend/nginx.conf +++ b/deploy/docker/frontend/nginx.conf @@ -84,7 +84,16 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } - location ~ ^/(webapp-logo|webapp-uploaded-logo|webapp-favicon|webapp-emoji|webapp-theme-css|webapp-theme-assets)/ { + location ~ ^/(webapp-logo|webapp-uploaded-logo|webapp-favicon|webapp-theme-css|webapp-theme-assets)/ { + proxy_pass http://backend:8081; + 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; + } + + location ~ "^/(?:$|login/password$|home$|install$|trial$|s/[a-f0-9]{32}$|invite$|devices$|settings$|support(?:/[0-9]+)?$|admin(?:/.*)?$)" { proxy_pass http://backend:8081; proxy_http_version 1.1; proxy_set_header Host $host; @@ -102,7 +111,9 @@ server { location ~* ^/subscription_webapp(_admin)?\.(css|js)$ { expires off; - add_header Cache-Control "no-cache"; + add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0"; + add_header Pragma "no-cache"; + add_header Expires "0"; add_header X-Robots-Tag "noindex, nofollow, noarchive"; try_files $uri =404; } @@ -115,7 +126,9 @@ server { } location / { - add_header Cache-Control "no-cache"; + add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0"; + add_header Pragma "no-cache"; + add_header Expires "0"; add_header X-Robots-Tag "noindex, nofollow, noarchive"; try_files $uri /index.html; } diff --git a/deploy/docker/nginx/remnawave-minishop.conf b/deploy/docker/nginx/remnawave-minishop.conf index 687f404..7f48e0d 100644 --- a/deploy/docker/nginx/remnawave-minishop.conf +++ b/deploy/docker/nginx/remnawave-minishop.conf @@ -72,6 +72,7 @@ server { proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + # Required for payment provider IP allowlists in webhook handlers. proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Proto $scheme; diff --git a/deploy/examples/README.md b/deploy/examples/README.md index e3717fb..c329761 100644 --- a/deploy/examples/README.md +++ b/deploy/examples/README.md @@ -4,11 +4,12 @@ Эта папка хранит только рабочие compose-примеры и конфиги. Подробное описание не дублируется здесь, чтобы сайт документации и навигация из README использовали один источник. -Файлы приложения (`/app/data`: тарифы, темы, логотипы) монтируются из папки `data` рядом с выбранным `docker-compose.yml`. Для кастомных тем создайте `data/themes`. +Файлы приложения (`/app/data`: тарифы, темы, логотипы) монтируются из папки `data` рядом с выбранным `docker-compose.yml` в `migrate`, `backend` и `worker`. Для кастомных тем создайте `data/themes`; для ручного каталога тарифов используйте `data/tariffs.json`. -| Папка | Документация | -| --- | --- | -| `caddy` | [Развертывание с Caddy](../../docs/getting-started/deployment.md#caddy-рекомендуемый-вариант) | -| `nginx` | [Развертывание с Nginx](../../docs/getting-started/deployment.md#nginx) | -| `newt` | [Развертывание через Pangolin / Newt](../../docs/getting-started/deployment.md#pangolin--newt) | -| `no-proxy` | [Запуск без обратного прокси](../../docs/getting-started/deployment.md#без-обратного-прокси) | +| Папка | Документация | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `caddy` | [Развертывание с Caddy](../../docs/getting-started/deployment.md#caddy-рекомендуемый-вариант) | +| `nginx` | [Развертывание с Nginx](../../docs/getting-started/deployment.md#nginx) | +| `newt` | [Развертывание через Pangolin / Newt](../../docs/getting-started/deployment.md#pangolin--newt) | +| `no-proxy` | [Запуск без обратного прокси](../../docs/getting-started/deployment.md#без-обратного-прокси) | +| `mail` | [Развертывание локального SMTP-сервера](../../docs/features/email-login.md#настройка-локального-smtp-сервера) | diff --git a/deploy/examples/caddy/.env.example b/deploy/examples/caddy/.env.example index 5d7fd3d..0ccb4a1 100644 --- a/deploy/examples/caddy/.env.example +++ b/deploy/examples/caddy/.env.example @@ -36,5 +36,5 @@ PANEL_API_URL=https://panel.example.com/api PANEL_API_KEY=change_me PANEL_WEBHOOK_SECRET=change_me -# Caddy and Docker network ranges that may set X-Forwarded-For. -TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12 +# Caddy and private Docker/LAN ranges that may set X-Forwarded-For. +TRUSTED_PROXIES=127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7 diff --git a/deploy/examples/caddy/Caddyfile b/deploy/examples/caddy/Caddyfile index edf2b96..ff17819 100644 --- a/deploy/examples/caddy/Caddyfile +++ b/deploy/examples/caddy/Caddyfile @@ -8,12 +8,15 @@ {$WEBHOOK_HOST} { encode zstd gzip + # Caddy sets X-Forwarded-For/Host/Proto for reverse_proxy by default. + # The backend uses X-Forwarded-For for payment provider IP allowlists. reverse_proxy backend:8080 } {$MINIAPP_HOST} { encode zstd gzip + # Keep forwarded headers for Web App URL generation and request logging. reverse_proxy frontend:80 } diff --git a/deploy/examples/caddy/docker-compose.yml b/deploy/examples/caddy/docker-compose.yml index 6321b0f..2ade806 100644 --- a/deploy/examples/caddy/docker-compose.yml +++ b/deploy/examples/caddy/docker-compose.yml @@ -64,7 +64,7 @@ services: environment: <<: *app_environment WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true} - TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12} + TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7} volumes: - ./data:/app/data - ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw} diff --git a/deploy/examples/mail/docker-compose.yml b/deploy/examples/mail/docker-compose.yml new file mode 100644 index 0000000..731b2cf --- /dev/null +++ b/deploy/examples/mail/docker-compose.yml @@ -0,0 +1,26 @@ +services: + mailserver: + image: mailserver/docker-mailserver:latest + container_name: mailserver + hostname: mail.example.com # Замените на ваш реальный домен (оставить поддомен mail) + domainname: example.com # Замените на ваш реальный домен + ports: + - "25:25" + - "587:587" + - "465:465" + - "993:993" + volumes: + - ./mail-data:/var/mail + - ./config:/tmp/docker-mailserver + environment: + - SSL_TYPE=manual + - SSL_CERT_PATH=path/to/your/crt/file/example.com.crt # Путь к файлу сертификата + - SSL_KEY_PATH=path/to/your/key/file/example.com.key # Путь к закрытому ключу + - OVERRIDE_HOSTNAME=mail.example.com # Замените на ваш реальный домен + - PERMIT_DOCKER=network + - ENABLE_OPENDKIM=0 + - ENABLE_OPENDMARC=0 + - ENABLE_AMAVIS=0 + - ENABLE_CLAMAV=0 + - ENABLE_SPAMASSASSIN=0 + restart: always diff --git a/deploy/examples/newt/.env.example b/deploy/examples/newt/.env.example index 4672a0b..9e2d75f 100644 --- a/deploy/examples/newt/.env.example +++ b/deploy/examples/newt/.env.example @@ -37,5 +37,5 @@ PANEL_API_URL=https://panel.example.com/api PANEL_API_KEY=change_me PANEL_WEBHOOK_SECRET=change_me -# Pangolin/Newt and Docker network ranges that may set X-Forwarded-For. -TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12 +# Pangolin/Newt and private Docker/LAN ranges that may set X-Forwarded-For. +TRUSTED_PROXIES=127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7 diff --git a/deploy/examples/newt/docker-compose.yml b/deploy/examples/newt/docker-compose.yml index ad7c3d5..90a2e24 100644 --- a/deploy/examples/newt/docker-compose.yml +++ b/deploy/examples/newt/docker-compose.yml @@ -64,7 +64,7 @@ services: environment: <<: *app_environment WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true} - TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12} + TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7} volumes: - ./data:/app/data - ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw} diff --git a/deploy/examples/nginx/.env.example b/deploy/examples/nginx/.env.example index c93a284..8f7b463 100644 --- a/deploy/examples/nginx/.env.example +++ b/deploy/examples/nginx/.env.example @@ -36,5 +36,5 @@ PANEL_API_URL=https://panel.example.com/api PANEL_API_KEY=change_me PANEL_WEBHOOK_SECRET=change_me -# Nginx and Docker network ranges that may set X-Forwarded-For. -TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12 +# Nginx and private Docker/LAN ranges that may set X-Forwarded-For. +TRUSTED_PROXIES=127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7 diff --git a/deploy/examples/nginx/docker-compose.yml b/deploy/examples/nginx/docker-compose.yml index 5a9f1d1..516a6a4 100644 --- a/deploy/examples/nginx/docker-compose.yml +++ b/deploy/examples/nginx/docker-compose.yml @@ -64,7 +64,7 @@ services: environment: <<: *app_environment WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true} - TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12} + TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7} volumes: - ./data:/app/data - ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw} diff --git a/deploy/examples/nginx/nginx.conf.template b/deploy/examples/nginx/nginx.conf.template index 829cbe8..e5cb768 100644 --- a/deploy/examples/nginx/nginx.conf.template +++ b/deploy/examples/nginx/nginx.conf.template @@ -31,6 +31,7 @@ server { proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + # Required for payment provider IP allowlists in webhook handlers. proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Proto $scheme; diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index 5f1b806..42bc807 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -16,6 +16,7 @@ export default defineConfig({ { label: 'Демо', href: '/demo/home' }, { label: 'Установка', href: '/getting-started/setup/' }, { label: 'GitHub', href: 'https://github.com/3252a8/remnawave-minishop' }, + { label: 'GitLab', href: 'https://gitlab.com/3252a8/remnawave-minishop' }, { label: 'Telegram', href: 'https://t.me/remnawave_minishop' } ], }), @@ -96,6 +97,7 @@ export default defineConfig({ items: [ { label: 'Обзор миграций', slug: 'migrations' }, { label: 'remnawave-tg-shop', slug: 'migrations/remnawave-tg-shop' }, + { label: 'remnashop', slug: 'migrations/remnashop' }, ], }, { diff --git a/docs-site/public/demo/demo-shell.js b/docs-site/public/demo/demo-shell.js index 542012f..5c334ee 100644 --- a/docs-site/public/demo/demo-shell.js +++ b/docs-site/public/demo/demo-shell.js @@ -1,5 +1,6 @@ const frame = document.getElementById("demo-frame"); const runtimeBase = "/demo/runtime"; +const runtimeAppPath = `${runtimeBase}/app/index.html`; const demoBase = "/demo"; const defaultMock = "tariffs"; const publicRouteAliases = new Map([["/app", "/home"]]); @@ -8,6 +9,8 @@ const stateMocks = new Set([ "depleted", "no-subscription", "trial", + "trial-telegram", + "referral-telegram", "devices", "notifications", "auth", @@ -145,7 +148,7 @@ if (initialMock !== "emails") { params.delete("screen"); params.delete("admin_section"); params.set("path", initialRoute); - frame.src = `${runtimeBase}/app/?${params.toString()}${window.location.hash || ""}`; + frame.src = `${runtimeAppPath}?${params.toString()}${window.location.hash || ""}`; } const routeFromRuntimeUrl = (url) => { @@ -155,7 +158,11 @@ const routeFromRuntimeUrl = (url) => { const runtimePath = normalizePath( url.pathname.slice(runtimeBase.length) || "/home", ); - if (runtimePath === "/app" || runtimePath === "/app.html") { + if ( + runtimePath === "/app" || + runtimePath === "/app/index.html" || + runtimePath === "/app.html" + ) { return normalizePath(url.searchParams.get("path") || "/home"); } return runtimePath; @@ -171,7 +178,7 @@ const runtimeSrc = (route, searchParams = new URLSearchParams()) => { nextParams.delete("screen"); nextParams.delete("admin_section"); nextParams.set("path", normalizePath(route)); - return `${runtimeBase}/app/?${nextParams.toString()}${window.location.hash || ""}`; + return `${runtimeAppPath}?${nextParams.toString()}${window.location.hash || ""}`; }; const topbar = document.querySelector(".demo-topbar"); const toggle = document.querySelector(".demo-topbar__toggle"); @@ -226,7 +233,8 @@ const setCollapsed = (collapsed) => { toggle?.addEventListener("click", () => setCollapsed(false)); hide?.addEventListener("click", () => setCollapsed(true)); -if (stateSelect) stateSelect.value = normalizeStateMock(params.get("mock") || initialMock); +if (stateSelect) + stateSelect.value = normalizeStateMock(params.get("mock") || initialMock); setDemoMode(initialMock); stateSelect?.addEventListener("change", () => { const mock = normalizeStateMock(stateSelect.value); diff --git a/docs-site/scripts/generate-email-previews.py b/docs-site/scripts/generate-email-previews.py index 84d54a4..facbe3e 100644 --- a/docs-site/scripts/generate-email-previews.py +++ b/docs-site/scripts/generate-email-previews.py @@ -1,5 +1,8 @@ +import base64 +import hashlib import json import sys +import tempfile from pathlib import Path from types import SimpleNamespace @@ -9,6 +12,7 @@ sys.path.insert(0, str(BACKEND_ROOT)) if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") +from bot.services import email_templates as email_templates_module # noqa: E402 from bot.services.email_templates import ( # noqa: E402 render_account_merged, render_login_code, @@ -23,6 +27,34 @@ from bot.services.email_templates import ( # noqa: E402 ) LANGUAGE = "ru" +PREVIEW_LOGO_FILE = ( + REPO_ROOT + / "backend" + / "bot" + / "app" + / "web" + / "templates" + / "default-brand" + / "favicons" + / "19b2a242e5b7bc2d" + / "icon-180.png" +) +PREVIEW_LOGO_TEMP_DIR = tempfile.TemporaryDirectory() + + +def prepare_preview_logo_url() -> str: + if not PREVIEW_LOGO_FILE.exists(): + return "" + body = PREVIEW_LOGO_FILE.read_bytes() + digest = hashlib.sha256(body).hexdigest()[:16] + filename = f"logo-{digest}.png" + logo_dir = Path(PREVIEW_LOGO_TEMP_DIR.name) + (logo_dir / filename).write_bytes(body) + email_templates_module._WEBAPP_UPLOADED_LOGO_DIR = logo_dir + return f"/webapp-uploaded-logo/{filename}" + + +PREVIEW_LOGO_URL = prepare_preview_logo_url() class PreviewI18n: @@ -63,8 +95,7 @@ def settings(): return SimpleNamespace( DEFAULT_LANGUAGE=LANGUAGE, EMAIL_CODE_TTL_SECONDS=600, - WEBAPP_LOGO_URL="", - WEBAPP_LOGO_USE_EMOJI=False, + WEBAPP_LOGO_URL=PREVIEW_LOGO_URL, WEBAPP_PRIMARY_COLOR="#00fe7a", WEBAPP_TITLE="remnawave-minishop", ) @@ -95,10 +126,20 @@ def preview(item_id: str, category: str, title: str, content): "category": category, "title": title, "subject": content.subject, - "html": content.html, + "html": preview_html(content), } +def preview_html(content): + rendered = content.html + for image in content.inline_images: + data_url = ( + f"data:{image.content_type};base64,{base64.b64encode(image.data).decode('ascii')}" + ) + rendered = rendered.replace(f"cid:{image.content_id}", data_url) + return rendered + + def payment_preview( item_id: str, title: str, diff --git a/docs-site/scripts/sync-docs.mjs b/docs-site/scripts/sync-docs.mjs index acb15ca..cb3ce42 100644 --- a/docs-site/scripts/sync-docs.mjs +++ b/docs-site/scripts/sync-docs.mjs @@ -30,6 +30,7 @@ const descriptions = { 'features/support.md': 'Пользовательские тикеты, список обращений в админке, уведомления и лимиты поддержки.', 'migrations/index.md': 'Готовые сценарии миграции в Remnawave Minishop с других ботов.', 'migrations/remnawave-tg-shop.md': 'Перенос данных со старого remnawave-tg-shop на split-архитектуру Minishop.', + 'migrations/remnashop.md': 'Импорт данных из Remnashop через install wizard или скрипт import_legacy.py.', 'troubleshooting/issues.md': 'Короткие чеклисты для частых проблем запуска, вебхуков, Mini App и платежей.', 'troubleshooting/logs.md': 'Какие логи смотреть при диагностике backend, worker, frontend, миграций и вебхуков.', 'troubleshooting/maintenance.md': 'Обновления, миграции, резервные копии и проверки продакшен-стека.', diff --git a/docs-site/src/components/Header.astro b/docs-site/src/components/Header.astro index 7db945b..d241095 100644 --- a/docs-site/src/components/Header.astro +++ b/docs-site/src/components/Header.astro @@ -46,6 +46,9 @@ function navIcon(href: string) { if (href.includes('github.com/3252a8/remnawave-minishop')) { return 'github' } + if (href.includes('gitlab.com/3252a8/remnawave-minishop')) { + return 'gitlab' + } if (href.includes('t.me/remnawave_minishop')) { return 'telegram' } diff --git a/docs-site/src/lib/emailPreviews.mjs b/docs-site/src/lib/emailPreviews.mjs index 3a0d6b8..1822f42 100644 --- a/docs-site/src/lib/emailPreviews.mjs +++ b/docs-site/src/lib/emailPreviews.mjs @@ -31,6 +31,7 @@ for (const command of pythonCommands) { const result = spawnSync(command, [generatorPath], { cwd: repoRoot, encoding: "utf8", + maxBuffer: 20 * 1024 * 1024, env: { ...process.env, PYTHONIOENCODING: "utf-8", diff --git a/docs-site/src/pages/demo.astro b/docs-site/src/pages/demo.astro index f5ce8e4..0a7ec7d 100644 --- a/docs-site/src/pages/demo.astro +++ b/docs-site/src/pages/demo.astro @@ -1,7 +1,7 @@ --- import { emailPreviews } from '../lib/emailPreviews.mjs'; -const defaultDemoSrc = '/demo/runtime/app/?path=/home&mock=tariffs'; +const defaultDemoSrc = '/demo/runtime/app/index.html?path=/home&mock=tariffs'; const docsHref = '/getting-started/demo/'; --- @@ -467,6 +467,8 @@ const docsHref = '/getting-started/demo/'; + + @@ -529,7 +531,7 @@ const docsHref = '/getting-started/demo/'; title={`Email preview: ${preview.title}`} srcdoc={preview.html} loading="lazy" - sandbox="" + sandbox="allow-scripts" > diff --git a/docs/configuration/env-vars.md b/docs/configuration/env-vars.md index b7dbdf7..61b9b32 100644 --- a/docs/configuration/env-vars.md +++ b/docs/configuration/env-vars.md @@ -41,10 +41,19 @@ | `DB_POOL_RECYCLE_SECONDS` | `.env` | Период recycling DB-соединений. | | `REDIS_URL` | Compose | Redis для FSM, кеша, rate-limit, очередей и locks. В Compose задается автоматически. | | `REDIS_KEY_PREFIX` | `.env` | Префикс Redis-ключей. | -| `TRUSTED_PROXIES` | `.env` | IP/CIDR обратных прокси, которым доверяется `X-Forwarded-For`. | +| `TRUSTED_PROXIES` | `.env` | IP/CIDR обратных прокси, которым доверяется `X-Forwarded-For`. По умолчанию включает loopback и private ranges для Docker/LAN/Kubernetes proxy. | | `HTTP_BIND` / `HTTPS_BIND` | Caddy Compose | Адреса публикации Caddy-варианта. | | `NEWT_ID` / `NEWT_SECRET` | Dev Compose | Доступы Newt в dev-compose. | +`TRUSTED_PROXIES` нужен не только для логов: платежные webhook-обработчики с IP-фильтром +сравнивают allowlist провайдера с client IP после обработки `X-Forwarded-For`. Если внешний +proxy не передает этот заголовок или его IP не входит в `TRUSTED_PROXIES`, backend увидит IP +proxy/Docker gateway и может отклонить валидный webhook. Для Caddy/Nginx/Newt из +`deploy/examples` дефолта достаточно; в кастомной инфраструктуре добавьте CIDR своего proxy +или сузьте значение до конкретных proxy IP. Trust-all вариант записывается как +`0.0.0.0/0,::/0`, но он безопасен только если backend не доступен напрямую, а внешний proxy +очищает входящий `X-Forwarded-For`. + ## Кеши, rate limits и worker Обычно эти значения не требуют правки. @@ -59,14 +68,31 @@ | `PANEL_DEVICES_CACHE_TTL_SECONDS` | TTL кеша устройств пользователя Remnawave. | | `PANEL_ALL_USERS_CACHE_TTL_SECONDS` | TTL кеша полных сканов пользователей Remnawave. | | `PANEL_ALL_USERS_PAGE_SIZE` | Размер страницы Remnawave `/users`. | +| `PANEL_API_TOTAL_TIMEOUT_SECONDS` | Общий timeout запроса к Remnawave API. | +| `PANEL_API_CONNECT_TIMEOUT_SECONDS` | Timeout получения соединения с Remnawave API. | +| `PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS` | Timeout TCP/TLS-подключения к Remnawave API. | +| `PANEL_API_SOCK_READ_TIMEOUT_SECONDS` | Timeout ожидания данных ответа Remnawave API. | | `ADMIN_PANEL_STATS_CACHE_TTL_SECONDS` | TTL статистики Remnawave в админке. | | `ADMIN_DB_STATS_CACHE_TTL_SECONDS` | TTL дорогих DB-агрегатов админки. | | `ADMIN_USERS_LIST_CACHE_TTL_SECONDS` | TTL списка пользователей админки. | +| `ADMIN_BROADCAST_AUDIENCE_COUNTS_CACHE_TTL_SECONDS` | TTL счетчиков целевых групп рассылки в админке. | | `PROFILE_SYNC_CACHE_TTL_SECONDS` | Минимальная пауза между sync Telegram-профиля пользователя. | | `PANEL_SYNC_LIFETIME_TRAFFIC_MIN_INTERVAL_SECONDS` | Минимальная пауза записи lifetime-трафика. | | `PANEL_SYNC_LIFETIME_TRAFFIC_MIN_DELTA_BYTES` | Дельта lifetime-трафика для более ранней записи. | | `WEBAPP_RATE_LIMIT_TTL_SECONDS` | Окно Web App rate limit. | | `WEBAPP_RATE_LIMIT_MAX_REQUESTS` | Количество запросов в окне rate limit. | +| `TELEGRAM_DROP_NON_PRIVATE_UPDATES` | Отбрасывать group/channel Telegram-апдейты до DB-backed middleware. По умолчанию `True`. | +| `TELEGRAM_ANTIFLOOD_ENABLED` | Включает мягкие per-user/per-chat лимиты для экстремального Telegram-флуда. По умолчанию `True`. | +| `TELEGRAM_ANTIFLOOD_WINDOW_SECONDS` | Окно лимитов Telegram антифлуда. По умолчанию `60`. | +| `TELEGRAM_ANTIFLOOD_MAX_UPDATES_PER_WINDOW` | Глобальный лимит Telegram-апдейтов на источник за окно. По умолчанию `180`; `0` отключает лимит. | +| `TELEGRAM_ANTIFLOOD_MESSAGE_MAX_PER_WINDOW` | Лимит Telegram messages на источник за окно. По умолчанию `120`; `0` отключает лимит. | +| `TELEGRAM_ANTIFLOOD_CALLBACK_MAX_PER_WINDOW` | Лимит callback query на источник за окно. По умолчанию `240`; `0` отключает лимит. | +| `TELEGRAM_ANTIFLOOD_INLINE_MAX_PER_WINDOW` | Лимит inline query на источник за окно. По умолчанию `60`; `0` отключает лимит. | +| `TELEGRAM_ANTIFLOOD_START_MAX_PER_WINDOW` | Лимит `/start` на источник за окно. По умолчанию `30`; `0` отключает лимит. | +| `TELEGRAM_ANTIFLOOD_EXPENSIVE_CALLBACK_MAX_PER_WINDOW` | Лимит платежных, trial, promo и account-changing callback за окно. По умолчанию `60`; `0` отключает лимит. | +| `TELEGRAM_ACTION_COOLDOWN_ENABLED` | Дедуплицирует точные повторы платежных и trial callback от того же пользователя. По умолчанию `True`. | +| `TELEGRAM_PAYMENT_CALLBACK_COOLDOWN_SECONDS` | Cooldown точного повтора платежного callback. По умолчанию `20`; `0` отключает cooldown. | +| `TELEGRAM_TRIAL_CALLBACK_COOLDOWN_SECONDS` | Cooldown точного повтора trial callback. По умолчанию `30`; `0` отключает cooldown. | | `WEBHOOK_QUEUE_NAME` | Redis queue для тяжелой обработки webhook. | | `WEBHOOK_QUEUE_CONCURRENCY` | Количество worker consumers для webhook queue. | | `WORKER_PANEL_SYNC_INTERVAL_SECONDS` | Интервал фоновой синхронизации с панелью. | @@ -101,11 +127,10 @@ | `DEFAULT_CURRENCY_SYMBOL` | Символ/код валюты в интерфейсе. | | `SUPPORT_LINK` | Внешняя ссылка поддержки. | | `SERVER_STATUS_URL` | Страница статуса сервиса. | -| `TERMS_OF_SERVICE_URL` | Условия использования. | | `PRIVACY_POLICY_URL` | Политика конфиденциальности. | | `USER_AGREEMENT_URL` | Пользовательское соглашение. | -| `REQUIRED_CHANNEL_ID` | ID обязательного Telegram-канала. | -| `REQUIRED_CHANNEL_LINK` | Ссылка на обязательный канал. | +| `REQUIRED_CHANNEL_ID` | ID обязательного Telegram-канала. Используется для проверки подписки и автоматического получения ссылки кнопки, если бот видит канал. | +| `REQUIRED_CHANNEL_LINK` | Необязательная запасная ссылка на обязательный канал (`@username` или invite-link), если ссылку нельзя получить по ID. | | `START_COMMAND_DESCRIPTION` | Описание `/start` для меню Telegram. | | `DISABLE_WELCOME_MESSAGE` | Отключить приветствие на `/start`. | @@ -157,9 +182,6 @@ | `TELEGRAM_OAUTH_REQUEST_ACCESS` | `.env` | Дополнительные разрешения, например `write`. | | `WEBAPP_PRIMARY_COLOR` | Админка | Устаревшее env-поле, игнорируется. | | `WEBAPP_LOGO_URL` | Админка | Устаревшее env-поле, игнорируется. | -| `WEBAPP_LOGO_USE_EMOJI` | Админка | Устаревшее env-поле, игнорируется. | -| `WEBAPP_LOGO_EMOJI` | Админка | Устаревшее env-поле, игнорируется. | -| `WEBAPP_LOGO_EMOJI_FONT` | Админка | Устаревшее env-поле, игнорируется. | | `WEBAPP_FAVICON_USE_CUSTOM` | Админка | Устаревшее env-поле, игнорируется. | | `WEBAPP_FAVICON_URL` | Админка | Устаревшее env-поле, игнорируется. | | `WEBAPP_LOGO_FAVICON_URL` | Админка | Устаревшее env-поле, игнорируется. | @@ -197,9 +219,10 @@ | Переменная | Назначение | | --- | --- | -| `PAYMENT_METHODS_ORDER` | Порядок кнопок оплаты: `severpay,wata,freekassa,platega,yookassa,stars,cryptopay,heleket`. | +| `PAYMENT_METHODS_ORDER` | Порядок кнопок оплаты: `severpay,wata,freekassa,platega,yookassa,stars,cryptopay,heleket,paykilla`. | | `SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED` | Показывать описание подписки перед выбором срока. | | `SUBSCRIPTION_PURCHASE_DESCRIPTION_RU` / `SUBSCRIPTION_PURCHASE_DESCRIPTION_EN` | Локализованное описание подписки. | +| `PAYMENT_REQUEST_TIMEOUT_SECONDS` | Общий таймаут одного API-запроса к платёжному провайдеру, в секундах. По умолчанию `20`. | | `PAYMENT__WEBAPP_LABEL_RU` / `PAYMENT__WEBAPP_LABEL_EN` | Текст кнопки провайдера в Web App. | | `PAYMENT__WEBAPP_ICON` | Lucide-иконка кнопки в Web App. | | `PAYMENT__TELEGRAM_LABEL_RU` / `PAYMENT__TELEGRAM_LABEL_EN` | Текст кнопки в Telegram. | @@ -213,6 +236,7 @@ | `WATA_ENABLED` | Включает Wata. | | `CRYPTOPAY_ENABLED` | Включает CryptoPay. | | `HELEKET_ENABLED` | Включает Heleket. | +| `PAYKILLA_ENABLED` | Включает PayKilla. | Конкретные ключи отображения: @@ -271,6 +295,12 @@ PAYMENT_HELEKET_WEBAPP_ICON PAYMENT_HELEKET_TELEGRAM_LABEL_RU PAYMENT_HELEKET_TELEGRAM_LABEL_EN PAYMENT_HELEKET_TELEGRAM_EMOJI +PAYMENT_PAYKILLA_WEBAPP_LABEL_RU +PAYMENT_PAYKILLA_WEBAPP_LABEL_EN +PAYMENT_PAYKILLA_WEBAPP_ICON +PAYMENT_PAYKILLA_TELEGRAM_LABEL_RU +PAYMENT_PAYKILLA_TELEGRAM_LABEL_EN +PAYMENT_PAYKILLA_TELEGRAM_EMOJI ``` ### YooKassa @@ -357,6 +387,35 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI | `HELEKET_VERIFY_WEBHOOK_SIGNATURE` | Проверять подпись webhook. | | `HELEKET_TRUSTED_IPS` | Список доверенных IP webhook-источников. | +### PayKilla + +Для приема оплат нужен API key типа **HMAC** с правом **INVOICE**. Право **WITHDRAWAL** для оплаты подписок не требуется; включайте его только для отдельной интеграции выплат. + +Webhook настраивается в PayKilla Dashboard: **Settings -> Webhooks**. Укажите `WEBHOOK_BASE_URL` + `/webhook/paykilla`, например `https://bot.example.com/webhook/paykilla`. Включите события `INVOICE_PAID` и `INVOICE_EXPIRED` как минимум. Рекомендуемый набор галочек: `INVOICE_PAID`, `PAYMENT_COMPLETED`, `PAYMENT_FAILED`, `PAYMENT_OVERPAID`, `PAYMENT_UNDERPAID`, `PAYMENT_PARTIAL`, `INVOICE_EXPIRED`, `COMPLIANCE_FAILED`. Если хотите видеть промежуточные статусы в логах PayKilla, дополнительно включите `INVOICE_CREATED`, `PAYMENT_PENDING`, `TRANSACTION_CONFIRMED` и `TRANSACTION_FINAL`. + +| Переменная | Назначение | +| --- | --- | +| `PAYKILLA_BASE_URL` | Базовый URL API, по умолчанию `https://account-api.paykilla.com`. | +| `PAYKILLA_WIDGET_URL` | URL hosted checkout, по умолчанию `https://gopay.paykilla.com`. | +| `PAYKILLA_API_KEY` / `PAYKILLA_V2_API_KEY` | Public HMAC key с правом `INVOICE`. | +| `PAYKILLA_SECRET_KEY` / `PAYKILLA_V2_SECRET_KEY` | Secret HMAC key для подписи API-запросов и проверки webhook. | +| `PAYKILLA_CURRENCY` | Резервная валюта инвойса PayKilla для платежей, чья валюта тарифа не входит в `PAYKILLA_INVOICE_CURRENCIES`. По умолчанию `USD`. | +| `PAYKILLA_INVOICE_CURRENCIES` | Валюты, которые PayKilla принимает в поле `currency` при создании invoice. По умолчанию `USD,EUR`. Если тариф в `RUB`, Minishop конвертирует сумму в `PAYKILLA_CURRENCY`. | +| `PAYKILLA_PAYMENT_CURRENCIES` | Crypto tickers для оплаты. По умолчанию `USDTTRC,BTC,ETH,USDTBSC,USDTTON`; оставляйте в списке только тикеры, доступные в PayKilla Dashboard для merchant account. | +| `PAYKILLA_SUPPORTED_CURRENCIES` | Валюты тарифов/платежей, которым разрешено использовать PayKilla в этом магазине. | +| `PAYKILLA_INVOICE_TYPE` | Необязательный override: `FIAT_BASED`, `FIXED_AMOUNT` или `OPEN_AMOUNT`. | +| `PAYKILLA_LIFETIME_SECONDS` | TTL инвойса, отправляется как `expiredAt`. | +| `PAYKILLA_RECV_WINDOW_MS` | `recvWindow` для подписанных API-запросов. | +| `PAYKILLA_USER_PAYS_SERVICE_FEE` | `true`, если пользователь оплачивает service fee. | +| `PAYKILLA_USER_PAYS_NETWORK_FEE` | `true`, если пользователь оплачивает network fee. | +| `PAYKILLA_EXCHANGE_RATE_URL` | Бесплатный no-key endpoint курса для конвертации валюты тарифа в валюту инвойса. По умолчанию `https://open.er-api.com/v6/latest/{source}`. Поддерживает placeholders `{source}` и `{target}`. | +| `PAYKILLA_EXCHANGE_RATE_CACHE_SECONDS` | Кэш курса и PayKilla currency limits в секундах. По умолчанию `3600`. | +| `PAYKILLA_MIN_PAYMENT_AMOUNT` | Минимальная сумма платежа через PayKilla. По умолчанию `10`. | +| `PAYKILLA_MIN_PAYMENT_CURRENCY` | Валюта для `PAYKILLA_MIN_PAYMENT_AMOUNT`. По умолчанию `USD`; для рублевых тарифов порог конвертируется по `PAYKILLA_EXCHANGE_RATE_URL`. | +| `PAYKILLA_VERIFY_WEBHOOK_SIGNATURE` | Проверять `X-API-SIGN` по raw body webhook. | +| `PAYKILLA_WEBHOOK_URL` | Точный публичный webhook URL для проверки подписи, если он отличается от `WEBHOOK_BASE_URL` + `/webhook/paykilla`. | +| `PAYKILLA_TRUSTED_IPS` | Необязательный список доверенных IP webhook-источников. | + ## Тарифы и legacy-цены Рекомендуемый способ настройки тарифов - раздел **Система -> Тарифы** в админке. Он сохраняет JSON в `TARIFFS_CONFIG_PATH`. @@ -386,10 +445,13 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI | `TRIAL_DURATION_DAYS` | Длительность пробного периода. | | `TRIAL_TRAFFIC_LIMIT_GB` | Лимит трафика пробного периода. | | `TRIAL_TRAFFIC_STRATEGY` | Стратегия лимита пробного периода. | +| `TRIAL_WITHOUT_TELEGRAM_ENABLED` | Разрешает активацию trial пользователям без привязанного Telegram. Disposable email домены всё равно требуют Telegram. | | `TRIAL_SQUAD_UUIDS` | Internal Squads для trial через запятую. Если пусто, используется `USER_SQUAD_UUIDS`. | | `REFERRAL_ONE_BONUS_PER_REFEREE` | Ограничить бонусы одним успешным платежом приглашенного. | | `REFERRAL_WELCOME_BONUS_DAYS` | Приветственный бонус пришедшему по реферальной ссылке. | +| `REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED` | Разрешает начислять реферальный приветственный бонус пользователям без привязанного Telegram. Disposable email домены всё равно требуют Telegram. | | `LEGACY_REFS` | Разрешить ссылки `ref_`. | +| `DISPOSABLE_EMAIL_DOMAINS` | Домены одноразовой почты через запятую. Для таких email trial и реферальный welcome bonus доступны только после привязки Telegram. | | `REFERRAL_BONUS_DAYS_1_MONTH`, `REFERRAL_BONUS_DAYS_3_MONTHS`, `REFERRAL_BONUS_DAYS_6_MONTHS`, `REFERRAL_BONUS_DAYS_12_MONTHS` | Legacy-бонусы пригласившему без JSON-каталога. В JSON-тарифах используйте `referral_bonus_days_inviter`. | | `REFEREE_BONUS_DAYS_1_MONTH`, `REFEREE_BONUS_DAYS_3_MONTHS`, `REFEREE_BONUS_DAYS_6_MONTHS`, `REFEREE_BONUS_DAYS_12_MONTHS` | Legacy-бонусы приглашенному без JSON-каталога. В JSON-тарифах используйте `referral_bonus_days_referee`. | | `SUBSCRIPTION_NOTIFICATIONS_ENABLED` | Включает напоминания о подписке. | diff --git a/docs/configuration/security.md b/docs/configuration/security.md index 98d04c4..45cd5c3 100644 --- a/docs/configuration/security.md +++ b/docs/configuration/security.md @@ -15,6 +15,33 @@ openssl rand -hex 32 ``` +## Telegram антифлуд + +Апдейты Telegram-бота проходят через ранний anti-flood middleware до sync +профиля, проверки каналов и логирования действий, которые обращаются к базе. +Профиль по умолчанию специально мягкий: он не должен мешать быстрым обычным +нажатиям кнопок, но отбрасывает экстремальные всплески сообщений и callback от +одного источника до того, как они разгонят записи в БД, платежные обработчики +или Telegram FloodWait. + +Настройки доступны в **Админка -> Система -> Настройки -> Система -> Telegram +антифлуд**. Для обычного приватного продающего бота дефолтов достаточно, поэтому +они не добавлены в `.env.example`. Меняйте их только если реальный трафик +показывает, что пороги нужно расширить или сузить. + +- `TELEGRAM_DROP_NON_PRIVATE_UPDATES=True` отбрасывает апдейты из групп и + каналов до дорогих middleware. Оставляйте включенным, если бот не должен + работать вне приватных чатов. +- `TELEGRAM_ANTIFLOOD_ENABLED=True` включает per-user/per-chat лимиты. Значение + `0` у отдельного числового лимита отключает только этот лимит. +- `TELEGRAM_ANTIFLOOD_WINDOW_SECONDS=60` задает общее скользящее окно. Дефолты + рассчитаны на нормальные button-heavy флоу: `180` всех апдейтов, `120` + сообщений, `240` callback, `60` inline-запросов, `30` команд `/start` и `60` + тяжелых callback за окно. +- `TELEGRAM_ACTION_COOLDOWN_ENABLED=True` дедуплицирует точные повторные + платежные и trial callback от того же пользователя. Разные платежные payload + не объединяются, поэтому обычный checkout остается независимым. + ## Доступ администраторов - `ADMIN_IDS` задает Telegram ID администраторов. @@ -28,6 +55,14 @@ openssl rand -hex 32 - `SUBSCRIPTION_MINI_APP_URL` должен вести на frontend/Mini App-домен. - Не добавляйте `/api`, `/auth` или webhook-пути в `SUBSCRIPTION_MINI_APP_URL`. +## IP allowlist вебхуков + +- Reverse proxy для `WEBHOOK_BASE_URL` должен передавать `X-Forwarded-For` с реальным IP отправителя. +- `TRUSTED_PROXIES` должен включать IP/CIDR последнего proxy-hop до backend. Иначе платежные webhook-обработчики будут проверять allowlist по IP proxy или Docker gateway. +- В Docker Compose профилях Caddy, Nginx и Pangolin/Newt дефолт покрывает loopback и private ranges: `127.0.0.1`, `::1`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `fc00::/7`. +- Если backend находится в общей Docker-сети с недоверенными контейнерами, сузьте `TRUSTED_PROXIES` до конкретных IP ваших reverse proxy. +- Если вы сознательно хотите доверять любому proxy-hop, используйте `0.0.0.0/0,::/0`, но только когда backend не опубликован напрямую, а внешний proxy очищает входящий `X-Forwarded-For`. + ## Дополнительно - Используйте HTTPS на всех публичных доменах. diff --git a/docs/configuration/telemetry.md b/docs/configuration/telemetry.md index 64fafbb..ae17a9d 100644 --- a/docs/configuration/telemetry.md +++ b/docs/configuration/telemetry.md @@ -11,6 +11,8 @@ | `installation_id` | `f47ac10b-...` (UUIDv4) | Случайный идентификатор установки. Генерируется один раз, хранится в БД. Не выводится из токена, домена или ID администраторов. | | `app_version` | `v3.4.6+gabc1234` | Полная версия сборки. | | `app_version_tag` | `v3.4.6` | Релизный тег для разбивки по версиям. | +| `build_provenance` | `official` / `custom` | Маркер происхождения образа. `official` ставится только официальной сборкой проекта; локальные сборки и форки считаются `custom`. | +| `image_modified` | `false` / `true` | Удобный булевый флаг для разбивки: `false` только у официальных образов, `true` у локальных/форк-сборок. | | `os` / `arch` | `linux` / `x86_64` | Платформа. | | `python_version` | `3.12.7` | Версия рантайма. | | `locale` | `ru` | Язык по умолчанию. | @@ -22,7 +24,7 @@ ## Чего там нет -Никогда не отправляются: токен бота, домены, URL вебхуков, ключи платёжных систем и Remnawave, ID или данные пользователей, точное число пользователей, какой-либо контент. +Никогда не отправляются: токен бота, домены, URL вебхуков, ключи платёжных систем и Remnawave, ID или данные пользователей, точное число пользователей, какой-либо контент, registry образа или URL форка. ## Как выключить diff --git a/docs/features/admin-panel.md b/docs/features/admin-panel.md index 1847075..8ceb670 100644 --- a/docs/features/admin-panel.md +++ b/docs/features/admin-panel.md @@ -9,7 +9,7 @@ - блокировка пользователей, входящий список тикетов поддержки, рассылки, промокоды и просмотр логов; - ручная синхронизация с Remnawave; - редактор разрешенных настроек приложения из manifest-файла; -- раздел **Внешний вид** для логотипа, emoji-логотипа, выбора темы, accent-цвета, масштаба логотипа и предпросмотра тем; +- раздел **Внешний вид** для логотипа, выбора темы, accent-цвета, масштаба логотипа и предпросмотра тем; - раздел **Инструкции подключения** для встроенной страницы установки, поведения кнопок бота и Remnawave Subscription Page config; - раздел **Бэкапы** для просмотра локальных ZIP-архивов, загрузки архива и восстановления БД/compose-папки; - редактор JSON-каталога тарифов; @@ -49,11 +49,11 @@ В manifest сейчас входят: - общие параметры: язык, валюта, ссылки поддержки, документы, обязательный канал, Remnawave-доступы и поведение `/start`; -- внешний вид и доступность Web App: название, цвет, логотип, emoji-логотип и `WEBAPP_ENABLED`; +- внешний вид и доступность Web App: название, цвет, логотип и `WEBAPP_ENABLED`; - инструкции подключения: `SUBSCRIPTION_GUIDES_ENABLED`, `SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED`, чтение конфига из Remnawave Panel, JSON-переопределение и резервный путь к файлу; - legacy-тарифы без JSON-каталога: периоды подписки, RUB/Stars цены, реферальные бонусы и пакеты трафика; - платежные провайдеры: включение методов, порядок кнопок, публичные параметры и секреты YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket и Stars, а также текст и иконки кнопок оплаты; -- пробный период, приветственный реферальный бонус, уведомления, логирование, поддержка, раздел устройств, лимит устройств и legacy-лимиты трафика. +- пробный период, приветственный реферальный бонус, уведомления, логирование, Telegram антифлуд, поддержка, раздел устройств, лимит устройств и legacy-лимиты трафика. Секретные поля помечены как secret и не должны использоваться для произвольного просмотра старых значений. Настройки, которых нет в manifest, остаются только в `.env` или коде. @@ -102,9 +102,11 @@ ## Внешний вид -Раздел **Внешний вид** объединяет настройки бренда и темы Web App. Логотип можно загрузить файлом или по HTTPS-ссылке; backend сохраняет файл в `data/webapp-logo/uploads` и подставляет локальный URL. Если включен emoji-логотип, картинка скрывается, а для emoji можно выбрать системный, Twemoji, Noto Color, animated Noto и другие варианты отрисовки. +Раздел **Внешний вид** объединяет настройки бренда и темы Web App. Логотип можно загрузить файлом или по HTTPS-ссылке; backend сохраняет файл в `data/webapp-logo/uploads` и подставляет локальный URL. Если логотип не задан, показывается логотип проекта по умолчанию. Favicon генерируется из логотипа или загружается отдельно. -В блоке тем админка читает каталог из `WEBAPP_THEMES_DIR`, показывает встроенные и кастомные темы, позволяет выбрать текущую тему, изменить accent, включить или выключить тему для админки и настроить масштаб логотипа на главной и экране входа. Кнопка предпросмотра открывает `/home?theme_preview=` и не меняет глобальную тему до сохранения. +Этот же бренд используется в HTML-письмах. Загруженный локальный логотип встраивается в письмо как inline image (`cid:webapp-logo`), поэтому email-клиенту не нужен прямой доступ к `/webapp-uploaded-logo/...`. Логотип по публичной HTTPS-ссылке остается внешней картинкой в письме. + +В блоке тем админка читает каталог из `WEBAPP_THEMES_DIR`, показывает встроенные и кастомные темы, позволяет выбрать текущую тему, изменить accent, включить или выключить тему для админки и настроить отдельный масштаб логотипа для desktop и mobile layout. Кнопка предпросмотра открывает `/home?theme_preview=` и не меняет глобальную тему до сохранения. Подробный формат `theme.json`, CSS/asset-роуты и пошаговый пайплайн создания новой темы описаны в [webapp-themes.md](webapp-themes.md). @@ -122,6 +124,8 @@ - **Premium**: названия premium-раздела RU/EN, premium Internal Squads, месячный premium-лимит и RUB/Stars пакеты premium-докупки; - **Устройства**: RUB/Stars пакеты докупки HWID-устройств. +Порядок периодов, traffic-пакетов, обычных докупок, premium-докупок и HWID-пакетов меняется перетаскиванием строк в редакторе. Этот порядок сохраняется в JSON и используется на витрине Web App и в Telegram-боте. + Базовые и premium Internal Squads выбираются из Remnawave через `/api/admin/panel/internal-squads`. Если панель недоступна, можно сохранить уже существующие UUID в JSON, но выпадающий список не загрузится. ## Практические замечания diff --git a/docs/features/email-login.md b/docs/features/email-login.md index c200518..8ef5a73 100644 --- a/docs/features/email-login.md +++ b/docs/features/email-login.md @@ -44,6 +44,12 @@ BRUTE_FORCE_WINDOW_SECONDS=900 BRUTE_FORCE_LOCK_SECONDS=900 ``` +## Брендинг писем + +HTML-письма используют тот же бренд, что и Mini App: название из `WEBAPP_TITLE`, accent из внешнего вида и логотип из раздела **Внешний вид**. Если логотип загружен через админку файлом, backend прикладывает его к письму как inline image (`cid:webapp-logo`), поэтому получателю не нужен доступ к внутреннему `/webapp-uploaded-logo/...`. + +Если в качестве логотипа задан публичный `https://` URL, письмо использует его как обычный внешний ``. В этом режиме некоторые почтовые клиенты могут скрыть картинку, пока получатель не разрешит загрузку внешних изображений. + Для Brevo обычно подходит порт `587` с STARTTLS. Если основной порт недоступен, приложение пробует порты из `SMTP_FALLBACK_PORTS`; порт `465` используется через SSL wrapper автоматически. `SMTP_FROM_EMAIL` должен быть подтвержден у SMTP-провайдера, иначе письмо часто отклоняется или попадает в спам. `SMTP_FROM_NAME` можно оставить пустым, тогда используется название Web App. @@ -106,3 +112,56 @@ docker compose logs -f backend - Пользователь получает `rate_limited`: подождите `EMAIL_CODE_RESEND_SECONDS` или проверьте brute-force настройки. Email-уведомления поддержки, платежей и жизненного цикла подписки используют тот же SMTP-контур. Сценарий поддержки описан в [разделе тикетов](support.md), сводка по каналам - в разделе [уведомления](notifications.md). + +## Настройка локального SMTP-сервера + +### Требования + +Перед запуском локального SMTP-сервера вам потребуется настроить DNS-записи вашего домена: + +* A — mail.example.com (указать IP вашего сервера) +* MX — mail.example.com (приоритет 10) +* PTR — mail.example.com (настраивается у вашего хостера VPS) +* TXT — `v=spf1 ip4:1.2.3.4 ~all` (поддомен @, замените `1.2.3.4` на реальный ipv4 вашего сервера) + +После изменения DNS-записей подождите некоторое время (иногда требуется от 3 часов до суток) для применения изменений. + +Для использования STARTTLS необходимо получить TLS/SSL-сертификат для домена mail.example.com. Самый простой способ это сделать — прописать следующую директиву в Caddyfile (если вы используете Caddy): + +```Caddyfile +https://mail.example.com { + respond "Mail server" +} +``` + +### Установка + +1. Настройка docker-compose.yml: + +```bash +mkdir -p /opt/mailserver +cd /opt/mailserver +curl -O https://raw.githubusercontent.com/3252a8/remnawave-minishop/refs/heads/main/deploy/examples/mail/docker-compose.yml +nano docker-compose.yml +``` + +2. Запуск: + +```bash +docker compose up -d +``` + +3. Создание пользователя: + +```bash +# Скрипт попросит придумать пароль и потвердить его +docker exec -it mailserver setup email add no-reply@example.com + +# Проверка создания пользователя +docker exec -it mailserver setup email list + +# Должны увидеть: +# no-reply@example.com +``` + +После всех настроек вы можете использовать свой почтовый клиент (Microsoft Outlook, Mozilla Thunderbird) для отправки и получения писем. В окне добавления нового аккаунта достаточно ввести созданную почту no-reply@example.com и пароль. diff --git a/docs/features/notifications.md b/docs/features/notifications.md index 1090eac..8450d4e 100644 --- a/docs/features/notifications.md +++ b/docs/features/notifications.md @@ -6,6 +6,8 @@ Minishop отправляет уведомления в Telegram и на email. Для уведомлений жизненного цикла подписки есть отдельный флаг `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED`. Если он включен, пользовательские уведомления об окончании подписки отправляются в Telegram при наличии привязанного Telegram-аккаунта и на email при наличии привязанной почты. +Все HTML-письма используют общий email-шаблон с брендом из Web App: заголовком, accent-цветом и логотипом. Логотип, загруженный через раздел **Внешний вид**, отправляется как inline image, а публичный HTTPS-логотип остается внешней картинкой. + ## Сводная таблица | Событие | Получатель | Telegram | Email | Условия и ограничения | @@ -16,7 +18,7 @@ Minishop отправляет уведомления в Telegram и на email. | Успешная покупка отдельного пакета трафика | Пользователь | ✓ | ✓ | Для `traffic` / `traffic_package`; email отправляется, если SMTP настроен и у пользователя есть email. | | Успешная докупка обычного трафика к тарифу | Пользователь | ✓ | ✓ | Для `topup`; email отправляется, если SMTP настроен и у пользователя есть email. | | Успешная покупка premium-трафика | Пользователь | ✓ | ✓ | Для `premium_topup`; email отправляется, если SMTP настроен и у пользователя есть email. | -| Успешная покупка HWID-устройств | Пользователь | ✓ | ✓ | Отправляется после оплаты `hwid_devices` или `hwid_devices_renewal`; email отправляется, если SMTP настроен и у пользователя есть email. | +| Успешная покупка HWID-устройств | Пользователь | ✓ | ✓ | Отправляется после отдельной оплаты `hwid_devices`; при продлении устройств вместе с подпиской добавляется примечание к уведомлению об успешной оплате подписки. Email отправляется, если SMTP настроен и у пользователя есть email. | | Платное повышение тарифа | Пользователь | ✓ | ✓ | Для `tariff_upgrade`; email отправляется, если SMTP настроен и у пользователя есть email. | | Способ оплаты YooKassa привязан | Пользователь | ✓ | ✓ | Отправляется после успешного сохранения платежного метода через webhook YooKassa; email отправляется, если SMTP настроен и у пользователя есть email. | | Ошибка оплаты по webhook провайдера | Пользователь | ✓ | ✓ | Отправляется, когда платежный провайдер сообщает о неуспешном платеже; email отправляется, если SMTP настроен и у пользователя есть email. | diff --git a/docs/features/payments.md b/docs/features/payments.md index e8d07b1..3a5d7fc 100644 --- a/docs/features/payments.md +++ b/docs/features/payments.md @@ -1,152 +1,273 @@ # Платежи -Платежные методы включаются настройками и отображаются пользователю как кнопки оплаты в Mini App и Telegram-сценариях. Настройки можно задавать через `.env` или через админку, если параметр есть в allowlist настроек. +Платежные методы включаются через `.env` или админ-панель, если параметр добавлен в allowlist настроек. В Mini App и Telegram-сценариях включённые методы отображаются как кнопки оплаты. -## Типовой порядок настройки +## Общий порядок настройки -1. Включите нужный провайдер в админке или через `.env`. +1. Включите нужный провайдер. 2. Заполните публичные параметры, секреты и URL возврата. -3. Настройте URL вебхука у провайдера, если это требуется. +3. Настройте webhook URL у провайдера, если он используется. 4. Проверьте порядок методов в `PAYMENT_METHODS_ORDER`. 5. Проверьте подписи и иконки кнопок оплаты. -6. Выполните тестовый платеж и проверьте логи `backend`. +6. Выполните тестовый платеж. +7. Проверьте логи `backend`. -Общие ссылки: +> [!NOTE] +> Если URL возврата не задан явно, используется ссылка на Telegram-бота. -- [Справочник `.env`](../configuration/env-vars.md) содержит все ключи провайдеров. -- [Админ-панель](admin-panel.md) описывает UI-настройки платежей. -- [Тарифы](tariffs.md) описывают цены, Telegram Stars и сценарии покупки. -- [Логи](../troubleshooting/logs.md) помогают проверить webhook и создание платежных ссылок. +## Общие ссылки + +- [Справочник `.env`](../configuration/env-vars.md) — все ключи платежных провайдеров. +- [Админ-панель](admin-panel.md) — UI-настройки платежей. +- [Тарифы](tariffs.md) — цены, Telegram Stars и сценарии покупки. +- [Логи](../troubleshooting/logs.md) — проверка webhook и создания платежных ссылок. + +## Webhook URL провайдеров +> [!TIP] +> Готовый URL вебхука отображается вверху раздела каждого провайдера в админ-панели. + +Все платежные webhook URL строятся от `WEBHOOK_BASE_URL` - публичного HTTPS-адреса backend/webhook-домена. Это должен быть домен, который проксируется на backend-сервер вебхуков (`backend:8080`), а не `SUBSCRIPTION_MINI_APP_URL` frontend/Mini App. Если `WEBHOOK_BASE_URL=https://bot.example.com`, то полный адрес получается как `https://bot.example.com` + путь из таблицы. + +Если у провайдера включена IP-фильтрация (`FREEKASSA_TRUSTED_IPS`, `WATA_TRUSTED_IPS`, +`HELEKET_TRUSTED_IPS`, `PAYKILLA_TRUSTED_IPS` или встроенный allowlist YooKassa), +reverse proxy должен прокидывать `X-Forwarded-For`, а его IP/CIDR должен входить в +`TRUSTED_PROXIES`. Иначе backend увидит IP proxy/Docker gateway и может отклонить +валидный webhook с ошибкой `403`. + +| Провайдер | Что указать в кабинете провайдера | Комментарий | +| --- | --- | --- | +| YooKassa | `WEBHOOK_BASE_URL` + `/webhook/yookassa` | Например `https://bot.example.com/webhook/yookassa`. | +| FreeKassa | `WEBHOOK_BASE_URL` + `/webhook/freekassa` | Используйте как notification/webhook URL; при IP-фильтрации заполните `FREEKASSA_TRUSTED_IPS`. | +| Platega | `WEBHOOK_BASE_URL` + `/webhook/platega` | Один общий webhook для основной, СБП/карты и crypto-кнопки Platega. | +| SeverPay | `WEBHOOK_BASE_URL` + `/webhook/severpay` | Укажите как callback/webhook URL, если поле есть в кабинете мерчанта. | +| Wata | `WEBHOOK_BASE_URL` + `/webhook/wata` | Если включена проверка подписи, настройте `WATA_WEBHOOK_VERIFY_SIGNATURE` и `WATA_PUBLIC_KEY`. | +| CryptoPay | `WEBHOOK_BASE_URL` + `/webhook/cryptopay` | Указывается в настройках Crypto Bot / CryptoPay webhook. | +| Heleket | `WEBHOOK_BASE_URL` + `/webhook/heleket` | При необходимости включите `HELEKET_VERIFY_WEBHOOK_SIGNATURE` и `HELEKET_TRUSTED_IPS`. | +| PayKilla | `WEBHOOK_BASE_URL` + `/webhook/paykilla` | Указывается в PayKilla Dashboard -> Settings -> Webhooks; включите события оплаты инвойсов. | +| Telegram Stars | Отдельный платежный webhook не нужен | Stars-события приходят через webhook Telegram-бота: `WEBHOOK_BASE_URL` + `/tg/webhook`. | + +После настройки сделайте тестовый платеж и проверьте, что в логах `backend` видно входящий `POST` на нужный путь. Если провайдер сообщает, что адрес недоступен, сначала проверьте DNS/HTTPS и reverse proxy для `WEBHOOK_BASE_URL`, затем убедитесь, что путь начинается ровно с `/webhook/...` без `/api`, `/auth` и frontend-домена. ## YooKassa -YooKassa используется для рублевых оплат и может участвовать в сценариях автопродления period-подписок. +YooKassa используется для рублевых оплат. Провайдер также может участвовать в сценариях автопродления period-подписок. -Что настроить: +### Настройка -- включение провайдера: `YOOKASSA_ENABLED`; -- идентификаторы и секреты магазина; -- URL вебхука на backend-домен; -- отображение кнопки оплаты и порядок платежных методов. +1. Включите `YOOKASSA_ENABLED`. +2. Заполните `YOOKASSA_SHOP_ID`, `YOOKASSA_SECRET_KEY` и `YOOKASSA_RETURN_URL`. +3. Скопируйте URL вебхука из админ-панели и укажите его в кабинете YooKassa. -Справочник переменных: [YooKassa](../configuration/env-vars.md#yookassa). +### Справочник + +- [YooKassa](../configuration/env-vars.md#yookassa) ## FreeKassa -FreeKassa подключается как отдельный платежный метод и обрабатывает входящие webhook-события через `backend`. +FreeKassa подключается как отдельный платежный метод. Входящие webhook-события обрабатываются через `backend`. -Что настроить: +### Настройка -- включение провайдера: `FREEKASSA_ENABLED`; -- ID магазина, API/secret-ключи и настройки подписи; -- список доверенных IP, если используется; -- публичный URL вебхука на `WEBHOOK_BASE_URL`. +1. Включите `FREEKASSA_ENABLED`. +2. Заполните `FREEKASSA_MERCHANT_ID`, `FREEKASSA_FIRST_SECRET`, `FREEKASSA_SECOND_SECRET` и `FREEKASSA_API_KEY`. +3. Проверьте настройки подписи. +4. Скопируйте URL вебхука из админ-панели и укажите его в кабинете FreeKassa. +5. При необходимости заполните `FREEKASSA_TRUSTED_IPS`. -Справочник переменных: [FreeKassa](../configuration/env-vars.md#freekassa). +### Справочник + +- [FreeKassa](../configuration/env-vars.md#freekassa) ## Platega -Platega подключается как отдельный платежный провайдер, но внутри Minishop может дать несколько кнопок: основную устаревшую кнопку, СБП/карту и крипто-кнопку. Общие параметры мерчанта задаются один раз, а ID методов оплаты и подписи кнопок настраиваются отдельно. +Platega подключается как отдельный платежный провайдер. Внутри Minishop он может создавать несколько кнопок: основную legacy-кнопку, СБП/карту и crypto-кнопку. -Что включить: +### Настройка -- `PLATEGA_ENABLED` - общий флаг провайдера; -- `PLATEGA_SBP_ENABLED` - отдельная кнопка СБП/карта; -- `PLATEGA_CRYPTO_ENABLED` - отдельная crypto-кнопка Platega; -- `PLATEGA_PAYMENT_METHOD` - устаревший/резервный ID метода оплаты для старых callback-запросов и старых установок. +1. Включите `PLATEGA_ENABLED`. +3. Укажите `PLATEGA_MERCHANT_ID` и `PLATEGA_SECRET`. +2. Включите необходимые кнопки `PLATEGA_SBP_ENABLED`, `PLATEGA_CRYPTO_ENABLED`. +4. Скопируйте URL вебхука из админ-панели и укажите его в кабинете Platega. -Что настроить: +### Справочник -1. Укажите `PLATEGA_BASE_URL`, `PLATEGA_MERCHANT_ID` и `PLATEGA_SECRET`. -2. Заполните `PLATEGA_SBP_METHOD` и/или `PLATEGA_CRYPTO_METHOD`, если используете отдельные кнопки. -3. Проверьте `PLATEGA_RETURN_URL` и `PLATEGA_FAILED_URL`. -4. Настройте тексты и иконки кнопок через `PAYMENT_PLATEGA_SBP_*` и `PAYMENT_PLATEGA_CRYPTO_*`. -5. Добавьте нужные методы в `PAYMENT_METHODS_ORDER`. - -Справочник переменных: [Platega](../configuration/env-vars.md#platega). +- [Platega](../configuration/env-vars.md#platega) ## SeverPay SeverPay подключается как отдельный платежный метод с собственным MID, token и сроком жизни платежной ссылки. -Что настроить: +### Настройка 1. Включите `SEVERPAY_ENABLED`. 2. Укажите `SEVERPAY_BASE_URL`. 3. Заполните `SEVERPAY_MID` и `SEVERPAY_TOKEN`. -4. Настройте `SEVERPAY_RETURN_URL`. +4. Скопируйте URL вебхука из админ-панели и укажите его в кабинете SeverPay. 5. При необходимости задайте `SEVERPAY_LIFETIME_MINUTES`. -6. Добавьте `severpay` в `PAYMENT_METHODS_ORDER`. -Справочник переменных: [SeverPay](../configuration/env-vars.md#severpay). +### Справочник + +- [SeverPay](../configuration/env-vars.md#severpay) ## Wata Wata подключается как отдельный провайдер с bearer token, платежными ссылками и опциональной проверкой подписи webhook. -Что настроить: +### Настройка 1. Включите `WATA_ENABLED`. 2. Укажите `WATA_BASE_URL` и `WATA_API_TOKEN`. -3. Проверьте `WATA_RETURN_URL` и `WATA_FAILED_URL`. -4. Настройте `WATA_LINK_TTL_MINUTES`: минимум 15 минут, максимум 43200. -5. Если включаете проверку подписи, задайте `WATA_WEBHOOK_VERIFY_SIGNATURE` и при необходимости `WATA_PUBLIC_KEY`. -6. Для дополнительной защиты заполните `WATA_TRUSTED_IPS`. -7. Добавьте `wata` в `PAYMENT_METHODS_ORDER`. +3. Настройте `WATA_LINK_TTL_MINUTES`. +4. Скопируйте URL вебхука из админ-панели и укажите его в кабинете Wata. +5. При необходимости включите `WATA_WEBHOOK_VERIFY_SIGNATURE`. +6. Если используется проверка подписи, задайте `WATA_PUBLIC_KEY`. +7. Для IP-фильтрации заполните `WATA_TRUSTED_IPS`. -Справочник переменных: [Wata](../configuration/env-vars.md#wata). +### Ограничения + +- `WATA_LINK_TTL_MINUTES` должен быть от `15` до `43200`. + +### Справочник + +- [Wata](../configuration/env-vars.md#wata) ## CryptoPay CryptoPay используется для криптовалютных платежей через отдельный токен и сеть Crypto Bot API. -Что настроить: +### Настройка 1. Включите `CRYPTOPAY_ENABLED`. 2. Укажите `CRYPTOPAY_TOKEN`. 3. Выберите `CRYPTOPAY_NETWORK`: `mainnet` или `testnet`. 4. Задайте `CRYPTOPAY_CURRENCY_TYPE`: `fiat` или `crypto`. 5. Проверьте `CRYPTOPAY_ASSET`, например `RUB`, `USDT` или `BTC`. -6. Добавьте `cryptopay` в `PAYMENT_METHODS_ORDER`. +6. Скопируйте URL вебхука из админ-панели и укажите его в CryptoPay. -Для тестов используйте соответствующую сеть: testnet-токен не должен попадать в mainnet-настройки. Если сумма или asset выглядят неверно, проверьте сочетание `CRYPTOPAY_CURRENCY_TYPE` и `CRYPTOPAY_ASSET`. +### Проверка -Справочник переменных: [CryptoPay](../configuration/env-vars.md#cryptopay). +- Testnet-токен должен использоваться только с `testnet`. +- Mainnet-токен должен использоваться только с `mainnet`. +- Если сумма или asset выглядят неверно, проверьте сочетание `CRYPTOPAY_CURRENCY_TYPE` и `CRYPTOPAY_ASSET`. + +### Справочник + +- [CryptoPay](../configuration/env-vars.md#cryptopay) ## Heleket -Heleket используется для крипто-инвойсов с отдельными merchant ID, ключом платежного API, валютой инвойса и настройками проверки webhook. +Heleket используется для крипто-инвойсов с merchant ID, ключом платежного API, валютой инвойса и настройками проверки webhook. -Что настроить: +### Настройка 1. Включите `HELEKET_ENABLED`. 2. Укажите `HELEKET_BASE_URL`, `HELEKET_MERCHANT_ID` и `HELEKET_API_KEY`. 3. Настройте `HELEKET_CURRENCY`. 4. При необходимости задайте `HELEKET_TO_CURRENCY` и `HELEKET_NETWORK`. 5. Проверьте `HELEKET_RETURN_URL` и `HELEKET_SUCCESS_URL`. -6. Настройте `HELEKET_LIFETIME_SECONDS`: допустимый диапазон 300..43200. -7. Если включаете проверку webhook, задайте `HELEKET_VERIFY_WEBHOOK_SIGNATURE`. -8. Для IP-фильтрации заполните `HELEKET_TRUSTED_IPS`. -9. Добавьте `heleket` в `PAYMENT_METHODS_ORDER`. +6. Настройте `HELEKET_LIFETIME_SECONDS`. +7. Скопируйте URL вебхука из админ-панели и укажите его в кабинете Heleket. +8. При необходимости включите `HELEKET_VERIFY_WEBHOOK_SIGNATURE`. +9. Для IP-фильтрации заполните `HELEKET_TRUSTED_IPS`. -Справочник переменных: [Heleket](../configuration/env-vars.md#heleket). +### Ограничения + +- `HELEKET_LIFETIME_SECONDS` должен быть от `300` до `43200`. + +### Справочник + +- [Heleket](../configuration/env-vars.md#heleket) + +## PayKilla + +PayKilla используется для крипто-инвойсов V2 через hosted checkout `https://gopay.paykilla.com/{invoice_id}`. + +API-запросы подписываются HMAC-SHA256. Webhook проверяется по заголовку `X-API-SIGN` и raw body. + +### Особенности + +- PayKilla строго валидирует текстовые поля invoice. +- В `purpose` и `description` Minishop отправляет простой английский текст ` payment `. +- Локализованное описание платежа остается только внутри Minishop. +- ASCII-safe sanitizer допускает ASCII-буквы, цифры, пробелы, `_`, `.`, `,`. +- Минимальная сумма платежа задается настройками `PAYKILLA_MIN_PAYMENT_AMOUNT` и `PAYKILLA_MIN_PAYMENT_CURRENCY`; по умолчанию это `10 USD`. +- Если выбранный тариф/пакет ниже этого порога после конвертации, Telegram bot не показывает кнопку PayKilla, WebApp показывает метод неактивным, а API создания платежа возвращает ошибку `payment_amount_below_minimum`. + +### Валюта invoice + +Minishop создает invoice в валюте, которую PayKilla принимает в поле `currency`. + +Если валюта тарифа входит в `PAYKILLA_INVOICE_CURRENCIES`, сумма отправляется как есть. + +Если валюта тарифа не входит в список, сумма конвертируется в `PAYKILLA_CURRENCY`. По умолчанию рублевые тарифы конвертируются в `USD` через ExchangeRate-API с кэшем `PAYKILLA_EXCHANGE_RATE_CACHE_SECONDS`. + +Перед созданием invoice Minishop читает `GET /api/v2/currency` и проверяет `invoiceMin`/`invoiceMax` для валюты инвойса. Этот endpoint также показывает актуальные currency/payment-method ограничения конкретного merchant account. + +### Payload invoice + +Payload создания invoice содержит обязательные поля `type`, `purpose`, `currency`, `totalPrice` и `paymentCurrencies`. + +Дополнительно отправляются `clientOrderId`, `description`, `expiredAt`, `userPaysServiceFee` и `userPaysNetworkFee`. + +Redirect URLs в PayKilla не отправляются. Завершение платежа обрабатывается через webhook. + +### API key + +1. В PayKilla Dashboard откройте **Settings -> API keys**. +2. Создайте ключ типа **HMAC**. +3. Для приема оплат включите permission **INVOICE**. +4. Permission **WITHDRAWAL** не нужен для Minishop-платежей. +5. Сохраните `publicKey` в `PAYKILLA_API_KEY`. +6. Сохраните `secretKey` в `PAYKILLA_SECRET_KEY`. + +### Webhook + +1. В PayKilla Dashboard откройте **Settings -> Webhooks**. +2. Скопируйте URL вебхука из админ-панели и укажите его в PayKilla. +3. Включите минимальные события: `INVOICE_PAID`, `INVOICE_EXPIRED`. +4. Для production также включите `PAYMENT_COMPLETED`, `PAYMENT_FAILED`, `PAYMENT_OVERPAID`, `PAYMENT_UNDERPAID`, `PAYMENT_PARTIAL`, `COMPLIANCE_FAILED`. +5. Если нужны промежуточные статусы в логах, дополнительно включите `INVOICE_CREATED`, `PAYMENT_PENDING`, `TRANSACTION_CONFIRMED` и `TRANSACTION_FINAL`. +6. Оставьте `PAYKILLA_VERIFY_WEBHOOK_SIGNATURE=True`. + +### Настройка + +1. Включите `PAYKILLA_ENABLED`. +2. Укажите `PAYKILLA_API_KEY` и `PAYKILLA_SECRET_KEY`. +3. Оставьте `PAYKILLA_CURRENCY=USD`, если PayKilla не принимает валюту тарифов как invoice currency. В `PAYKILLA_INVOICE_CURRENCIES` укажите валюты, доступные в PayKilla для поля `currency`, например `USD,EUR`. +4. В `PAYKILLA_PAYMENT_CURRENCIES` оставьте `USDTTRC,BTC,ETH,USDTBSC,USDTTON` или укажите другой список тикеров, доступных в PayKilla Dashboard; `USDTTRC` должен идти первым. +5. Оставьте `PAYKILLA_MIN_PAYMENT_AMOUNT=10` и `PAYKILLA_MIN_PAYMENT_CURRENCY=USD`, если минимальный invoice PayKilla равен `10 USD`. +6. Убедитесь, что webhook `/webhook/paykilla` настроен в PayKilla: Minishop не отправляет redirect URLs в PayKilla и полагается на webhook для активации платежа. +7. Добавьте `paykilla` в `PAYMENT_METHODS_ORDER`, если хотите задать явный порядок кнопок. + +### Справочник + +- [PayKilla](../configuration/env-vars.md#paykilla) ## Telegram Stars Telegram Stars используются напрямую и поддерживаются в legacy-ценах и JSON-каталоге тарифов. -Где применяются Stars: +### Где используются -- цены периодов подписки; -- пакеты трафика; -- premium-докупки; +- Цены period-подписок. +- Пакеты трафика. +- Premium-докупки. - HWID-докупки, если они включены в каталоге тарифов. -Что проверить: +### Настройка -- `STARS_ENABLED`; -- Stars-цены в legacy-настройках или JSON-каталоге; -- корректное округление цены до целого количества Stars; -- сценарии смены тарифа: XTR/Stars-докупки не конвертируются без явного курса. +1. Включите `STARS_ENABLED`. +2. Проверьте Stars-цены в legacy-настройках или JSON-каталоге. +3. Убедитесь, что цена округляется до целого количества Stars. +4. Проверьте сценарии смены тарифа. -См. также [переменные платежей](../configuration/env-vars.md#платежи) и [тарифы](tariffs.md). +### Ограничения + +- Отдельный платежный webhook не нужен. +- Stars-события приходят через webhook Telegram-бота: `WEBHOOK_BASE_URL` + `/tg/webhook`. +- XTR/Stars-докупки не конвертируются без явно заданного курса. + +### Справочник + +- [Переменные платежей](../configuration/env-vars.md#платежи) +- [Тарифы](tariffs.md) diff --git a/docs/features/tariffs.md b/docs/features/tariffs.md index 6fcf5cb..4ed0aee 100644 --- a/docs/features/tariffs.md +++ b/docs/features/tariffs.md @@ -7,6 +7,8 @@ JSON-каталог может содержать несколько тарифов разных моделей: подписки на срок, пакеты трафика без срока действия, разные наборы Internal Squads, лимиты устройств и пакеты докупки. Пример формата: [data/tariffs.example.json](https://github.com/3252a8/remnawave-minishop/blob/main/data/tariffs.example.json). +Важно для Docker: `TARIFFS_CONFIG_PATH` обычно указывает на файл внутри `/app/data`, и этот каталог должен быть доступен `migrate`, `backend` и `worker`. Мигратор тоже читает каталог тарифов, когда привязывает существующие активные подписки без `tariff_key` к тарифу по умолчанию и заполняет тарифные лимиты. В штатных compose-файлах это уже настроено: корневой compose использует `shop-data:/app/data`, а production-примеры - `./data:/app/data` для всех трех сервисов. + Коротко по моделям: - `period` - подписка на срок с месячным лимитом трафика и опциональной докупкой GB поверх месячного лимита; @@ -28,6 +30,8 @@ JSON-каталог может содержать несколько тариф - настройка premium-раздела: названия RU/EN, premium Internal Squads, месячный premium-лимит и пакеты докупки premium-трафика в платежной валюте/Stars; - настройка базового HWID-лимита и пакетов докупки устройств. +Порядок продаваемых вариантов управляется в админке перетаскиванием строк: это работает для периодов покупки подписки, traffic-пакетов, обычных докупок трафика, premium-докупок и HWID-пакетов. Такой же порядок сохраняется в JSON и затем используется в Web App и Telegram-боте. + После сохранения изменения применяются к новым запросам Web App сразу, потому что конфиг тарифов загружается из JSON при обращении. Уже созданные подписки сохраняют свой `tariff_key`; при удалении или отключении тарифа проверьте, что активные подписки с этим ключом не требуют дальнейшего продления или смены. Подробности по админ-панели, правам доступа, сохранению настроек и списку разделов есть в [админ-панели](admin-panel.md). @@ -56,16 +60,16 @@ Legacy-поля остаются алиасами: `prices_rub`, `conversion_rat Платежные провайдеры не принимают произвольный код валюты одинаково. Бот фильтрует способы оплаты и блокирует создание платежа, если текущая валюта каталога не поддерживается провайдером: -| Провайдер | Валюты по умолчанию | -| --- | --- | -| YooKassa | `RUB` | -| WATA | `RUB`, `USD`, `EUR` | -| FreeKassa | `RUB`, `USD`, `EUR`, `UAH`, `KZT` | -| CryptoPay | fiat: `USD`, `EUR`, `RUB`, `BYN`, `UAH`, `GBP`, `CNY`, `KZT`, `UZS`, `GEL`, `TRY`, `AMD`, `THB`, `INR`, `BRL`, `IDR`, `AZN`, `AED`, `PLN`, `ILS`; crypto: `USDT`, `TON`, `BTC`, `ETH`, `LTC`, `BNB`, `TRX`, `USDC` | -| Heleket | настраиваемый список `HELEKET_SUPPORTED_CURRENCIES` | -| Platega | настраиваемый список `PLATEGA_SUPPORTED_CURRENCIES` | -| SeverPay | настраиваемый список `SEVERPAY_SUPPORTED_CURRENCIES` | -| Telegram Stars | `XTR`, отдельные Stars-цены | +| Провайдер | Валюты по умолчанию | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| YooKassa | `RUB` | +| WATA | `RUB`, `USD`, `EUR` | +| FreeKassa | `RUB`, `USD`, `EUR`, `UAH`, `KZT` | +| CryptoPay | fiat: `USD`, `EUR`, `RUB`, `BYN`, `UAH`, `GBP`, `CNY`, `KZT`, `UZS`, `GEL`, `TRY`, `AMD`, `THB`, `INR`, `BRL`, `IDR`, `AZN`, `AED`, `PLN`, `ILS`; crypto: `USDT`, `TON`, `BTC`, `ETH`, `LTC`, `BNB`, `TRX`, `USDC` | +| Heleket | настраиваемый список `HELEKET_SUPPORTED_CURRENCIES` | +| Platega | настраиваемый список `PLATEGA_SUPPORTED_CURRENCIES` | +| SeverPay | настраиваемый список `SEVERPAY_SUPPORTED_CURRENCIES` | +| Telegram Stars | `XTR`, отдельные Stars-цены | В админке раздел **Система → Тарифы** показывает текущую платежную валюту и матрицу провайдеров: включен ли метод, настроен ли сервис и будет ли он доступен при выбранной валюте. Для Platega, SeverPay и Heleket список валют нужно держать в соответствии с условиями вашего мерчанта. @@ -113,43 +117,43 @@ Legacy-поля остаются алиасами: `prices_rub`, `conversion_rat Основные поля: -| Поле | Назначение | -| --- | --- | -| `default_tariff` | Тариф по умолчанию для первичного выбора и привязки активных подписок без `tariff_key`. | -| `default_currency` | Валюта цен по умолчанию для JSON-каталога. По умолчанию `rub`; `stars` запрещен, потому что Stars используют отдельные цены. | -| `tariffs[].key` | Стабильный ключ тарифа. Используется в платежах, подписках и смене тарифа. | -| `tariffs[].names` | Названия тарифа по языкам. | -| `tariffs[].descriptions` | Описания тарифа по языкам. | -| `tariffs[].enabled` | Доступность тарифа на витрине. | -| `tariffs[].squad_uuids` | Internal Squads Remnawave для пользователей тарифа. | -| `tariffs[].premium_names` | Название premium-раздела по языкам. Используется в карточке лимита, модалке докупки premium-трафика и предупреждениях. Если поле не задано, используется `Premium-серверы` / `Premium servers`. | -| `tariffs[].premium_squad_uuids` | Internal Squads с отдельным premium-лимитом. Ноды для учета берутся автоматически из accessible nodes этих сквадов через API панели. | -| `tariffs[].premium_monthly_gb` | Отдельный месячный лимит трафика по premium-сквадам. `0` или отсутствие поля отключает отдельное ограничение. | -| `tariffs[].premium_topup_packages` | Пакеты докупки premium-трафика в формате `{ "rub": [{ "gb": 10, "price": 99 }], "stars": [...] }`. Требуют `premium_squad_uuids`. | -| `tariffs[].billing_model` | Модель тарифа: `period` или `traffic`. | -| `tariffs[].hwid_device_limit` | Базовый лимит HWID-устройств. `0` означает безлимит, отсутствие поля использует `USER_HWID_DEVICE_LIMIT`. | -| `tariffs[].hwid_device_packages` | Пакеты докупки устройств. `price` — legacy/monthly fallback, `prices` задаёт полную цену пакета для периодов тарифа (`"1"`, `"3"`, `"6"`, `"12"`), `min_price` задаёт минимальную цену prorate-докупки. | +| Поле | Назначение | +| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `default_tariff` | Тариф по умолчанию для первичного выбора и привязки активных подписок без `tariff_key`. | +| `default_currency` | Валюта цен по умолчанию для JSON-каталога. По умолчанию `rub`; `stars` запрещен, потому что Stars используют отдельные цены. | +| `tariffs[].key` | Стабильный ключ тарифа. Используется в платежах, подписках и смене тарифа. | +| `tariffs[].names` | Названия тарифа по языкам. | +| `tariffs[].descriptions` | Описания тарифа по языкам. | +| `tariffs[].enabled` | Доступность тарифа на витрине. | +| `tariffs[].squad_uuids` | Internal Squads Remnawave для пользователей тарифа. | +| `tariffs[].premium_names` | Название premium-раздела по языкам. Используется в карточке лимита, модалке докупки premium-трафика и предупреждениях. Если поле не задано, используется `Premium-серверы` / `Premium servers`. | +| `tariffs[].premium_squad_uuids` | Internal Squads с отдельным premium-лимитом. Ноды для учета берутся автоматически из accessible nodes этих сквадов через API панели. | +| `tariffs[].premium_monthly_gb` | Отдельный месячный лимит трафика по premium-сквадам. `0` или отсутствие поля отключает отдельное ограничение. | +| `tariffs[].premium_topup_packages` | Пакеты докупки premium-трафика в формате `{ "rub": [{ "gb": 10, "price": 99 }], "stars": [...] }`. Требуют `premium_squad_uuids`. Порядок строк задает порядок premium-докупок на витрине и меняется drag&drop в админке. | +| `tariffs[].billing_model` | Модель тарифа: `period` или `traffic`. | +| `tariffs[].hwid_device_limit` | Базовый лимит HWID-устройств. `0` означает безлимит, отсутствие поля использует `USER_HWID_DEVICE_LIMIT`. | +| `tariffs[].hwid_device_packages` | Пакеты докупки устройств. `price` — legacy/monthly fallback, `prices` задаёт полную цену пакета для периодов тарифа (`"1"`, `"3"`, `"6"`, `"12"`), `min_price` задаёт минимальную цену prorate-докупки. Порядок строк задает порядок HWID-докупок и меняется drag&drop в админке. | Для `period`-тарифа также используются: -| Поле | Назначение | -| --- | --- | -| `monthly_gb` | Базовый месячный лимит трафика тарифа. `0` означает безлимит. | -| `prices` | Generic-цены периодов по валютам, например `{ "usd": { "1": 4.99 } }`. | -| `prices_rub` | Legacy-цены периодов в рублях, ключ - количество месяцев. Эквивалент `prices.rub`. | -| `prices_stars` | Цены периодов в Telegram Stars. | -| `referral_bonus_days_inviter` | Бонус пригласившему в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. | -| `referral_bonus_days_referee` | Бонус приглашенному в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. | -| `enabled_periods` | Периоды, доступные для покупки. | -| `topup_packages` | Пакеты докупки трафика именно для этого тарифа. Если поле не задано или списки пустые, докупка для тарифа не показывается в Web App и Telegram-боте. | +| Поле | Назначение | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `monthly_gb` | Базовый месячный лимит трафика тарифа. `0` означает безлимит. | +| `prices` | Generic-цены периодов по валютам, например `{ "usd": { "1": 4.99 } }`. | +| `prices_rub` | Legacy-цены периодов в рублях, ключ - количество месяцев. Эквивалент `prices.rub`. | +| `prices_stars` | Цены периодов в Telegram Stars. | +| `referral_bonus_days_inviter` | Бонус пригласившему в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. | +| `referral_bonus_days_referee` | Бонус приглашенному в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. | +| `enabled_periods` | Периоды, доступные для покупки. Порядок элементов в массиве задаёт порядок периодов на витрине (в Telegram-боте и Web App) — отсортируйте их так, как нужно показывать. В веб-админке этот порядок меняется перетаскиванием строк периодов. | +| `topup_packages` | Пакеты докупки трафика именно для этого тарифа. Если поле не задано или списки пустые, докупка для тарифа не показывается в Web App и Telegram-боте. Порядок строк задает порядок докупок на витрине и меняется drag&drop в админке. | Для `traffic`-тарифа используются: -| Поле | Назначение | -| --- | --- | -| `traffic_packages` | Пакеты трафика в GB по валютам каталога и Telegram Stars. | -| `conversion_rate_per_gb` | Курс для конвертации оставшихся дней period-тарифа в GB при смене на traffic-тариф в валюте каталога. | -| `conversion_rate_rub_per_gb` | Legacy-алиас для рублевых каталогов. | +| Поле | Назначение | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `traffic_packages` | Пакеты трафика в GB по валютам каталога и Telegram Stars. Порядок пакетов в списке задаёт порядок на витрине (в Telegram-боте и Web App): сначала идут пакеты валюты каталога, затем пакеты, доступные только за Stars. В веб-админке порядок меняется перетаскиванием строк. | +| `conversion_rate_per_gb` | Курс для конвертации оставшихся дней period-тарифа в GB при смене на traffic-тариф в валюте каталога. | +| `conversion_rate_rub_per_gb` | Legacy-алиас для рублевых каталогов. | Если у traffic-тарифа нет пакетов в `default_currency`, `conversion_rate_per_gb` обязателен. @@ -278,7 +282,10 @@ limit_after = current_used + balance_after - полная цена HWID-пакета берется из `prices[duration_months]`; если периода нет, используется fallback `price * duration_months`; - фактическая цена докупки считается пропорционально оплачиваемому окну `valid_from -> valid_until` относительно периода подписки и фиксируется в платежe; - для Telegram Stars цена округляется вверх до целого Stars, для платежной валюты — вверх до копеек; `min_price` защищает от микроплатежей в конце периода; -- при продлении подписки докупленные устройства не продлеваются автоматически: старая докупка действует до прежнего `end_date`, а для нового срока создается отдельная `hwid_devices_renewal`-покупка; +- кнопка докупки устройств всегда покупает устройства только для текущей активной подписки и только до текущего срока ее действия; +- при продлении подписки пользователь видит отдельный чекбокс продления действующих докупленных устройств; чекбокс включен по умолчанию, цена считается по текущему тарифу и добавляется в тот же платеж подписки; +- если пользователь продлил подписку без продления устройств, старая докупка продолжает действовать до своего `valid_until`, а Web App показывает предупреждение о возможном временном возврате к базовому лимиту; +- админские продления, промокоды и реферальные бонусы добавляют фиксированное количество дней отдельно к подписке и к действующим докупкам устройств, не склеивая даты окончания; - `traffic`-тарифы не показывают и не принимают докупку HWID-устройств, потому что у них нет срока подписки; - при смене тарифа базовый лимит берется из целевого тарифа, а неиспользованная стоимость HWID-докупок в платежной валюте конвертируется в дни нового period-тарифа или GB traffic-тарифа; XTR/Stars-докупки не конвертируются без явного курса и продолжают жить по своему `valid_until`; - история докупок пишется в `hwid_device_purchases`; @@ -292,12 +299,12 @@ limit_after = current_used + balance_after Варианты расчета: -| Переход | Поведение | -| --- | --- | -| `period -> period` | Остаток оплаченных дней оценивается по legacy-полю `effective_monthly_price_rub`, где хранится месячная цена в платежной валюте каталога, затем пересчитывается в дни целевого тарифа через месячную цену целевого тарифа. Неиспользованная стоимость HWID-докупок в платежной валюте добавляется к этому расчету как дополнительные дни. Количество дней округляется вниз. | -| `period -> period` с доплатой | Если целевой тариф дороже, может быть создан платеж `tariff_upgrade`; неиспользованная стоимость HWID-докупок в платежной валюте уменьшает сумму доплаты. После оплаты применяется целевой тариф, а конвертированные HWID-окна закрываются. | -| `period -> traffic` | Остаток оплаченных дней и неиспользованная стоимость HWID-докупок в платежной валюте конвертируются в GB по `conversion_rate_per_gb` или минимальной цене GB из пакетов целевого тарифа. | -| `traffic -> period` | Пользователь выбирает и оплачивает период целевого тарифа; остаток GB сохраняется как `topup_balance_bytes` поверх лимита period-тарифа. | +| Переход | Поведение | +| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `period -> period` | Остаток оплаченных дней оценивается по legacy-полю `effective_monthly_price_rub`, где хранится месячная цена в платежной валюте каталога, затем пересчитывается в дни целевого тарифа через месячную цену целевого тарифа. Неиспользованная стоимость HWID-докупок в платежной валюте добавляется к этому расчету как дополнительные дни. Количество дней округляется вниз. | +| `period -> period` с доплатой | Если целевой тариф дороже, может быть создан платеж `tariff_upgrade`; неиспользованная стоимость HWID-докупок в платежной валюте уменьшает сумму доплаты. После оплаты применяется целевой тариф, а конвертированные HWID-окна закрываются. | +| `period -> traffic` | Остаток оплаченных дней и неиспользованная стоимость HWID-докупок в платежной валюте конвертируются в GB по `conversion_rate_per_gb` или минимальной цене GB из пакетов целевого тарифа. | +| `traffic -> period` | Пользователь выбирает и оплачивает период целевого тарифа; остаток GB сохраняется как `topup_balance_bytes` поверх лимита period-тарифа. | При смене тарифа бот меняет: @@ -313,15 +320,15 @@ limit_after = current_used + balance_after В платежах используются поля: -| Поле | Назначение | -| --- | --- | -| `sale_mode` | Тип продажи: `subscription`, `traffic_package`, `topup`, `premium_topup`, `tariff_upgrade`, `hwid_devices`. | -| `tariff_key` | Ключ тарифа, к которому относится платеж. | -| `purchased_gb` | Купленный объем GB для traffic-пакетов и докупки трафика. | -| `purchased_hwid_devices` | Количество устройств при докупке HWID. | -| `hwid_valid_from`, `hwid_valid_until` | Зафиксированное окно действия HWID-докупки на момент создания платежа. | -| `hwid_pricing_period_months`, `hwid_proration_ratio`, `hwid_full_price` | Метаданные расчета цены HWID-докупки: период тарифа, коэффициент prorate и полная цена пакета для периода. | -| `subscription_duration_months` | Количество месяцев для подписки на срок; также используется платежными обработчиками как числовое поле покупки. | +| Поле | Назначение | +| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `sale_mode` | Тип продажи: `subscription`, `traffic_package`, `topup`, `premium_topup`, `tariff_upgrade`, `hwid_devices`. | +| `tariff_key` | Ключ тарифа, к которому относится платеж. | +| `purchased_gb` | Купленный объем GB для traffic-пакетов и докупки трафика. | +| `purchased_hwid_devices` | Количество устройств при докупке HWID. | +| `hwid_valid_from`, `hwid_valid_until` | Зафиксированное окно действия HWID-докупки на момент создания платежа. | +| `hwid_pricing_period_months`, `hwid_proration_ratio`, `hwid_full_price` | Метаданные расчета цены HWID-докупки: период тарифа, коэффициент prorate и полная цена пакета для периода. | +| `subscription_duration_months` | Количество месяцев для подписки на срок; также используется платежными обработчиками как числовое поле покупки. | В callback и metadata платежных провайдеров `sale_mode` может передаваться с суффиксом тарифа, например `subscription@standard` или `topup@standard`. При активации платежа тариф сохраняется отдельно в `tariff_key`. @@ -345,12 +352,14 @@ Remnawave ограничивает доступ при достижении `tra Автопродление через YooKassa применяется к подпискам на срок. Для режима продажи трафика без JSON-каталога автопродление пропускается. Для traffic-тарифов JSON-каталога покупка является пакетом трафика, а не периодической подпиской. -Пробный период использует настройки `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB`, `TRIAL_TRAFFIC_STRATEGY` и `TRIAL_SQUAD_UUIDS`. Он не выбирает тариф из JSON-каталога, но его можно настроить на странице **Система → Тарифы** рядом с каталогом продаж. Если `TRIAL_SQUAD_UUIDS` пустой, для trial применяются squads из `USER_SQUAD_UUIDS`. +Пробный период использует настройки `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB`, `TRIAL_TRAFFIC_STRATEGY` и `TRIAL_SQUAD_UUIDS`. Он не выбирает тариф из JSON-каталога, но его можно настроить на странице **Система → Тарифы** рядом с каталогом продаж. Если `TRIAL_SQUAD_UUIDS` пустой, для trial применяются squads из `USER_SQUAD_UUIDS`. Переключатель `TRIAL_WITHOUT_TELEGRAM_ENABLED` управляет активацией trial для аккаунтов без Telegram, а домены из `DISPOSABLE_EMAIL_DOMAINS` требуют привязки Telegram независимо от этого переключателя. Промокоды с бонусными днями применяются к покупке period-подписки. Реферальные бонусы за оплату в JSON-каталоге задаются прямо в period-тарифе рядом с ценами периода: `referral_bonus_days_inviter` для пригласившего и `referral_bonus_days_referee` для приглашенного. Ключи этих словарей - месяцы периода (`"1"`, `"3"`, `"6"`, `"12"` или любые другие периоды тарифа, например `"2"`, `"4"`, `"8"`, `"16"`). Для `traffic`-тарифов такие бонусы не применяются. +Приветственный бонус приглашённому (`REFERRAL_WELCOME_BONUS_DAYS`) настраивается в отдельном блоке **Реферальная программа** на странице тарифов. `REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED` разрешает или запрещает выдачу этого бонуса аккаунтам без Telegram; disposable email домены из `DISPOSABLE_EMAIL_DOMAINS` всегда требуют Telegram перед начислением. + Если приглашенный покупает один тариф, а пригласивший находится на другом, размер бонуса берется из тарифа и периода, который купил приглашенный. При этом подписка пригласившего только продлевается на бонусные дни: лимиты, Internal Squads и другие параметры его текущего тарифа не пересчитываются под тариф приглашенного. В Web App и Telegram-меню подробные строки по периодам показываются только для legacy-режима или когда активен один period-тариф. Если включено несколько period-тарифов, Web App показывает сообщение, что бонус зависит от тарифа и периода оплаты друга, затем список тарифов с диапазонами "от N до N дней" и раскрытием подробностей по иконке вопроса. Telegram-меню в этом случае показывает только диапазоны по каждому тарифу. diff --git a/docs/features/web-app.md b/docs/features/web-app.md index a92408e..bdfb761 100644 --- a/docs/features/web-app.md +++ b/docs/features/web-app.md @@ -67,7 +67,7 @@ SUPPORT_TICKET_RATE_LIMIT_PER_HOUR=5 Если `WEBAPP_ENABLED=False`, пользовательское веб-приложение и админ-панель не регистрируются. Чтобы снова попасть в админку, включите `WEBAPP_ENABLED=True` в `.env` и перезапустите backend/frontend контейнеры. -Внешний вид настраивается в админке: раздел **Внешний вид** управляет логотипом, emoji-логотипом, accent-цветом, выбранной темой и масштабом логотипа. Кастомные темы читаются из `WEBAPP_THEMES_DIR`, а `WEBAPP_DEFAULT_THEME` может принудительно выбрать тему по ключу. Подробный контракт `theme.json`, CSS/asset-роуты и пайплайн создания темы описаны в [webapp-themes.md](webapp-themes.md). +Внешний вид настраивается в админке: раздел **Внешний вид** управляет логотипом, favicon, accent-цветом, выбранной темой и отдельным масштабом логотипа для desktop/mobile layout. Кастомные темы читаются из `WEBAPP_THEMES_DIR`, а `WEBAPP_DEFAULT_THEME` может принудительно выбрать тему по ключу. Подробный контракт `theme.json`, CSS/asset-роуты и пайплайн создания темы описаны в [webapp-themes.md](webapp-themes.md). ## Авторизация diff --git a/docs/features/webapp-themes.md b/docs/features/webapp-themes.md index 4ee81c9..7357d65 100644 --- a/docs/features/webapp-themes.md +++ b/docs/features/webapp-themes.md @@ -13,7 +13,6 @@ Web App поддерживает файловые темы, предпросмо - включить или выключить применение темы в админ-панели; - настроить масштаб логотипа на главной и экране входа; - загрузить логотип файлом или по HTTPS-ссылке; -- включить emoji-логотип и выбрать способ его отрисовки; - открыть предпросмотр темы через `/home?theme_preview=`. Через файлы темы можно менять намного больше: @@ -51,7 +50,9 @@ WEBAPP_DEFAULT_THEME= В compose-примерах `data/themes` - это локальная папка рядом с выбранным `docker-compose.yml`; она монтируется в контейнер как `/app/data/themes`. Правки в `backend/bot/app/web/themes` попадают в прод только при сборке собственного образа; опубликованный образ их не видит. -Важно: `WEBAPP_PRIMARY_COLOR`, `WEBAPP_LOGO_URL`, `WEBAPP_LOGO_USE_EMOJI`, `WEBAPP_LOGO_EMOJI` и `WEBAPP_LOGO_EMOJI_FONT` больше не являются рабочим способом первичной настройки через `.env`. Эти значения редактируются в админке и сохраняются как overrides в базе. Тема при этом может использовать сохраненный primary color как fallback accent. +Важно: `WEBAPP_PRIMARY_COLOR` и `WEBAPP_LOGO_URL` больше не являются рабочим способом первичной настройки через `.env`. Эти значения редактируются в админке и сохраняются как overrides в базе. Тема при этом может использовать сохраненный primary color как fallback accent. + +Email-шаблоны берут тот же бренд из настроек внешнего вида. Загруженный логотип добавляется в письма как inline image (`cid:webapp-logo`), а публичный HTTPS-логотип остается внешней картинкой, которую почтовый клиент может скрыть до разрешения загрузки изображений. ## Контракт `theme.json` @@ -114,7 +115,8 @@ WEBAPP_DEFAULT_THEME= "font_sans": "Inter, system-ui, sans-serif", "font_logo": "Inter, system-ui, sans-serif", "font_mono": "\"JetBrains Mono\", \"Fira Code\", monospace", - "home_logo_scale": 120, + "home_logo_scale_desktop": 120, + "home_logo_scale_mobile": 95, "admin_bg": "#05040a", "admin_surface": "#11101c", "admin_surface_2": "#090815", @@ -130,52 +132,54 @@ WEBAPP_DEFAULT_THEME= Поля верхнего уровня: -| Поле | Назначение | -| --- | --- | -| `key` | Уникальный ключ темы, 1-64 символа: латиница, цифры, `_` и `-`. Если ключ не указан, берется имя папки. | -| `names` | Локализованные названия, например `ru` и `en`. | -| `enabled` | Показывать тему пользователям. Отключенная тема не попадает в публичный каталог. | -| `default` | Делает тему выбранной по умолчанию, если `WEBAPP_DEFAULT_THEME` не задан. | +| Поле | Назначение | +| -------------------- | ------------------------------------------------------------------------------------------------------- | +| `key` | Уникальный ключ темы, 1-64 символа: латиница, цифры, `_` и `-`. Если ключ не указан, берется имя папки. | +| `names` | Локализованные названия, например `ru` и `en`. | +| `enabled` | Показывать тему пользователям. Отключенная тема не попадает в публичный каталог. | +| `default` | Делает тему выбранной по умолчанию, если `WEBAPP_DEFAULT_THEME` не задан. | | `use_primary_accent` | Если `true`, тема может получить accent из настройки внешнего вида, когда в `tokens.accent` ничего нет. | -| `use_in_admin` | Если `false`, пользовательская часть использует тему, но админка откатывается на `dark`. | -| `css_file` | CSS-файл внутри папки темы. Может быть `style.css` или вложенный путь вроде `css/theme.css`. | -| `assets_version` | Версия ассетов. Для встроенных тем используется для обновления старых файлов в `data/themes`. | -| `tokens` | Дизайн-токены, которые превращаются в CSS-переменные на `.app-shell`. | +| `use_in_admin` | Если `false`, пользовательская часть использует тему, но админка откатывается на `dark`. | +| `css_file` | CSS-файл внутри папки темы. Может быть `style.css` или вложенный путь вроде `css/theme.css`. | +| `assets_version` | Версия ассетов. Для встроенных тем используется для обновления старых файлов в `data/themes`. | +| `tokens` | Дизайн-токены, которые превращаются в CSS-переменные на `.app-shell`. | ## Токены Поддерживаемые токены: -| Токен | CSS-переменная | Что меняет | -| --- | --- | --- | -| `color_scheme` | `color-scheme` | Нативная светлая/темная схема браузера: `dark` или `light`. | -| `style_preset` | CSS-класс пресета | Сейчас `win95`/`windows95` добавляет `theme-preset-win95`; остальные значения не дают специального класса. | -| `accent` | `--accent` | Главный акцент: активные элементы, кнопки, прогресс, фокус. Только hex `#RGB` или `#RRGGBB`. | -| `bg` | `--bg` | Основной фон приложения. | -| `panel` | `--panel` | Основные карточки и поверхности. | -| `panel_2` | `--panel-2` | Вторичные поверхности. | -| `panel_3` | `--panel-3` | Поверхности повышенной вложенности, dropdown/popover. | -| `border` | `--border` | Обычные границы. | -| `border_strong` | `--border-strong` | Усиленные границы и hover-состояния. | -| `text` | `--text` | Основной текст. | -| `muted` | `--muted` | Вторичный текст. | -| `dim` | `--dim` | Еще более тихий текст и служебные подписи. | -| `danger` | `--danger` | Ошибки и опасные действия. | -| `blue` | `--blue` | Синий вспомогательный цвет. | -| `radius` | `--radius` | Базовый радиус карточек, кнопок и контролов. | -| `font_sans` | `--font-sans` | Основной шрифт интерфейса. | -| `font_logo` | `--font-logo` | Шрифт бренда и заголовка. | -| `font_mono` | `--font-mono` | Моноширинный шрифт. | -| `home_logo_scale` | `--home-logo-scale` | Масштаб логотипа на главной и входе, от `50` до `300` процентов. | -| `admin_bg` | `--admin-bg` | Фон админ-панели. | -| `admin_surface` | `--admin-surface` | Основные карточки админки. | -| `admin_surface_2` | `--admin-surface-2` | Вторичные поверхности админки. | -| `admin_elev` | `--admin-elev` | Elevated-поверхности админки. | -| `admin_border` | `--admin-border` | Границы админки. | -| `admin_border_strong` | `--admin-border-strong` | Усиленные границы админки. | -| `admin_text` | `--admin-text` | Основной текст админки. | -| `admin_muted` | `--admin-muted` | Вторичный текст админки. | -| `admin_dim` | `--admin-dim` | Тихие подписи админки. | +| Токен | CSS-переменная | Что меняет | +| ------------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `color_scheme` | `color-scheme` | Нативная светлая/темная схема браузера: `dark` или `light`. | +| `style_preset` | CSS-класс пресета | Сейчас `win95`/`windows95` добавляет `theme-preset-win95`; остальные значения не дают специального класса. | +| `accent` | `--accent` | Главный акцент: активные элементы, кнопки, прогресс, фокус. Только hex `#RGB` или `#RRGGBB`. | +| `bg` | `--bg` | Основной фон приложения. | +| `panel` | `--panel` | Основные карточки и поверхности. | +| `panel_2` | `--panel-2` | Вторичные поверхности. | +| `panel_3` | `--panel-3` | Поверхности повышенной вложенности, dropdown/popover. | +| `border` | `--border` | Обычные границы. | +| `border_strong` | `--border-strong` | Усиленные границы и hover-состояния. | +| `text` | `--text` | Основной текст. | +| `muted` | `--muted` | Вторичный текст. | +| `dim` | `--dim` | Еще более тихий текст и служебные подписи. | +| `danger` | `--danger` | Ошибки и опасные действия. | +| `blue` | `--blue` | Синий вспомогательный цвет. | +| `radius` | `--radius` | Базовый радиус карточек, кнопок и контролов. | +| `font_sans` | `--font-sans` | Основной шрифт интерфейса. | +| `font_logo` | `--font-logo` | Шрифт бренда и заголовка. | +| `font_mono` | `--font-mono` | Моноширинный шрифт. | +| `home_logo_scale_desktop` | `--home-logo-scale-desktop` | Масштаб логотипа на desktop layout, от `50` до `300` процентов. | +| `home_logo_scale_mobile` | `--home-logo-scale-mobile` | Масштаб логотипа на mobile layout, от `50` до `300` процентов. | +| `home_logo_scale` | `--home-logo-scale` | Legacy fallback для старых тем; используется, если desktop/mobile token не задан. | +| `admin_bg` | `--admin-bg` | Фон админ-панели. | +| `admin_surface` | `--admin-surface` | Основные карточки админки. | +| `admin_surface_2` | `--admin-surface-2` | Вторичные поверхности админки. | +| `admin_elev` | `--admin-elev` | Elevated-поверхности админки. | +| `admin_border` | `--admin-border` | Границы админки. | +| `admin_border_strong` | `--admin-border-strong` | Усиленные границы админки. | +| `admin_text` | `--admin-text` | Основной текст админки. | +| `admin_muted` | `--admin-muted` | Вторичный текст админки. | +| `admin_dim` | `--admin-dim` | Тихие подписи админки. | Если `css_file` не задан, интерфейс полностью строится на токенах и общих стилях. Если `css_file` задан, токены все равно применяются первыми, а CSS темы может уточнить или полностью переопределить внешний вид. @@ -254,7 +258,8 @@ CSS можно писать для пользовательской части content: ""; width: 16px; height: 16px; - background: url("/webapp-theme-assets/neon/icons/spark.png") center / contain no-repeat; + background: url("/webapp-theme-assets/neon/icons/spark.png") center / contain + no-repeat; } ``` @@ -302,7 +307,7 @@ CSS можно писать для пользовательской части 7. Подберите accent и масштаб логотипа. - В админке можно менять accent и `home_logo_scale` без ручного редактирования JSON. При сохранении backend перепишет `theme.json` в `WEBAPP_THEMES_DIR`, выставит ровно один `default` и сбросит кеш публичных настроек. + В админке можно менять accent, `home_logo_scale_desktop` и `home_logo_scale_mobile` без ручного редактирования JSON. При сохранении backend перепишет `theme.json` в `WEBAPP_THEMES_DIR`, выставит ровно один `default` и сбросит кеш публичных настроек. Старый `home_logo_scale` сохраняется как fallback для уже существующих тем. 8. Добавьте `style.css`, если токенов мало. @@ -329,7 +334,6 @@ CSS можно писать для пользовательской части 11. Сделайте тему дефолтной. Есть два способа: - - в админке выбрать тему и сохранить; - указать `WEBAPP_DEFAULT_THEME=neon` в `.env`, если нужен жесткий override на уровне окружения. diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index 7941128..5165fc2 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -84,13 +84,16 @@ openssl rand -hex 32 В compose-примерах данные монтируются из локальной папки `./data` рядом с выбранным `docker-compose.yml`. Внутри нее лежат тарифы, темы, логотипы и прочие файловые данные приложения. +Папка `./data` монтируется в `/app/data` для `migrate`, `backend` и `worker`. Если вы меняете compose-файл вручную, не убирайте этот mount у `migrate`: иначе `docker compose run --rm migrate` не увидит `data/tariffs.json` из `TARIFFS_CONFIG_PATH`. + Перед первым запуском создайте каталоги и отдайте их пользователю контейнера: ```bash -mkdir -p data/themes data/webapp-logo data/webapp-emoji data/tariffs +mkdir -p data/themes data/webapp-logo data/tariffs touch data/locales-overrides.json chown -R 10001:10001 data chmod -R u+rwX data +docker compose run --rm migrate docker compose up -d --force-recreate backend worker ``` diff --git a/docs/getting-started/demo.md b/docs/getting-started/demo.md index c3e6caa..1da0a6c 100644 --- a/docs/getting-started/demo.md +++ b/docs/getting-started/demo.md @@ -11,6 +11,8 @@ - [Админка: пользователи](/demo/admin/users) - [Админка: бэкапы](/demo/admin/backups) - [Пробный период](/demo/home?mock=trial) +- [Email-only: нужен Telegram для триала](/demo/home?mock=trial-telegram) +- [Email-only: нужен Telegram для реферального бонуса](/demo/home?mock=referral-telegram) - [Докупка устройств](/demo/devices?mock=devices) - [Запуск бота для Telegram-уведомлений](/demo/home?mock=notifications) - [Вход и регистрация](/demo/login?mock=auth) diff --git a/docs/getting-started/deployment.md b/docs/getting-started/deployment.md index cee2da1..de41920 100644 --- a/docs/getting-started/deployment.md +++ b/docs/getting-started/deployment.md @@ -13,6 +13,51 @@ docker compose ps docker compose logs -f backend worker frontend ``` +## Интерактивный install wizard + +Для нового сервера скачайте install-скрипт и запустите его: + +```bash +curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/install.sh -o install.sh +sh install.sh +``` + +Та же ссылка на install-скрипт в GitLab: + +```bash +curl -fsSL https://gitlab.com/3252a8/remnawave-minishop/-/raw/main/scripts/install.sh -o install.sh +sh install.sh +``` + +Wizard работает через меню с цифрами и подтверждениями `y/n`. Он умеет: + +- скачать выбранный compose-профиль (`Caddy`, `Nginx`, `Pangolin/Newt` или `no-proxy`); +- сгенерировать минимальный `.env`, включая пароли и стабильные secrets; +- сохранить backup существующих файлов перед перезаписью; +- подготовить writable `data/` для файлов приложения; +- запустить `docker compose pull && docker compose up -d`; +- проверить текущий стек через `docker compose ps` и логи `migrate`; +- запустить миграцию из поддерживаемых ботов: Remnashop и старый + `remnawave-tg-shop`; + +Для тестирования другой ветки или форка задайте источник перед запуском: + +```bash +MINISHOP_INSTALL_REPO=3252a8/remnawave-minishop \ +MINISHOP_INSTALL_REF=main \ +sh install.sh +``` + +Миграция Remnashop в wizard сначала запускает `dry-run`, показывает JSON-сводку +и только после отдельного подтверждения применяет изменения в целевую БД. Если +указать старый Remnashop `.env`, wizard передаст importer-у `APP_CRYPT_KEY`, +Remnawave API settings и поддерживаемые payment provider settings из таблицы +`payment_gateways`. После применения wizard печатает новые webhook URL для +Remnawave Panel и платежных провайдеров. +Миграция со старого `remnawave-tg-shop` работает как upgrade совместимой БД: +либо копирует старый Docker volume, либо делает `pg_dump` по source DSN, +восстанавливает дамп в целевую compose-БД и запускает сервис `migrate`. + Обычный `docker compose up -d --build` поднимает: - `postgres` и `redis` с проверками здоровья; @@ -158,7 +203,7 @@ curl http://127.0.0.1:8082/health docker compose logs -f backend worker frontend ``` -Корневой `docker-compose.yml` оставлен для локальной сборки из исходников. Примеры в `deploy/examples` используют готовые GHCR-образы и не требуют указывать `-f`. +Корневой `docker-compose.yml` оставлен для локальной сборки из исходников. Примеры в `deploy/examples` используют готовые Docker Hub-образы и не требуют указывать `-f`. ## Миграции @@ -272,7 +317,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\docker-build-push-images.ps1 Если нужен только один registry или другой namespace, переопределите переменные: ```bash -IMAGE_REGISTRIES=ghcr.io IMAGE_TAG=v3.4.3 bash scripts/docker-build-push-images.sh +IMAGE_REGISTRIES=docker.io IMAGE_TAG=v3.4.3 bash scripts/docker-build-push-images.sh IMAGE_REGISTRIES="ghcr.io docker.io" IMAGE_NAMESPACE=other IMAGE_TAG=v3.4.3 bash scripts/docker-build-push-images.sh ``` @@ -336,13 +381,16 @@ distributed lock; код подготовлен к нескольким репл Файлы приложения монтируются из локальной папки `./data` рядом с выбранным `docker-compose.yml` в `/app/data`; внутри нее лежат тарифы, темы, логотипы и прочие файловые данные приложения. +Тот же `/app/data` должен быть смонтирован в `migrate`, `backend` и `worker`. Это важно для `data/tariffs.json`: `docker compose run --rm migrate` читает тот же каталог тарифов, что и приложение. В текущих compose-файлах этот mount уже есть у всех трех сервисов. + Перед первым запуском на сервере заранее дайте права пользователю контейнера `10001`: ```bash -mkdir -p data/themes data/webapp-logo data/webapp-emoji data/tariffs +mkdir -p data/themes data/webapp-logo data/tariffs touch data/locales-overrides.json chown -R 10001:10001 data chmod -R u+rwX data +docker compose run --rm migrate docker compose up -d --force-recreate backend worker ``` @@ -385,6 +433,16 @@ docker compose up -d backend worker - Mini App/frontend-домен целиком идет в `frontend:80`; - API/auth/theme routes Mini App дальше проксируются frontend nginx в `backend:8081`. +Для платежных провайдеров с IP allowlist важно, чтобы reverse proxy передавал реальный IP +отправителя в `X-Forwarded-For`, а backend доверял IP последнего proxy-hop через +`TRUSTED_PROXIES`. Готовые профили `caddy`, `nginx` и `newt` уже доверяют loopback и +private ranges (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `fc00::/7`), чтобы +Docker/LAN/Kubernetes proxy не ломал проверки `YOOKASSA`, `FREEKASSA_TRUSTED_IPS`, +`WATA_TRUSTED_IPS`, `HELEKET_TRUSTED_IPS` и `PAYKILLA_TRUSTED_IPS`. Если в вашей +Docker-сети есть недоверенные контейнеры, сузьте `TRUSTED_PROXIES` до конкретного IP +Caddy/Nginx/Newt. Trust-all режим возможен через `0.0.0.0/0,::/0`, но используйте его +только когда backend недоступен напрямую, а внешний proxy очищает входящий `X-Forwarded-For`. + Минимальная логика Caddy: ```caddyfile diff --git a/docs/migrations/index.md b/docs/migrations/index.md index 501a37a..42d2a74 100644 --- a/docs/migrations/index.md +++ b/docs/migrations/index.md @@ -5,3 +5,4 @@ | Источник | Поддерживаемый случай | Документы | | --- | --- | --- | | [remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop/) | Полный перенос всех данных | [Инструкция](remnawave-tg-shop.md) | +| [Remnashop](https://github.com/snoups/remnashop/) | Автоматический импорт пользователей, подписок, платежей, рефералов, промокодов и поддерживаемых платежных настроек | [Инструкция](remnashop.md) | diff --git a/docs/migrations/remnashop.md b/docs/migrations/remnashop.md new file mode 100644 index 0000000..bd5f072 --- /dev/null +++ b/docs/migrations/remnashop.md @@ -0,0 +1,131 @@ +# Миграция из Remnashop + +Remnashop импортируется через общий скрипт импорта `backend/scripts/import_legacy.py`. +Самый удобный путь - интерактивный install wizard: + +```bash +curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/install.sh -o install.sh +sh install.sh +``` + +Та же ссылка на install-скрипт в GitLab: + +```bash +curl -fsSL https://gitlab.com/3252a8/remnawave-minishop/-/raw/main/scripts/install.sh -o install.sh +sh install.sh +``` + +В меню выберите `Install new stack and run migration` для нового сервера +или `Run migration only`, если compose-папка и `.env` уже готовы. + +## Что переносится + +- пользователи Telegram, username, email, Remnawave UUID и метаданные профиля; +- старые referral codes и связи рефералов; +- подписки, сроки, лимиты трафика, HWID/device limit и UUID подписок панели; +- платежи и статусы платежей; +- промокоды на дни подписки и их активации, если таблицы есть в source DB; +- служебные mappings, чтобы повторный запуск мог работать в режиме `merge`; +- настройки совместимости Remnashop в админке: старые ref-ссылки и promo codes. + +Данные, которые не имеют прямого аналога, сохраняются в служебных таблицах миграции или +message logs как заметки, чтобы администратор мог проверить их после переноса. + +## Настройки и платежные провайдеры + +Если указать старый Remnashop `.env`, importer дополнительно переносит часть +настроек в админские overrides: + +- `REMNAWAVE_HOST` -> `PANEL_API_URL`; +- `REMNAWAVE_TOKEN` -> `PANEL_API_KEY`; +- `REMNAWAVE_WEBHOOK_SECRET` -> `PANEL_WEBHOOK_SECRET`; +- `BOT_SUPPORT_USERNAME` -> `SUPPORT_LINK`; +- `APP_DEFAULT_LOCALE` -> `DEFAULT_LANGUAGE`. + +`BOT_MINI_APP` из Remnashop не переносится автоматически. В Remnashop эта +переменная управляет кнопкой подключения к subscription page или внешнему Mini +App, а не веб-кабинетом Remnashop. В Minishop `SUBSCRIPTION_MINI_APP_URL` +должен указывать на текущий frontend/Mini App этого стека; wizard настраивает +его из `WEBHOOK_HOST`/`MINIAPP_HOST` или `MINIAPP_PUBLIC_URL`. + +Значения-заглушки вроде `change_me` importer пропускает, чтобы случайно не +записать шаблонные секреты в рабочую конфигурацию. + +Платежные провайдеры берутся из таблицы Remnashop `payment_gateways`. +Поддерживаются и автоматически маппятся: Telegram Stars, YooKassa, WATA, +CryptoPay, Heleket, PayKilla, FreeKassa и Platega. Для них importer переносит флаги +включения, API-ключи/merchant IDs и прямые технические параметры, без которых +провайдер не сможет работать: YooKassa receipt email/VAT, FreeKassa second +secret/payment method/server IP и Platega payment method. + +Provider currency и supported-currency ограничения не переносятся автоматически: +в Minishop валюта платежа управляется тарифами и `DEFAULT_CURRENCY_SYMBOL`. +Если старый gateway Remnashop был настроен на нестандартную валюту, importer +оставит предупреждение в JSON-сводке; проверьте `CRYPTOPAY_ASSET`, +`HELEKET_CURRENCY`, `HELEKET_SUPPORTED_CURRENCIES`, `PAYKILLA_CURRENCY`, +`PAYKILLA_PAYMENT_CURRENCIES` или +`PLATEGA_SUPPORTED_CURRENCIES` вручную. + +Провайдеры YooMoney, Cryptomus, MulenPay, PayMaster, RoboKassa и UrlPay сейчас +не имеют прямого аналога в Minishop. Если они были в Remnashop, importer +оставит предупреждение в JSON-сводке и notes миграции, а настроить их нужно +вручную или через будущий отдельный provider. + +Remnashop может хранить секреты в формате `enc_...`. Для расшифровки нужен +старый `APP_CRYPT_KEY`; проще всего указать путь к старому `.env` в wizard или +передать `--source-env-file`. Если ключ не передан или неверный, зашифрованные +значения будут пропущены с предупреждением, остальные данные продолжат +импортироваться. + +После успешного применения wizard печатает список новых адресов webhook. Их +нужно указать во внешних сервисах вместо старых Remnashop URL: + +- Remnawave Panel -> `WEBHOOK_URL`: `WEBHOOK_BASE_URL` + `/webhook/panel`; +- YooKassa HTTP notifications URL: `WEBHOOK_BASE_URL` + `/webhook/yookassa`; +- WATA webhook/callback URL: `WEBHOOK_BASE_URL` + `/webhook/wata`; +- CryptoBot/Crypto Pay webhook URL: `WEBHOOK_BASE_URL` + `/webhook/cryptopay`; +- Heleket payment webhook/callback URL: `WEBHOOK_BASE_URL` + `/webhook/heleket`; +- PayKilla webhook URL: `WEBHOOK_BASE_URL` + `/webhook/paykilla`; +- FreeKassa notification/result URL: `WEBHOOK_BASE_URL` + `/webhook/freekassa`; +- Platega webhook URL: `WEBHOOK_BASE_URL` + `/webhook/platega`; +- Telegram webhook `WEBHOOK_BASE_URL` + `/tg/webhook` выставляется ботом + автоматически при старте. + +## Flow wizard + +1. Wizard скачивает compose-профиль и `backend/scripts/import_legacy.py` через + `raw.githubusercontent.com`, без клонирования репозитория. +2. Вы указываете source PostgreSQL DSN Remnashop и schema, обычно `public`. +3. Опционально указываете путь к старому Remnashop `.env` для `APP_CRYPT_KEY`, + Remnawave API settings и переносимых settings. +4. Вы выбираете целевую БД: текущую compose-БД или ручной target DSN. +5. При необходимости указываете JSON map тарифов Remnashop в локальные + `tariff_key`, например `{"basic": "standard_month"}`. +6. Wizard запускает `dry-run` и показывает JSON-сводку. +7. После подтверждения `y` importer применяет изменения, печатает список новых + webhook URL для Remnawave Panel и платежных провайдеров, затем перезапускает + `backend`/`worker`, чтобы настройки совместимости перечитались. + +Если source DB находится на том же Docker host, помните, что DSN выполняется +из backend-контейнера. Для подключения к сервису вне compose-сети может +понадобиться host name вроде `host.docker.internal`, внешний адрес сервера или +ручное подключение контейнеров к общей Docker network. + +## Ручной запуск + +Если нужно запустить importer без wizard: + +```bash +docker compose run --rm backend \ + python backend/scripts/import_legacy.py \ + --source-type remnashop \ + --source-dsn 'postgresql://old_user:old_password@old_host:5432/remnashop' \ + --source-schema public \ + --source-env-file /path/to/remnashop/.env \ + --dry-run +``` + +После успешного `dry-run` повторите команду без `--dry-run`. По умолчанию +режим конфликтов `merge`: существующие пользователи и платежи сопоставляются, +а новые записи добавляются. Для узкого импорта используйте `--only`, например +`--only users,referrals,promocodes`. diff --git a/docs/migrations/remnawave-tg-shop.md b/docs/migrations/remnawave-tg-shop.md index b7e16b0..ed4a908 100644 --- a/docs/migrations/remnawave-tg-shop.md +++ b/docs/migrations/remnawave-tg-shop.md @@ -1,90 +1,67 @@ -# Миграция с `remnawave-tg-shop` (≤ v2.7.0) на `remnawave-minishop` (v3.4+) +# Миграция с `remnawave-tg-shop` на `remnawave-minishop` -Эта страница - готовый сценарий для legacy-стека `remnawave-tg-shop`. Это единственная миграция с другого бота, которая сейчас описана в документации. Для других Telegram-ботов, самописных панелей и ручных таблиц готового сценария пока нет: их нельзя переносить по этой инструкции без отдельного анализа схемы БД, тарифов, платежей и связи с Remnawave Panel. - -Автоматический скрипт ниже рассчитан именно на родственный стек `remnawave-tg-shop`, где структура БД и Docker volumes известны заранее. Для других ботов нужен отдельный адаптер экспорта/импорта. - -## Короткий путь без смены ветки и сборки - -Если вы используете только готовые Docker-образы и не собираете проект -локально, git-команды из ручного способа не нужны. Достаточно обновить -compose-файл до одного из готовых примеров в `deploy/examples` и -перенести/обновить БД. Самый прямой вариант без встроенного обратного прокси - -`deploy/examples/no-proxy/docker-compose.yml`; для Caddy, Nginx и Newt есть -такие же самостоятельные папки. - -Минимальная последовательность: +Для переноса со старого родственного стека используйте общий install wizard: ```bash -docker compose down - -# Скопируйте старый .env в выбранную папку примера и обновите значения там. -cp .env deploy/examples/no-proxy/.env -nano deploy/examples/no-proxy/.env - -# Подготовьте стек из готовых образов. -IMAGE_TAG=3.4.0 docker compose \ - --env-file deploy/examples/no-proxy/.env \ - -f deploy/examples/no-proxy/docker-compose.yml \ - up --no-start - -# Нужно только при переходе со старого имени volume remnawave-tg-shop-db-data. -# Если у вас уже есть remnawave-minishop-db-data, этот шаг пропустите. -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" - -IMAGE_TAG=3.4.0 docker compose \ - --env-file deploy/examples/no-proxy/.env \ - -f deploy/examples/no-proxy/docker-compose.yml \ - up -d -docker compose \ - --env-file deploy/examples/no-proxy/.env \ - -f deploy/examples/no-proxy/docker-compose.yml \ - logs migrate +curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/install.sh -o install.sh +sh install.sh ``` -Сервис `migrate` сам применит недостающие схемные миграции к перенесённому -тому PostgreSQL. Новые тома `remnawave-minishop-redis-data` и -`remnawave-minishop-shop-data` переносить не нужно: они создаются пустыми. +Та же ссылка на install-скрипт в GitLab: -Этот документ описывает обновление стека, поднятого по `remnawave-tg-shop` -(включая последний релиз `v2.7.0` форка `kavore/remnawave-tg-shop`), до -текущей версии `remnawave-minishop` (v3.4+). Между этими версиями произошли -две независимые перетряски, и скрипт пытается отработать обе одной командой: +```bash +curl -fsSL https://gitlab.com/3252a8/remnawave-minishop/-/raw/main/scripts/install.sh -o install.sh +sh install.sh +``` -1. **Переименование стека** (v3.1.0): контейнеры и тома `remnawave-tg-shop-*` - стали `remnawave-minishop-*`. Простой `docker compose up -d` после - `git pull` создаёт пустую БД — без переноса тома данные теряются. -2. **Разделение бота на сервисы** (v3.4.0): из одного контейнера выделены - `backend`, `worker`, `frontend`, `migrate` + новые `postgres`, `redis`. - Появились новые volumes `redis-data` и `shop-data`, новые обязательные - переменные окружения, а схема БД обновляется автоматически one-shot - сервисом `migrate`. +В меню выберите `Install new stack and run migration` для нового +сервера или `Run migration only`, если compose-папка уже готова. Затем +выберите источник `Old remnawave-tg-shop`. -После миграции `docker compose ps` должен показать как минимум: `backend`, -`worker`, `frontend`, `postgres`, `redis` (running) и `migrate` (exited 0). -Логи: `docker compose logs -f backend worker frontend`. +Wizard поддерживает два способа переноса: -Доступные пути: +- `Copy old Docker volumes` - для старого compose-стека на том же Docker host. + Скрипт подготавливает новый stack, копирует + `remnawave-tg-shop-db-data` в `remnawave-minishop-db-data`, опционально + переносит Caddy volumes и запускает новый stack. +- `Dump from a source PostgreSQL DSN` - для старой БД, доступной по DSN. + Скрипт поднимает целевой `postgres`, сбрасывает целевую БД, делает + `pg_dump` из старой БД, восстанавливает дамп в compose-БД и запускает + сервис `migrate`. -- [Автоматический](#автоматический-способ-через-скрипт) — скрипт-обёртка - останавливает старый стек, накатывает свежий код, переносит том БД, - поднимает новые сервисы. Идемпотентный. -- [Ручной](#ручной-способ) — те же шаги командами, для тех, кому нужно - понимать каждое действие или выполнить выборочно. +В обоих режимах старые volumes и старая БД не удаляются автоматически. -В обоих случаях: +## Как работает перенос -- старые тома **не удаляются** автоматически — это безопасный бэкап на случай - отката; -- сертификаты Caddy (если используется `deploy/examples/caddy/docker-compose.yml`) - тоже переносятся, чтобы Let's Encrypt не выписывал их заново и не упереться - в rate limit; -- схема БД обновляется автоматически: при первом `docker compose up -d` сервис - `migrate` накатывает на перенесённый том все недостающие миграции (от - alembic-схемы v2.7.0 до текущей). +`remnawave-tg-shop` и `remnawave-minishop` имеют совместимую историю схемы. +После переноса старой PostgreSQL-БД сервис `migrate` накатывает недостающие +миграции из `backend/db/migrator.py`: сначала применяются `Base.metadata`, +затем последовательные записи `schema_migrations`. Это one-shot сервис: он +должен завершиться с кодом `0`, после чего стартуют `backend` и `worker`. + +При volume-миграции wizard: + +1. Останавливает известные контейнеры старого и переходного стеков, если вы + подтверждаете этот шаг. +2. Запускает `docker compose up --no-start`, чтобы Docker Compose создал новые + volumes. +3. Копирует старый volume БД: + + ```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" + ``` + +4. Если старые Caddy volumes существуют, переносит + `remnawave-tg-shop-caddy-data` -> `remnawave-minishop-caddy-data` и + `remnawave-tg-shop-caddy-config` -> `remnawave-minishop-caddy-config`. +5. Запускает новый stack через Docker Compose. + +Если целевой DB volume уже непустой, wizard не перетирает его молча: он +останавливается и просит отдельное подтверждение на продолжение без копирования +старой БД. ## Что меняется в архитектуре @@ -93,256 +70,55 @@ docker compose \ | Версия | Сервисы | | --- | --- | | `v2.7.0` | `remnawave-tg-shop`, `remnawave-tg-shop-db` | -| `v3.1.x–v3.3.x` | `remnawave-minishop`, `remnawave-minishop-db` | -| `v3.4+` (текущая) | `remnawave-minishop-backend`, `remnawave-minishop-worker`, `remnawave-minishop-frontend`, `remnawave-minishop-migrate`, `remnawave-minishop-postgres`, `remnawave-minishop-redis` | - -Внутри Docker-сети сервисы доступны по коротким DNS-именам (`backend`, `worker`, -`frontend`, `postgres`, `redis`), а не по полному `container_name`. Это важно -для внешнего reverse-proxy — см. раздел [Внешний reverse-proxy](#внешний-reverse-proxy) ниже. +| `v3.1.x-v3.3.x` | `remnawave-minishop`, `remnawave-minishop-db` | +| `v3.4+` | `remnawave-minishop-backend`, `remnawave-minishop-worker`, `remnawave-minishop-frontend`, `remnawave-minishop-migrate`, `remnawave-minishop-postgres`, `remnawave-minishop-redis` | **Volumes**: -| Volume | v2.7.0 | v3.4+ | Что внутри | -| --- | --- | --- | --- | -| `remnawave-minishop-db-data` | переименовать из `remnawave-tg-shop-db-data` | переносится скриптом | PostgreSQL | -| `remnawave-minishop-redis-data` | — | создаётся пустым | Redis (FSM, rate-limit, cache, очередь вебхуков, distributed locks) | -| `remnawave-minishop-shop-data` | — | создаётся пустым | `/app/data`: `tariffs.json`, темы Web App, кэш логотипа/emoji | -| `remnawave-minishop-caddy-data` / `remnawave-minishop-caddy-config` | переименовать из `remnawave-tg-shop-caddy-*` | переносится скриптом | только при Caddy-варианте | +| Volume | Что происходит | +| --- | --- | +| `remnawave-minishop-db-data` | переносится из `remnawave-tg-shop-db-data` или восстанавливается из source DSN | +| `remnawave-minishop-redis-data` | создается пустым | +| `remnawave-minishop-shop-data` | создается пустым; runtime-файлы в `/app/data` дальше настраиваются через админку или вручную | +| `remnawave-minishop-caddy-data` / `remnawave-minishop-caddy-config` | переносятся из `remnawave-tg-shop-caddy-*`, если старый стек использовал Caddy | -`redis-data` и `shop-data` стартуют пустыми — это нормально. Redis ничего -долгоживущего не хранит (всё либо FSM, либо кеш с TTL), а `data/` инициализируется -из образа при первом старте (`tariffs.json` пуст пока вы не сконфигурируете -тарифы через админ-панель). +Доступные compose-профили: `docker-compose.yml`, +`deploy/examples/caddy/docker-compose.yml`, +`deploy/examples/nginx/docker-compose.yml`, +`deploy/examples/newt/docker-compose.yml`, +`deploy/examples/no-proxy/docker-compose.yml`. -## Переменные окружения, которые могли исчезнуть или переехать +## Переменные окружения -Перед запуском нового стека проверьте `.env`. Ниже — только то, что точно -менялось между v2.7.0 и v3.4+: +Перед запуском нового стека проверьте `.env`. Самые важные изменения: -| Было (v2.7.0) | Стало (v3.4+) | Действие | +| Было | Стало | Действие | | --- | --- | --- | -| `TELEGRAM_WEBHOOK_SECRET` | `WEBHOOK_SECRET_TOKEN` | Переименовать. Если пусто — будет сгенерирован при старте, но тогда Telegram переустановит webhook (на это не реагирует существующий запрос). | -| `TELEGRAM_WEBHOOK_PATH` | удалена | Путь вебхука теперь генерируется из `BOT_TOKEN` автоматически. | -| `REQUIRED_CHANNEL_SUBSCRIBE_TO_USE` | удалена | Гейт включается автоматически, как только задан `REQUIRED_CHANNEL_ID`. | -| `STARS_PROVIDER_TOKEN` | удалена | Telegram Stars (XTR) используются напрямую. | -| `REFERRAL_ENABLED` | удалена | Реферальная программа активна по умолчанию. В legacy-режиме без JSON-каталога отключайте платежные бонусы через нули в `REFERRAL_BONUS_DAYS_*` и `REFEREE_BONUS_DAYS_*`; в JSON-тарифах обнуляйте или удаляйте `referral_bonus_days_inviter` и `referral_bonus_days_referee` у period-тарифов. | -| `POSTGRES_HOST=remnawave-tg-shop-db` | в `.env` — `remnawave-minishop-db` или пусто | Под compose значение всё равно переопределяется на сервисное имя `postgres` (см. `environment:` в compose-файлах), поэтому скрипт правит `.env` только для bare-metal сценариев. | -| `WEBHOOK_BASE_URL` | **обязательна** | Polling-режим удалён, без публичного URL бот не стартует. | -| — | `REDIS_URL=redis://redis:6379/0` | Обязательна для воркера, очередей и rate-limit. По умолчанию в compose-файлах уже задана. | -| — | `WEBAPP_SESSION_SECRET`, `WEBAPP_ENABLED`, `WEBAPP_SERVER_PORT`, `WEBAPP_THEMES_DIR`, `TARIFFS_CONFIG_PATH` | Новые настройки Web App / тарифного каталога. Безопасные дефолты есть в `.env.example`. | +| `TELEGRAM_WEBHOOK_SECRET` | `WEBHOOK_SECRET_TOKEN` | Перенести значение или сгенерировать новый stable secret. | +| `TELEGRAM_WEBHOOK_PATH` | удалена | Путь вебхука теперь рассчитывается автоматически. | +| `REQUIRED_CHANNEL_SUBSCRIBE_TO_USE` | удалена | Гейт включается, когда задан `REQUIRED_CHANNEL_ID`. | +| `STARS_PROVIDER_TOKEN` | удалена | Telegram Stars используются напрямую. | +| `POSTGRES_HOST=remnawave-tg-shop-db` | `postgres` внутри Compose | В compose-файлах `POSTGRES_HOST` переопределяется service name `postgres`. | +| `WEBHOOK_BASE_URL` | обязательна | Без публичного URL backend не стартует корректно. | +| - | `REDIS_URL=redis://redis:6379/0` | В compose-профилях задано автоматически. | +| - | `WEBAPP_SESSION_SECRET`, `WEBAPP_ENABLED`, `TARIFFS_CONFIG_PATH` | Новые настройки Web App и каталога тарифов. | -Полный референс — [docs/getting-started/configuration.md](../getting-started/configuration.md). Скрипт миграции -эти переменные **не правит** автоматически (только `POSTGRES_HOST`), потому -что у каждой инсталляции свой шаблон `.env` с кастомными значениями. Лучше -сравнить свой `.env` с `.env.example` глазами один раз, чем получить -несовместимый шаблон автоматом. +Остальные продуктовые настройки удобнее проверить после первого входа в +админку. -## Автоматический способ (через скрипт) +## Reverse Proxy -Если helper ещё не лежит у вас локально, запускайте его прямо из `raw` из -корня старого репозитория: +В старом стеке часто был один upstream `remnawave-tg-shop:8000`. В текущем +split-arch stack маршруты разделены: -```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-файла. -# Перед запуском скопируйте старый .env в deploy/examples/caddy/.env -# и заполните WEBHOOK_HOST / MINIAPP_HOST. -COMPOSE_FILE=deploy/examples/caddy/docker-compose.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. **Останавливает текущий стек**: ищет известные контейнеры старой схемы - (`remnawave-tg-shop`, `…-db`, `…-caddy`), переходного периода - (`remnawave-minishop`, `…-db`, `…-caddy`) и новой схемы - (`…-backend`, `…-worker`, `…-frontend`, `…-migrate`, `…-postgres`, `…-redis`) - и останавливает их, если запущены. Безопасно при повторном запуске. -2. **Переключает `origin`**, если задана переменная `NEW_ORIGIN_URL`, иначе - оставляет как есть. -3. **Подтягивает целевую ветку** (`git fetch` + `git switch` + `git pull --ff-only`). - Прерывается, если в рабочем дереве есть незакоммиченные изменения. -4. **Правит `POSTGRES_HOST` в `.env`** (только для bare-metal сценариев — в - compose это значение перебивает `environment:` блок). -5. **Подготавливает новый стек в режиме `--no-start`**, чтобы Compose сам - создал тома `db-data`, `redis-data`, `shop-data` и не ругался на уже - существующий volume. -6. **Переносит том БД** `remnawave-tg-shop-db-data` → `remnawave-minishop-db-data` - (и Caddy-тома, если применимо) через одноразовый `alpine`-контейнер. Если - новый том уже непустой — копирование пропускается. Новые volumes - `redis-data` и `shop-data` остаются пустыми (их и не должно быть в старом - стеке). -7. **Стартует новый стек** (`docker compose up -d --remove-orphans` плюс - `--build` для локальной сборки). `migrate` отработает первым, накатит - на перенесённый том все недостающие миграции (от alembic-схемы v2.7.0 до - текущей) и завершится. Затем стартуют `backend`, `worker`, `frontend`. - -Скрипт идемпотентен: повторный запуск ничего не сломает, просто пропустит уже -выполненные шаги. - -После того как убедитесь, что бот работает и данные на месте, удалите старые -тома: - -```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 -``` - -## Ручной способ - -1. **Остановите старый стек и обновите код:** - - ```bash - docker compose down - git fetch origin - git checkout main - git pull --ff-only origin main - ``` - -2. **(Только для bare-metal без compose)** обновите `.env`, если в нём ещё - жёстко прописан старый контейнер БД: - - ```bash - sed -i.bak 's/^POSTGRES_HOST=remnawave-tg-shop-db$/POSTGRES_HOST=remnawave-minishop-db/' .env - ``` - - Под `docker compose up` это не нужно: compose сам выставляет - `POSTGRES_HOST: postgres` (имя сервиса) в `environment:` и `.env`-значение - не используется. - -3. **Проверьте `.env`** на наличие переменных, которые исчезли или - переименовались — см. раздел - [Переменные окружения](#переменные-окружения-которые-могли-исчезнуть-или-переехать) - выше. Главное: `WEBHOOK_SECRET_TOKEN` (бывший `TELEGRAM_WEBHOOK_SECRET`), - обязательный `WEBHOOK_BASE_URL` и наличие `REDIS_URL` (по умолчанию задано - в compose). - -4. **Подготовьте новый стек без запуска**, чтобы Compose создал новые volumes - (`db-data`, `redis-data`, `shop-data`) и контейнеры: - - ```bash - # Локальная сборка - docker compose up --no-start --build - - # Или готовый Caddy-вариант из GHCR-образов - cp .env deploy/examples/caddy/.env - nano deploy/examples/caddy/.env - docker compose \ - --env-file deploy/examples/caddy/.env \ - -f deploy/examples/caddy/docker-compose.yml \ - up --no-start - - # Другие готовые варианты: - # deploy/examples/nginx/docker-compose.yml - # deploy/examples/newt/docker-compose.yml - # deploy/examples/no-proxy/docker-compose.yml - ``` - -5. **Перенесите том БД в новое имя:** - - ```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" - ``` - - `remnawave-minishop-redis-data` и `remnawave-minishop-shop-data` — новые, - переносить нечего. Они инициализируются на лету: Redis пуст, а `data/` - наполняется при первом обращении к настройкам Web App / каталогу тарифов. - -6. **(Только для 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 - ``` - -7. **Запустите новый стек:** - - ```bash - docker compose up -d - # или - docker compose \ - --env-file deploy/examples/caddy/.env \ - -f deploy/examples/caddy/docker-compose.yml \ - up -d - ``` - - Сервис `migrate` запустится первым, обнаружит перенесённый том, - применит недостающие схемные миграции (`Base.metadata.create_all` + - последовательные миграции `0001..00NN` из `backend/db/migrator.py`) и - выйдет с кодом 0. Только после этого стартуют `backend` и `worker`. - -8. **Проверьте состояние:** - - ```bash - docker compose ps - docker compose logs -f backend worker frontend - docker compose logs migrate # должен закончиться "Migrator: migration 00NN applied successfully" - ``` - -9. **(Опционально) удалите старые тома**, когда убедитесь, что новый стек - стабилен: - - ```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 - -В v2.7.0 был один upstream — `remnawave-tg-shop:8000`. В v3.4+ функциональность -разнесена по портам и сервисам: - -| Назначение | DNS-имя сервиса | Порт | +| Назначение | Service | Port | | --- | --- | --- | -| Telegram / платежные / вебхуки панели | `backend` | `8080` | -| Health-чек | `backend` | `8080` (`/healthz`) | -| Web App API (`/api/*`, `/auth/*`, ассеты тем и логотипов) | `backend` | `8081` (доступен только из Docker-сети) | -| Статический фронт Web App | `frontend` | `80` (внутри `frontend` уже проксирует `/api/*` и `/auth/*` на `backend:8081`) | +| Telegram, платежные и panel webhooks | `backend` | `8080` | +| Health-check | `backend` | `8080` (`/healthz`) | +| Web App API и auth | `backend` | `8081` внутри Docker-сети | +| Статический Web App frontend | `frontend` | `80` | -Минимальная замена для внешнего Nginx, который раньше слал всё на один -upstream: +Минимальная схема для внешнего Nginx: ```nginx upstream remnawave_backend_webhooks { server backend:8080; } @@ -351,33 +127,32 @@ upstream remnawave_frontend { server frontend:80; } server { server_name app.domain.com; listen 443 ssl; - http2 on; - # ssl_certificate / ssl_certificate_key — без изменений location /webhook/ { proxy_pass http://remnawave_backend_webhooks; } location /healthz { proxy_pass http://remnawave_backend_webhooks; } - location / { proxy_pass http://remnawave_frontend; } + location / { proxy_pass http://remnawave_frontend; } } ``` -Полные примеры (Caddy, Nginx, Newt/Pangolin и запуск без обратного прокси) — в -[docs/getting-started/deployment.md](../getting-started/deployment.md) и [docs/features/web-app.md](../features/web-app.md). Если раньше прокси указывал на -`remnawave-tg-shop:8000` напрямую, после миграции нужно либо переключиться на -`backend:8080` / `frontend:80`, либо использовать готовый Caddy/Nginx/Newt -пример, который уже знает правильную маршрутизацию. +Готовые Caddy, Nginx, Pangolin/Newt и no-proxy профили уже содержат нужную +маршрутизацию. -## Если что-то пошло не так +## Проверка -`migrate` упал → читайте `docker compose logs migrate`. Том БД остался -не тронут, можно откатиться, переключив compose-файл обратно на старый -коммит и подняв старый стек на старом томе `remnawave-tg-shop-db-data` -(пока вы его не удалили). +После переноса: -`backend` не стартует → чаще всего `WEBHOOK_BASE_URL` пуст, либо -`WEBHOOK_SECRET_TOKEN` отличается от того, что Telegram ждёт. Поставьте -свежий секрет в `.env` и перезапустите — Telegram переустановит webhook -автоматически. +```bash +docker compose ps +docker compose logs migrate +docker compose logs -f backend worker frontend +``` -Web App пуст / 502 → проверьте, что `frontend` живёт (`docker compose ps`), -а внешний прокси шлёт на `frontend:80`, а не на старый -`remnawave-tg-shop:8000`. +`migrate` должен завершиться успешно, а `backend`, `worker`, `frontend`, +`postgres` и `redis` должны быть running/healthy. + +Когда убедитесь, что новый stack работает, старые volumes можно удалить вручную: + +```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 +``` diff --git a/frontend/scripts/prepare_nginx_assets.mjs b/frontend/scripts/prepare_nginx_assets.mjs new file mode 100644 index 0000000..01714b3 --- /dev/null +++ b/frontend/scripts/prepare_nginx_assets.mjs @@ -0,0 +1,150 @@ +#!/usr/bin/env node +import { + copyFile, + lstat, + mkdir, + readdir, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, "..", ".."); +const templatesDir = path.join(repoRoot, "backend", "bot", "app", "web", "templates"); +const DEFAULT_OUT_DIR = path.join(repoRoot, "frontend-nginx-dist"); +const outDir = resolveOutDir(); + +function resolveOutDir() { + const argIndex = process.argv.indexOf("--out"); + if (argIndex === -1) { + return DEFAULT_OUT_DIR; + } + const rawValue = process.argv[argIndex + 1]; + if (!rawValue) { + throw new Error("--out requires a directory path"); + } + return path.resolve(process.cwd(), rawValue); +} + +async function pathExists(filePath) { + try { + await lstat(filePath); + return true; + } catch (error) { + if (error?.code === "ENOENT") return false; + throw error; + } +} + +async function copyIfExists(sourceName, targetName = sourceName) { + const sourcePath = path.join(templatesDir, sourceName); + if (!(await pathExists(sourcePath))) return false; + await copyFile(sourcePath, path.join(outDir, targetName)); + return true; +} + +async function linkOrCopy(targetName, aliasName) { + const aliasPath = path.join(outDir, aliasName); + try { + await symlink(targetName, aliasPath); + } catch (error) { + if (!["EPERM", "EINVAL", "ENOSYS"].includes(error?.code)) { + throw error; + } + await copyFile(path.join(outDir, targetName), aliasPath); + } +} + +function latestMatching(entries, pattern, fallbackName) { + const matches = entries.filter((name) => pattern.test(name)).sort(); + return matches.at(-1) || fallbackName; +} + +async function copyRuntimeAsset({ hashedName, stableName }) { + const copied = await copyIfExists(hashedName); + if (copied && hashedName !== stableName) { + await linkOrCopy(hashedName, stableName); + } else if (!copied) { + await copyIfExists(stableName); + } + + const gzipName = `${hashedName}.gz`; + const gzipCopied = await copyIfExists(gzipName); + if (gzipCopied && hashedName !== stableName) { + await linkOrCopy(gzipName, `${stableName}.gz`); + } +} + +function prepareIndexHtml(rawHtml, { cssName, jsName }) { + const html = rawHtml + .replace(/\r\n/g, "\n") + .replace('href="/subscription_webapp.css"', `href="/${cssName}"`); + const lines = html.split("\n"); + const output = lines + .map((line) => + line.includes("WEBAPP_JS_SCRIPT") + ? ` ` + : line + ) + .filter( + (line) => + !line.includes("WEBAPP_I18N_SCRIPT") && + !line.includes("WEBAPP_CONFIG_SCRIPT") && + !line.includes("WEBAPP_DEV_MOCK_START") && + !line.includes("WEBAPP_DEV_MOCK_END") && + !line.includes('subscription_webapp.js" defer') + ) + .join("\n"); + return output.endsWith("\n") ? output : `${output}\n`; +} + +async function main() { + const entries = await readdir(templatesDir); + const mainJsName = latestMatching( + entries, + /^subscription_webapp\.min\.[0-9a-f]{8}\.js$/, + "subscription_webapp.js" + ); + const mainCssName = latestMatching( + entries, + /^subscription_webapp\.[0-9a-f]{8}\.css$/, + "subscription_webapp.css" + ); + const adminJsName = latestMatching( + entries, + /^subscription_webapp_admin\.min\.[0-9a-f]{8}\.js$/, + "subscription_webapp_admin.js" + ); + const adminCssName = latestMatching( + entries, + /^subscription_webapp_admin\.[0-9a-f]{8}\.css$/, + "subscription_webapp_admin.css" + ); + + await rm(outDir, { recursive: true, force: true }); + await mkdir(outDir, { recursive: true }); + + await Promise.all([ + copyRuntimeAsset({ hashedName: mainJsName, stableName: "subscription_webapp.js" }), + copyRuntimeAsset({ hashedName: mainCssName, stableName: "subscription_webapp.css" }), + copyRuntimeAsset({ hashedName: adminJsName, stableName: "subscription_webapp_admin.js" }), + copyRuntimeAsset({ hashedName: adminCssName, stableName: "subscription_webapp_admin.css" }), + ]); + + const indexTemplate = await readFile(path.join(templatesDir, "subscription_webapp.html"), "utf8"); + await writeFile( + path.join(outDir, "index.html"), + prepareIndexHtml(indexTemplate, { cssName: mainCssName, jsName: mainJsName }), + "utf8" + ); + + console.log( + `Prepared nginx assets in ${path.relative(repoRoot, outDir)}: ${mainJsName}, ${mainCssName}, ${adminJsName}, ${adminCssName}` + ); +} + +await main(); diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 6c0eb53..e8ae5c6 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -74,11 +74,15 @@ const ACTIVATION_PENDING_WATCH_MAX_ATTEMPTS = 45; const ACTIVATION_RESUME_CHECK_COOLDOWN_MS = 1500; const TELEGRAM_NOTIFICATIONS_RESUME_REFRESH_COOLDOWN_MS = 1500; + const TELEGRAM_LINK_PENDING_ACTION_STORAGE_KEY = "rw_webapp_telegram_link_pending_action_v1"; + const TELEGRAM_LINK_PENDING_TTL_MS = 10 * 60 * 1000; + const TELEGRAM_LINK_ACTION_TRIAL = "trial"; + const TELEGRAM_LINK_ACTION_REFERRAL_WELCOME = "referral_welcome"; import { activationPaymentFailed, createActivationHandoff, } from "./lib/webapp/activationHandoff.js"; - import { buildGravatarUrl } from "./lib/webapp/gravatar.js"; + import { buildGravatarUrl, resolveProfileAvatarUrl } from "./lib/webapp/gravatar.js"; import { createBillingActions } from "./lib/webapp/billingActions.js"; import { invalidateWebappTariffOptionCaches } from "./lib/webapp/billingOptionCache.js"; import { runWebappBoot } from "./lib/webapp/webappBoot.js"; @@ -107,9 +111,10 @@ export let mockRuntime = null; + const FALLBACK_BRAND_TITLE = "Subscription"; const EMPTY_MOCK = { config: { - title: "/minishop", + title: FALLBACK_BRAND_TITLE, primaryColor: "#00fe7a", apiBase: "/api", language: "ru", @@ -171,6 +176,7 @@ let telegramNotificationsBotOpenedAt = 0; let telegramNotificationsResumeRefreshBusy = false; let telegramNotificationsResumeLastCheckAt = 0; + let telegramLinkPendingActionBusy = false; let promoCode = ""; let promoBusy = false; let promoStatus = ""; @@ -357,14 +363,10 @@ languageBusy, } = $accountStore); - $: brandTitle = CFG.title || "/minishop"; - $: brandEmoji = CFG.logoEmoji || "🫥"; - $: brandEmojiFont = CFG.logoEmojiFont || "system"; + $: brandTitle = CFG.title || FALLBACK_BRAND_TITLE; $: brand = normalizeBrand({ title: brandTitle, - logoUrl: CFG.logoUseEmoji ? "" : CFG.logoUrl, - emoji: brandEmoji, - emojiFont: brandEmojiFont, + logoUrl: CFG.logoUrl, }); $: faviconBrand = normalizeBrand({ ...brand, @@ -373,6 +375,9 @@ $: plans = data?.plans?.length ? data.plans : MOCK_SOURCE.data.plans; $: methods = data?.payment_methods?.length ? data.payment_methods : []; $: appSettings = data?.settings || MOCK_SOURCE.data.settings; + $: rawEmailAuthEnabled = + data?.settings?.email_auth_enabled ?? appSettings?.email_auth_enabled ?? CFG.emailAuthEnabled; + $: emailAuthEnabled = rawEmailAuthEnabled !== false && rawEmailAuthEnabled !== "false"; $: subscriptionPurchaseDescription = String( appSettings?.subscription_purchase_description || "" ).trim(); @@ -487,14 +492,14 @@ ); $: telegramNotificationsStartLink = String(user?.telegram_notifications_start_link || ""); $: hasUnlinkedIdentity = - !user?.telegram_linked || !user?.email || telegramNotificationsNeedPrompt; + !user?.telegram_linked || (emailAuthEnabled && !user?.email) || telegramNotificationsNeedPrompt; $: referralBonusDetails = Array.isArray(referral?.bonus_details) ? referral.bonus_details : []; $: referralWelcomeBonusDays = Math.max(0, Number(referral?.welcome_bonus_days || 0)); $: referralOneBonusPerReferee = Boolean(referral?.one_bonus_per_referee); $: telegramProfileName = telegramName(user); $: profileEmail = user?.email || t("wa_settings_email_not_linked"); $: profileTelegramId = user?.telegram_id ? `TG ID ${user.telegram_id}` : t("wa_tg_id_not_linked"); - $: profileAvatarUrl = user?.telegram_photo_url || emailAvatarUrl || ""; + $: profileAvatarUrl = resolveProfileAvatarUrl(user, emailAvatarUrl); $: privacyPolicyUrl = String(CFG.privacyPolicyUrl || "").trim(); $: userAgreementUrl = String(CFG.userAgreementUrl || "").trim(); $: supportUrl = String(appSettings?.support_url || CFG.supportUrl || "").trim(); @@ -531,9 +536,15 @@ changeConfirmOpen || topupModalOpen || deviceTopupModalOpen || - linkEmailOpen || - setPasswordOpen + (emailAuthEnabled && linkEmailOpen) || + (emailAuthEnabled && setPasswordOpen) ); + $: if (!emailAuthEnabled && linkEmailOpen) { + accountStore.closeLinkEmailDialog(); + } + $: if (!emailAuthEnabled && setPasswordOpen) { + accountStore.closeSetPasswordDialog(); + } $: if (!tariffMode && !$billingStore.selectedPlan && plans.length) { billingStore.update((s) => ({ ...s, selectedPlan: plans[Math.min(1, plans.length - 1)] })); } @@ -1161,10 +1172,16 @@ } function openSettingsLinkEmailDialog() { + if (!emailAuthEnabled) return; const authDemo = MOCK_SOURCE.data?.auth_demo || {}; accountStore.openLinkEmailDialog(demoAuthLogin ? authDemo.email || "3252a8@proton.me" : ""); } + function openSettingsSetPasswordDialog() { + if (!emailAuthEnabled) return; + accountStore.openSetPasswordDialog(); + } + async function linkTelegramFromSettings() { if (!demoAuthLogin) { await accountStore.linkTelegramAccount(() => telegramMiniAppInitData); @@ -1187,6 +1204,161 @@ } } + function currentTelegramLinkPendingUserId() { + const currentUser = data?.user || user || {}; + const id = currentUser.user_id ?? currentUser.id; + return id == null ? "" : String(id); + } + + function isTelegramLinkPendingAction(action) { + return [TELEGRAM_LINK_ACTION_TRIAL, TELEGRAM_LINK_ACTION_REFERRAL_WELCOME].includes(action); + } + + function rememberTelegramLinkPendingAction(action) { + if (typeof window === "undefined" || !isTelegramLinkPendingAction(action)) return; + try { + window.sessionStorage.setItem( + TELEGRAM_LINK_PENDING_ACTION_STORAGE_KEY, + JSON.stringify({ + action, + userId: currentTelegramLinkPendingUserId(), + createdAt: Date.now(), + }) + ); + } catch (_error) { + void _error; + } + } + + function clearTelegramLinkPendingAction() { + if (typeof window === "undefined") return; + try { + window.sessionStorage.removeItem(TELEGRAM_LINK_PENDING_ACTION_STORAGE_KEY); + } catch (_error) { + void _error; + } + } + + function readTelegramLinkPendingAction() { + if (typeof window === "undefined") return null; + try { + const raw = window.sessionStorage.getItem(TELEGRAM_LINK_PENDING_ACTION_STORAGE_KEY); + if (!raw) return null; + const payload = JSON.parse(raw); + const action = String(payload?.action || ""); + const createdAt = Number(payload?.createdAt || 0); + const pendingUserId = String(payload?.userId || ""); + const currentUserId = currentTelegramLinkPendingUserId(); + if ( + !isTelegramLinkPendingAction(action) || + !createdAt || + Date.now() - createdAt > TELEGRAM_LINK_PENDING_TTL_MS || + (pendingUserId && currentUserId && pendingUserId !== currentUserId) + ) { + clearTelegramLinkPendingAction(); + return null; + } + return action; + } catch (_error) { + clearTelegramLinkPendingAction(); + return null; + } + } + + async function runTelegramLinkedAction(action) { + if (action === TELEGRAM_LINK_ACTION_TRIAL) { + await activateTrial(); + return true; + } + if (action === TELEGRAM_LINK_ACTION_REFERRAL_WELCOME) { + await claimReferralWelcomeBonus(); + return true; + } + return false; + } + + async function continueTelegramLinkPendingAction() { + if (telegramLinkPendingActionBusy) return false; + const currentUser = data?.user || user || {}; + if (!currentUser?.telegram_linked) return false; + const action = readTelegramLinkPendingAction(); + if (!action) return false; + telegramLinkPendingActionBusy = true; + clearTelegramLinkPendingAction(); + try { + return await runTelegramLinkedAction(action); + } finally { + telegramLinkPendingActionBusy = false; + } + } + + async function linkTelegramWithPayloadForPendingAction(payload) { + accountStore.update((s) => ({ ...s, linkTelegramBusy: true })); + try { + const response = await api("/account/telegram/link", { + method: "POST", + body: JSON.stringify(payload), + }); + if (!response?.ok) throw response; + if (response?.csrf_token) setToken("", response.csrf_token); + await loadData({ fresh: true, preserveView: true }); + const handled = await continueTelegramLinkPendingAction(); + if (!handled) { + clearTelegramLinkPendingAction(); + showToast(t("wa_settings_linked")); + } + } catch (error) { + clearTelegramLinkPendingAction(); + showToast(error?.message || t("wa_auth_telegram_not_confirmed")); + } finally { + accountStore.update((s) => ({ ...s, linkTelegramBusy: false })); + } + } + + async function linkTelegramForPendingAction(action) { + if (!isTelegramLinkPendingAction(action) || linkTelegramBusy || telegramLinkPendingActionBusy) { + return; + } + const currentUser = data?.user || user || {}; + if (currentUser?.telegram_linked) { + await runTelegramLinkedAction(action); + return; + } + + rememberTelegramLinkPendingAction(action); + if (demoAuthLogin) { + await linkTelegramWithPayloadForPendingAction({ auth_data: demoTelegramAuthPayload() }); + return; + } + + const isTelegramMiniAppAttempt = hasTelegramLaunchParams(); + if (isTelegramMiniAppAttempt) { + await telegramSdk.ensureForAction(); + } + const initData = + telegramMiniAppInitData || tg?.initData || readTelegramMiniAppInitDataFromLocation(); + if (initData) { + await linkTelegramWithPayloadForPendingAction({ init_data: initData }); + return; + } + if (!telegramOAuthClientId) { + clearTelegramLinkPendingAction(); + showToast(t("wa_auth_telegram_not_configured")); + return; + } + await accountStore.linkTelegramAccount( + () => telegramMiniAppInitData || tg?.initData || readTelegramMiniAppInitDataFromLocation() + ); + } + + function linkTelegramAndActivateTrial() { + return linkTelegramForPendingAction(TELEGRAM_LINK_ACTION_TRIAL); + } + + function linkTelegramAndClaimReferralWelcome() { + return linkTelegramForPendingAction(TELEGRAM_LINK_ACTION_REFERRAL_WELCOME); + } + function openTelegramNotificationsBot() { const link = telegramNotificationsStartLink; telegramNotificationsBotOpenedAt = Date.now(); @@ -1453,9 +1625,12 @@ getCsrfToken: () => csrfToken, }); if (mode === "app" && screen !== "admin") { - if (hasPendingActivationHandoff()) await loadData({ fresh: true }); - const shown = await maybeShowActivationSuccessDialog({ source: "boot" }); - if (!shown) startPendingActivationWatch(); + const telegramActionHandled = await continueTelegramLinkPendingAction(); + if (!telegramActionHandled) { + if (hasPendingActivationHandoff()) await loadData({ fresh: true }); + const shown = await maybeShowActivationSuccessDialog({ source: "boot" }); + if (!shown) startPendingActivationWatch(); + } } } @@ -1524,6 +1699,7 @@ selectedPlan: null, selectedTariffKey: "", paymentStep: "tariff", + renewHwidDevices: true, selectedMethod: payload.payment_methods?.[0]?.id || "", })); const currentQuery = currentSearchParams(); @@ -1904,6 +2080,55 @@ } } + function trialActivationFailureMessage(error) { + if ( + error?.error === "trial_telegram_required" || + error?.message === "telegram_required" || + error?.message === "disposable_email" + ) { + return t( + "wa_trial_telegram_required_error", + {}, + "Для активации пробного периода привяжите Telegram." + ); + } + return error?.message || t("wa_trial_activation_failed"); + } + + function referralWelcomeFailureMessage(error) { + if ( + error?.error === "referral_welcome_telegram_required" || + error?.message === "telegram_required" || + error?.message === "disposable_email" + ) { + return t( + "wa_referral_welcome_telegram_required_error", + {}, + "Для получения реферального бонуса привяжите Telegram." + ); + } + return error?.message || t("wa_referral_welcome_claim_failed"); + } + + async function claimReferralWelcomeBonus() { + try { + const response = await api("/referral/welcome-bonus/claim", { + method: "POST", + body: JSON.stringify({}), + }); + if (!response.ok) throw response; + showToast( + response.end_date_text + ? t("wa_referral_welcome_claimed_until", { date: response.end_date_text }) + : t("wa_referral_welcome_claimed") + ); + await loadData({ fresh: true }); + await maybeShowActivationSuccessDialog({ source: "referral_welcome", force: true }); + } catch (error) { + showToast(referralWelcomeFailureMessage(error)); + } + } + async function activateTrial() { if (trialBusy) return; trialBusy = true; @@ -1920,7 +2145,7 @@ await loadData({ fresh: true }); await maybeShowActivationSuccessDialog({ source: "trial", force: true }); } catch (error) { - const message = error?.message || t("wa_trial_activation_failed"); + const message = trialActivationFailureMessage(error); trialActivationError = message; showToast(message); } finally { @@ -2077,9 +2302,6 @@ ]); return [ "WEBAPP_LOGO_URL", - "WEBAPP_LOGO_USE_EMOJI", - "WEBAPP_LOGO_EMOJI", - "WEBAPP_LOGO_EMOJI_FONT", "WEBAPP_FAVICON_URL", "WEBAPP_FAVICON_USE_CUSTOM", "WEBAPP_LOGO_FAVICON_URL", @@ -2286,7 +2508,9 @@ {premiumTrafficTopupUnlocked} {regularTrafficTopupBarClickable} {regularTrafficTopupUnlocked} + {referral} {subscription} + {linkTelegramBusy} {telegramNotificationsNeedPrompt} {telegramNotificationsStartLink} {telegramNotificationsStatus} @@ -2294,6 +2518,8 @@ {trafficMode} {trialBusy} {activateTrial} + {linkTelegramAndActivateTrial} + {linkTelegramAndClaimReferralWelcome} {openTelegramNotificationsBot} openConnectLink={openInstallOrConnect} {openPaymentModal} @@ -2323,9 +2549,11 @@ {brandTitle} {subscription} {trialBusy} + {linkTelegramBusy} trialResult={trialActivationResult} trialError={trialActivationError} {activateTrial} + {linkTelegramAndActivateTrial} openInstallOrConnect={openTrialInstallOrConnect} {goHome} {t} @@ -2384,6 +2612,7 @@
@@ -113,7 +113,7 @@
-
{logoEmoji}
+
{title}
@@ -198,7 +198,7 @@
-
{logoEmoji}
+
{title}
@@ -221,7 +221,7 @@
-
{logoEmoji}
+
{title}
@@ -252,7 +252,7 @@ diff --git a/frontend/src/admin/AdminPanel.svelte b/frontend/src/admin/AdminPanel.svelte index c37f59e..250b1dc 100644 --- a/frontend/src/admin/AdminPanel.svelte +++ b/frontend/src/admin/AdminPanel.svelte @@ -45,9 +45,11 @@ import AppearanceSection from "./sections/AppearanceSection.svelte"; import UserDetailModal from "./sections/UserDetailModal.svelte"; import UsersSection from "./sections/UsersSection.svelte"; + import ConfigAlertsBanner from "./ConfigAlertsBanner.svelte"; import { createAdsStore } from "../lib/admin/stores/adsStore.js"; import { createBackupsStore } from "../lib/admin/stores/backupsStore.js"; import { createBroadcastStore } from "../lib/admin/stores/broadcastStore.js"; + import { createHealthStore } from "../lib/admin/stores/healthStore.js"; import { createLogsStore } from "../lib/admin/stores/logsStore.js"; import { createPaymentsStore } from "../lib/admin/stores/paymentsStore.js"; import { createPromosStore } from "../lib/admin/stores/promosStore.js"; @@ -93,7 +95,7 @@ export let onTranslationsSaved = () => {}; export let routePrefix = ""; export let brand = {}; - export let brandTitle = "/minishop"; + export let brandTitle = "Subscription"; export let appFaviconUrl = ""; export let appFaviconUseCustom = false; export let appVersion = "dev+local"; @@ -223,6 +225,8 @@ } let sidebarOpen = false; let isCompact = false; + let dismissedUserRouteKey = ""; + let lastUserRouteKey = ""; let adminLanguageMenuOpen = false; let adminLanguageClickGuard = false; let adminLanguageClickGuardArmed = false; @@ -245,6 +249,7 @@ const adsStore = createAdsStore({ api, onToast: flash, at }); const backupsStore = createBackupsStore({ api, onToast: flash, at }); const broadcastStore = createBroadcastStore({ api, onToast: flash, at }); + const healthStore = createHealthStore({ api }); const logsStore = createLogsStore({ api, at }); const paymentsStore = createPaymentsStore({ api, onToast: flash, at, routePrefix }); const promosStore = createPromosStore({ api, onToast: flash, at }); @@ -258,6 +263,7 @@ setContext("promosStore", promosStore); setContext("adsStore", adsStore); + setContext("healthStore", healthStore); setContext("backupsStore", backupsStore); setContext("broadcastStore", broadcastStore); setContext("logsStore", logsStore); @@ -379,6 +385,7 @@ const uid = Number(userId); // Synthetic email-only users use negative user_id; still a valid admin target. if (!Number.isFinite(uid) || uid === 0) return; + dismissedUserRouteKey = ""; const next = normalizeSection("payments"); sidebarOpen = false; if (active !== next) { @@ -395,6 +402,7 @@ function openLogsUserCard(userId) { const uid = Number(userId); if (!Number.isFinite(uid) || uid === 0) return; + dismissedUserRouteKey = ""; const next = normalizeSection("logs"); sidebarOpen = false; if (active !== next) { @@ -410,16 +418,24 @@ function openUserCard(userId) { const uid = Number(userId); if (!Number.isFinite(uid) || uid === 0) return; - const next = normalizeSection("users"); + dismissedUserRouteKey = ""; sidebarOpen = false; - if (active !== next) { - active = next; - paymentsStore.closePayment({ skipPush: true }); - supportStore.closeTicketView({ skipPush: true }); - onSectionChange(next, uid); + usersStore.setActive(active); + usersStore.openUser(uid, { skipPush: true, pathContext: active }); + } + + function userRouteKey(section = active) { + if (section === "users" && initialUserId) return `users:${initialUserId}`; + if (section === "payments" && initialPaymentUserId) return `payments:${initialPaymentUserId}`; + return ""; + } + + function closeUserCard() { + dismissedUserRouteKey = userRouteKey(); + usersStore.closeUser({ skipPush: true }); + if (active === "users" || active === "payments") { + onSectionChange(active, 0); } - usersStore.setActive(next); - usersStore.openUser(uid); } function resolvedAvatarUrl(user) { @@ -518,6 +534,12 @@ if (typeof window !== "undefined") { window.addEventListener("popstate", onPopState); } + void broadcastStore.loadCounts(); + void healthStore.loadHealth(); + const healthTimer = + typeof window !== "undefined" + ? window.setInterval(() => void healthStore.loadHealth(), 5 * 60 * 1000) + : null; return () => { if (motionMql) motionMql.removeEventListener("change", onMotionChange); if (compactMql) { @@ -526,6 +548,7 @@ else if (compactMql.removeListener) compactMql.removeListener(onCompactChange); } if (typeof window !== "undefined") window.removeEventListener("popstate", onPopState); + if (healthTimer !== null) window.clearInterval(healthTimer); clearAdminLanguageClickGuard(); }; }); @@ -533,9 +556,18 @@ $: sectionFade = reduceMotion ? { duration: 0 } : { duration: 200 }; $: sidebarBackdropFade = reduceMotion ? { duration: 0 } : { duration: 180 }; + $: { + const currentUserRouteKey = userRouteKey(); + if (currentUserRouteKey !== lastUserRouteKey) { + if (currentUserRouteKey !== dismissedUserRouteKey) dismissedUserRouteKey = ""; + lastUserRouteKey = currentUserRouteKey; + } + } + $: if ( active === "users" && initialUserId && + dismissedUserRouteKey !== `users:${initialUserId}` && (!$usersStore.openedUser || $usersStore.openedUser.user_id !== initialUserId) ) { usersStore.openUser(initialUserId, { skipPush: true }); @@ -552,6 +584,7 @@ $: if ( active === "payments" && initialPaymentUserId && + dismissedUserRouteKey !== `payments:${initialPaymentUserId}` && (!$usersStore.openedUser || $usersStore.openedUser.user_id !== initialPaymentUserId) ) { usersStore.openUser(initialPaymentUserId, { skipPush: true, pathContext: "payments" }); @@ -779,6 +812,7 @@
+ {#key active}
{#if active === "stats"} @@ -892,4 +926,5 @@ {trafficPercentValue} {trafficLeftLabel} {trafficOfLabel} + onClose={closeUserCard} /> diff --git a/frontend/src/admin/ConfigAlertsBanner.svelte b/frontend/src/admin/ConfigAlertsBanner.svelte new file mode 100644 index 0000000..9aff0ae --- /dev/null +++ b/frontend/src/admin/ConfigAlertsBanner.svelte @@ -0,0 +1,127 @@ + + +{#if visibleAlerts.length} +
0} + role="alert" + aria-live="polite" + > +
+ + + {at("health_title", {}, "Проблемы конфигурации")} + + {#if isDashboard} + healthStore.loadHealth({ refresh: true })} + disabled={healthLoading} + > + + {at("health_refresh", {}, "Проверить снова")} + + {/if} +
+
    + {#each visibleAlerts as alert (alert.id)} +
  • + + {alertText(alert)} + {#if isDashboard && (alert.sections || []).length} + + {#each alert.sections as sectionId (sectionId)} + + {/each} + + {/if} +
  • + {/each} +
+
+{/if} diff --git a/frontend/src/admin/sections/AppearanceSection.svelte b/frontend/src/admin/sections/AppearanceSection.svelte index 21aef22..26ad2a2 100644 --- a/frontend/src/admin/sections/AppearanceSection.svelte +++ b/frontend/src/admin/sections/AppearanceSection.svelte @@ -1,16 +1,10 @@
@@ -24,7 +43,7 @@ {at("broadcast_label_audience", {}, "Аудитория")} broadcastStore.updateField({ broadcastTarget: value })} /> diff --git a/frontend/src/admin/sections/LogsSection.svelte b/frontend/src/admin/sections/LogsSection.svelte index d0de85b..970d0c2 100644 --- a/frontend/src/admin/sections/LogsSection.svelte +++ b/frontend/src/admin/sections/LogsSection.svelte @@ -15,10 +15,11 @@ export let onOpenUserCard = () => {}; const logsStore = getContext("logsStore"); + const LOGS_PAGE_SIZE = 50; $: ({ logs, logsTotal, logsPage, logsUserFilter, logsLoading } = $logsStore); - $: logsHasMore = logs.length > 0 && logsTotal > (logsPage + 1) * 50; // 50 is LOGS_PAGE_SIZE + $: logsPageCount = Math.max(1, Math.ceil(Number(logsTotal || 0) / LOGS_PAGE_SIZE)); $: logHeaders = [ at("date", {}, "Дата"), at("event", {}, "Событие"), @@ -165,17 +166,18 @@
{ - logsStore.setPage(Math.max(0, logsPage - 1)); - }} - onNext={() => { - logsStore.setPage(logsPage + 1); - }} + onPageChange={(page) => logsStore.setPage(page)} /> diff --git a/frontend/src/lib/webapp/BrandMark.svelte b/frontend/src/lib/webapp/BrandMark.svelte index cabedee..d79a3cb 100644 --- a/frontend/src/lib/webapp/BrandMark.svelte +++ b/frontend/src/lib/webapp/BrandMark.svelte @@ -1,39 +1,15 @@
@@ -42,7 +43,7 @@ {t("wa_devices_title")} - {devicesCountLabel(devicesData, t)} + {devicesCountLabel(devicesData, t, effectiveMaxDevices)}
- {:else} + {:else if emailAuthEnabled}
+ {:else if trialRequiresTelegram} +

{t("wa_trial_telegram_required_title", {}, "Привяжите Telegram для триала")}

+

+ {t( + "wa_trial_telegram_required_description", + { + duration: + daysLeft > 0 ? t("wa_trial_days_left", { days: daysLeft }, "{days} days") : "", + traffic: trafficLabel, + }, + "Чтобы активировать пробный период, сначала привяжите Telegram." + )} +

+
+ {#if daysLeft > 0} +
+
{t("wa_trial_duration_label", {}, "Срок")}
+
{t("wa_trial_days_left", { days: daysLeft }, "{days} days")}
+
+ {/if} +
+
{t("wa_trial_traffic_label", {}, "Traffic")}
+
{trafficLabel}
+
+
{:else if trialError}

{t("wa_trial_activation_failed")}

{trialError}

@@ -138,6 +172,17 @@ {t("wa_install_and_configure")} + {:else if trialRequiresTelegram} + {:else if trialError && canRequestTrial}