Compare commits

..

10 Commits

Author SHA1 Message Date
nineap e6c81bf8fd Дублирует пайплайны для Gitea Actions (.gitea/workflows/)
Build & Push Image / Build and push to GHCR (push) Failing after 3m1s
Build & Push Image / Build and push Caddy (Cloudflare DNS) image (push) Failing after 11s
CI / fmt + clippy + test (push) Failing after 21m4s
Копии .github/workflows/{ci,build,deploy}.yml — Gitea Actions синтаксически
совместим с GitHub Actions, поэтому большая часть 1:1. Отличие: логин на
ghcr.io в build.yml теперь через secrets.GHCR_TOKEN (настоящий GitHub PAT),
не secrets.GITHUB_TOKEN — автотокен Gitea валиден только для самого Gitea,
на ghcr.io им не залогиниться. deploy.yml уже использовал GHCR_TOKEN,
не тронут в этой части.

Нужно завести в Gitea: секрет GHCR_TOKEN (write:packages), плюс все
остальные секреты, что уже есть в GitHub Actions (отдельное хранилище,
не шарится автоматически). Триггер workflow_run в deploy.yml поддержан не
во всех версиях Gitea Actions — см. комментарий в файле.
2026-07-09 14:28:05 +07:00
nineap 7290f9483b Тесты для ключевой бизнес-логики + 3 найденных бага
Добавляет 52 теста (было 7), приоритет: биллинг -> auth -> RBAC-гварды ->
staff/выводы средств -> hack-сессии -> рефералы -> proxy_internal.

Попутно найдены и исправлены реальные баги:
- migrations/0004: ALTER TABLE auth_sessions падал на чистой БД, потому что
  0002 эту таблицу уже дропнул — ломало --migrate на CI/новом инстансе и
  весь sqlx::test. Заменено на CREATE TABLE IF NOT EXISTS.
- request_withdrawal: гонка при параллельных заявках на вывод (не было
  FOR UPDATE) — партнёр мог одновременно вывести баланс кратно больше
  доступного. Новый repository::request_partner_withdrawal блокирует
  строку партнёра и пересчитывает остаток в одной транзакции.
- get_referral_stats: SUM() над BIGINT возвращал NUMERIC, sqlx падал на
  ColumnDecode для любого юзера с рефералами.
- reward_referrer: начислял рефереру 10% при каждой повторной покупке
  того же приведённого юзера вместо разового бонуса — добавлен фильтр
  WHERE status = 'pending'.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 01:18:15 +07:00
nineap 375b333978 Ролевая админка (Owner/Moderator/Partner) + оплата через @CryptoBot
Ролевая система вместо привязки к Telegram
----------------------------------------
Раньше единственный способ получить админ-доступ — admin_guard, пускавший
по ADMIN_TG_ID или role=="admin" (без сеттера для роли). Заменяет на
Owner/Moderator/Partner:

- migrations/0005_roles_and_partners.sql — username/password_hash на users
  (NULL у обычных Ghost/Telegram-юзеров, тот же паттерн, что и у seed_hash/
  tg_id), can_manage_nodes, commission_percent, partner_code,
  referred_by_partner_id; новые таблицы partner_commissions и
  withdrawal_requests.
- auth/guard.rs — admin_guard заменён на owner_guard/staff_guard/
  node_manage_guard/partner_guard; ADMIN_TG_ID убран из ApiState и main.rs.
- auth/service.rs — hash_password/login_staff со случайной Argon2-солью на
  каждого юзера (не общий фиксированный ARGON_SALT, который годится для
  высокоэнтропийных seed-логинов, но не для человеческих паролей).
- POST /api/v1/auth/admin/login — вход модератора/партнёра/овнера паролем,
  переиспользует существующий issue_session().
- --create-owner CLI-флаг (main.rs) со скрытым вводом пароля (rpassword) —
  вместо ручного SQL для создания первого владельца с VPS-консоли.
- src/modules/staff/ — эндпоинты создания модераторов/партнёров, переключения
  can_manage_nodes, списка заявок на вывод и их approve/reject/paid.
- Комиссия партнёра начисляется в BillingService::confirm_payment отдельным
  шагом после существующей рефералки, пересчитывается через
  get_plan_price(invoice.plan_name, invoice.currency) — НЕ через
  invoice.amount напрямую, потому что у провайдеров разная внутренняя
  единица (TON — nanoTON+2% буфер, CryptoBot — фиатные центы напрямую).
- bot.rs — /start p_XXXXXX привязывает покупателя к партнёру по коду
  (first-wins, до существующих веток numeric-tg_id-рефералки и кода входа).
- frontend/ — AdminLogin/AdminLayout/StaffPage/WithdrawalsPage/EarningsPage
  поверх существующего Vite-админ-фронта (без отдельного проекта).

Оплата через @CryptoBot
------------------------
- modules/billing/providers/cryptobot.rs — новый PaymentProvider (Crypto Pay
  API), плюс попутно исправлены два реальных бага в существующем коде:
  confirm_payment был жёстко завязан на TON-провайдера при любом инвойсе, и
  InvoiceRecord не хранил currency, из-за чего TON-курс молча применялся ко
  всем провайдерам вместо родной валюты инвойса.
- bot.rs — экран выбора способа оплаты для CryptoBot-инвойсов.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 22:53:02 +07:00
nineap c8eea8203e Добавить проверку токена прокси, динамические лимиты трафика и Telegram magic-link вход
Прокси теперь может валидировать клиентские JWT и отчитываться о расходе
трафика через новые internal-эндпоинты (защищены X-Internal-Secret), лимит
на юзера настраивается динамически через admin PATCH без передеплоя прокси.
Заодно реализован задокументированный, но не подключённый флоу входа через
Telegram-бота (magic-link) для десктоп/мобильного приложения, и seed-эндпоинты
теперь возвращают access_token в JSON (не только в cookie) — без этого
Tauri-приложение не могло ими пользоваться.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 00:30:25 +07:00
nineap 1a26403afb Переключить dev Caddy на порт 443 вместо 8443
Telegram Login Widget в браузере требует именно порт 443 — его CSP
frame-ancestors от oauth.telegram.org не учитывает порт домена и всегда
подразумевает дефолтный порт схемы, поэтому на 8443 виджет рендерился, но
iframe блокировался как cross-origin. VPN-нода, ранее занимавшая 443 на
dev VPS, перенесена на другой порт, так что конфликта больше нет.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 20:00:09 +07:00
nineap 3af0c46130 docs: reflect runtime bot_username config in frontend README
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 14:23:31 +07:00
nineap 818d4fa384 Serve bot_username at runtime instead of baking it into the SPA build
Auth.tsx and Profile.tsx read import.meta.env.VITE_BOT_USERNAME — a Vite
build-time constant. This ЛК frontend is a single static bundle built
once in CI and served identically by both prod and dev (no
server-rendering to re-inject a value per environment, unlike the
Next.js landing fix from earlier). So the Telegram Login Widget on the
dev stand was rendering with whatever bot the CI build happened to be
compiled with — in practice the fallback default, the prod bot's
username — and Telegram correctly refused it with "Bot domain invalid"
since that bot's registered domain is the prod domain, not
dev.netrunner-vpn.com.

Added GET /api/v1/config (unauthenticated, reads BOT_USERNAME from the
server's actual environment on every request) and switched both files to
fetch it at runtime instead of reading the baked-in env var. Verified
locally: the dev container's /api/v1/config correctly returns its own
dev bot's username, and a real headless Chrome run confirms the Login
Widget script tag gets that value, not the hardcoded fallback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 14:21:55 +07:00
nineap 4f1efc6ff9 Fix infinite reload loop on the login screen itself
apiFetch()'s fallback for a failed silent-refresh unconditionally does
window.location.href = "/" — but Auth.tsx (the "/" route) calls this same
apiFetch("/users/me") on mount just to check whether a session already
exists. For any fresh/anonymous visit that 401s (the normal, expected
case), the failed-refresh branch fired a navigation to "/" while already
sitting on "/" — which browsers still treat as a real reload, not a
no-op. That reload remounts the app, which repeats the exact same check,
which fails the exact same way: an infinite reload loop, observed live as
the page "loading forever" and — since the bot's WebApp bridge lands on
/profile and bounces to "/" on any auth hiccup — the "Личный Кабинет"
button appearing to do nothing.

Only redirect when not already on "/". Verified with a real headless
Chrome run (Playwright): before, this path can't be distinguished from a
one-shot failure without watching for repeated navigation; confirmed here
that with the guard in place the app performs exactly one GET /users/me +
one POST /auth/refresh and then sits idle, no repeat requests or
navigations over 12s of observation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 14:03:45 +07:00
nineap 4a8816b908 Build the Caddy image in CI instead of on the dev VPS
The previous deploy attempt confirmed the failure mode directly: `docker
compose build caddy` over SSH got most of the way through compiling
xcaddy (downloading Go modules) and then the whole SSH command hit
appleboy/ssh-action's timeout mid-build, aborting the deploy before it
ever reached `up -d`. Compiling anything on that VPS was already a
deliberate anti-pattern here — see DEPLOYMENT.md's "compilation happens
in GitHub Actions, never on the VPS" — this just hadn't been applied to
the new Caddy service yet.

Added a build-caddy job to build.yml pushing
ghcr.io/nineap/netrunner-caddy-cloudflare:latest (hardcoded lowercase
owner — github.repository_owner resolves to "nineAp" and Docker rejects
mixed-case image refs, same fix already applied to the main image's
deploy script). docker-compose.dev-remote.yml now just pulls that image;
deploy.yml no longer builds it over SSH or ships Dockerfile.caddy to the
server at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 13:52:07 +07:00
nineap d22cd0de41 Add Caddy for real TLS on dev — Telegram Web Apps require https://
Bot buttons that open a Web App (Личный Кабинет/Тарифы/Синдикат) appeared
to load and then silently do nothing on the dev stand. Root cause:
teloxide's WebAppInfo::url (and the underlying Bot API) requires an HTTPS
URL — Telegram's client refuses to render a Web App served over plain
HTTP, with no error surfaced to the user, just a stuck loading state.
WEB_APP_BASE_URL was http://dev.netrunner-vpn.com:8080.

Port 443 on the dev VPS is already in use by a VPN node, and port 80 is
closed, so a normal HTTP-01 ACME challenge won't work. Added a custom
Caddy build (caddy-dns/cloudflare) terminating TLS via DNS-01 on port
8443 instead, reverse-proxying to the existing plain-HTTP app container.
WEB_APP_BASE_URL and the CORS/CSRF allowlists now point at the :8443
origin, and COOKIE_SECURE reverts to true (the default, matching prod) —
now that the backend actually has real TLS to serve it over, we no
longer need the plain-HTTP workaround introduced for the earlier
cookie-drop bug. Verified the custom image builds and the Caddyfile
validates (only the dummy Cloudflare token used for the local test is
rejected, as expected).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 19:36:38 +07:00
47 changed files with 4959 additions and 141 deletions
+33 -9
View File
@@ -6,16 +6,37 @@
# BotFather принимает только доменное имя. На этот dev VPS (45.76.161.137) # BotFather принимает только доменное имя. На этот dev VPS (45.76.161.137)
# заведён DNS A-record dev.netrunner-vpn.com — используй его во всех # заведён DNS A-record dev.netrunner-vpn.com — используй его во всех
# URL ниже (и в аналогичном .env для netrunner-landing), а не IP напрямую. # URL ниже (и в аналогичном .env для netrunner-landing), а не IP напрямую.
#
# ВАЖНО #2: Telegram Web Apps (кнопки бота "Личный Кабинет"/"Тарифы"/
# "Синдикат") требуют https:// URL, а Telegram Login Widget в обычном браузере
# требует именно порт 443 (его CSP frame-ancestors не учитывает порт и всегда
# подразумевает дефолтный для схемы) — оба жёсткие требования платформы, не
# нашего кода. HTTPS для dev поднят через Caddy (docker-compose.dev-remote.yml)
# на порту 443, сертификат — через DNS-01 challenge (Cloudflare, порт 80
# закрыт — обычный HTTP-01 не пройдёт). Нужен CF_API_TOKEN ниже. Порт 443
# раньше занимала VPN-нода — перенесена на другой порт на этом же VPS.
# ===================================================================== # =====================================================================
DB_USER=netrunner_admin DB_USER=netrunner_admin
DB_PASSWORD=CHANGE_ME_DEV_DB_PASSWORD DB_PASSWORD=CHANGE_ME_DEV_DB_PASSWORD
DB_NAME=netrunner DB_NAME=netrunner
# Токен Cloudflare для DNS-01 (выпуск TLS-сертификата Caddy'ем без открытых
# 80/443) — создать в Cloudflare: My Profile → API Tokens → Create Token →
# шаблон "Edit zone DNS", ограниченный зоной netrunner-vpn.com. НЕ Global API Key.
CF_API_TOKEN=CHANGE_ME_CLOUDFLARE_DNS_EDIT_TOKEN
JWT_SECRET=CHANGE_ME_DEV_JWT_SECRET JWT_SECRET=CHANGE_ME_DEV_JWT_SECRET
ARGON_SALT=CHANGE_ME_DEV_ARGON_SALT_MIN_16_CHARS ARGON_SALT=CHANGE_ME_DEV_ARGON_SALT_MIN_16_CHARS
TON_MASTER_WALLET=UQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKK8y TON_MASTER_WALLET=UQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKK8y
ADMIN_TG_ID=0 ADMIN_TG_ID=0
# Опционально: без него оплата через @CryptoBot просто вернёт "provider not
# supported", остальное продолжает работать как раньше. Токен берётся у
# @CryptoBot: /pay -> Create App -> Payment Token (лучше завести отдельное
# приложение для dev, чтобы инвойсы не путались с прод-платежами).
CRYPTOBOT_API_TOKEN=CHANGE_ME_CRYPTOBOT_API_TOKEN
CRYPTOBOT_TESTNET=true
BOT_ENABLED=true BOT_ENABLED=true
# ОТДЕЛЬНЫЙ dev-бот, не прод-бот: у прод-бота в BotFather уже прописан # ОТДЕЛЬНЫЙ dev-бот, не прод-бот: у прод-бота в BotFather уже прописан
# прод-домен через /setdomain, и один бот не может обслуживать два разных # прод-домен через /setdomain, и один бот не может обслуживать два разных
@@ -25,21 +46,24 @@ BOT_ENABLED=true
# использует прод-бота и Telegram ответит "Bot domain invalid". # использует прод-бота и Telegram ответит "Bot domain invalid".
TELOXIDE_TOKEN=123456789:YOUR_DEV_BOT_TOKEN_HERE TELOXIDE_TOKEN=123456789:YOUR_DEV_BOT_TOKEN_HERE
BOT_USERNAME=your_dev_bot_username BOT_USERNAME=your_dev_bot_username
WEB_APP_BASE_URL=http://dev.netrunner-vpn.com:8080 # https, порт 443 — через Caddy (см. выше), не порт 8080 напрямую: WebApp
# от Telegram по http:// открывать откажется.
WEB_APP_BASE_URL=https://dev.netrunner-vpn.com
SUBSCRIPTION_CHECK_INTERVAL=1800 SUBSCRIPTION_CHECK_INTERVAL=1800
RUST_LOG=netrunner_control_plane=debug,axum=info,tower_http=debug,sqlx=warn RUST_LOG=netrunner_control_plane=debug,axum=info,tower_http=debug,sqlx=warn
# dev.netrunner-vpn.com:3000 — netrunner-landing, задеплоенный на этот же dev # dev.netrunner-vpn.com:3000 — netrunner-landing, задеплоенный на этот же dev
# VPS (docker-compose.dev-remote.yml в репозитории netrunner-landing). # VPS (docker-compose.dev-remote.yml в репозитории netrunner-landing), обычный
# HTTP. dev.netrunner-vpn.com — сам бэкенд через Caddy (см. выше).
# COOKIE_DOMAIN нарочно пуст: host-only cookie уже расшаривается между # COOKIE_DOMAIN нарочно пуст: host-only cookie уже расшаривается между
# портами одного и того же хоста браузером — Domain нужен только для # портами одного и того же хоста браузером — Domain нужен только для
# реальных сабдоменов (прод). # реальных сабдоменов (прод).
CORS_ALLOWED_ORIGINS=http://localhost:1420,http://localhost:5173,tauri://localhost,http://tauri.localhost,http://dev.netrunner-vpn.com:3000 CORS_ALLOWED_ORIGINS=http://localhost:1420,http://localhost:5173,tauri://localhost,http://tauri.localhost,http://dev.netrunner-vpn.com:3000,https://dev.netrunner-vpn.com
CSRF_ALLOWED_ORIGINS=http://localhost:1420,http://localhost:5173,http://dev.netrunner-vpn.com:3000 CSRF_ALLOWED_ORIGINS=http://localhost:1420,http://localhost:5173,http://dev.netrunner-vpn.com:3000,https://dev.netrunner-vpn.com
COOKIE_DOMAIN= COOKIE_DOMAIN=
# ОБЯЗАТЕЛЬНО false: на этом VPS нет TLS (прямой HTTP на IP/домен, порт 443 занят # true (как в проде): теперь у бэкенда есть настоящий TLS через Caddy на 443.
# VPN-нодой). Secure-cookie по голому HTTP браузер молча выбрасывает для любого # Прямой доступ на голый :8080 (без Caddy) для браузерных auth-флоу больше не
# хоста, кроме localhost, — с COOKIE_SECURE=true (дефолт) сессия не переживёт # годится — Secure-cookie по нему браузер по-прежнему отбросит, это ожидаемо:
# вообще ни одного запроса на этом стенде. # все клиентские сценарии (бот, лендинг) идут через Caddy на 443.
COOKIE_SECURE=false COOKIE_SECURE=true
+6
View File
@@ -24,6 +24,12 @@ TON_MASTER_WALLET=UQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKK8y
ADMIN_TG_ID=0 ADMIN_TG_ID=0
TELOXIDE_TOKEN=123456789:YOUR_DEV_BOT_TOKEN_HERE TELOXIDE_TOKEN=123456789:YOUR_DEV_BOT_TOKEN_HERE
# Опционально: без него оплата через @CryptoBot вернёт "provider not
# supported", остальное работает как обычно. Токен — у @CryptoBot: /pay ->
# Create App -> Payment Token.
CRYPTOBOT_API_TOKEN=
CRYPTOBOT_TESTNET=true
# CORS: пускаем локальные дев-origin'ы Vite/Tauri-приложения. # CORS: пускаем локальные дев-origin'ы Vite/Tauri-приложения.
CORS_ALLOWED_ORIGINS=http://localhost:1420,http://localhost:5173,tauri://localhost,http://tauri.localhost CORS_ALLOWED_ORIGINS=http://localhost:1420,http://localhost:5173,tauri://localhost,http://tauri.localhost
CSRF_ALLOWED_ORIGINS=http://localhost:1420,http://localhost:5173 CSRF_ALLOWED_ORIGINS=http://localhost:1420,http://localhost:5173
+5
View File
@@ -25,6 +25,11 @@ ARGON_SALT=какая-то-рандомная-длинная-строка-мин
GRAFANA_PASSWORD=netrunner_admin_2026 GRAFANA_PASSWORD=netrunner_admin_2026
TON_MASTER_WALLET=UQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKK8y TON_MASTER_WALLET=UQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKK8y
# Опционально: без него оплата через @CryptoBot вернёт "provider not
# supported", остальные провайдеры продолжают работать. Токен — у @CryptoBot:
# /pay -> Create App -> Payment Token.
CRYPTOBOT_API_TOKEN=
# ===================================================================== # =====================================================================
# === TELEGRAM API INTEGRATION === # === TELEGRAM API INTEGRATION ===
# ===================================================================== # =====================================================================
+98
View File
@@ -0,0 +1,98 @@
# Копия .github/workflows/build.yml для Gitea Actions. Отличие от оригинала:
# логин на ghcr.io использует secrets.GHCR_TOKEN (настоящий GitHub PAT с
# правами write:packages), а не secrets.GITHUB_TOKEN — автотокен Gitea валиден
# только для самого Gitea (API/пакеты этого инстанса), на ghcr.io им не
# залогиниться. Секрет GHCR_TOKEN нужно завести отдельно в настройках этого
# репозитория/организации в Gitea.
name: Build & Push Image
on:
push:
branches: [main]
tags: ["v*"]
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository_owner }}/netrunner-control-plane
jobs:
build:
name: Build and push to GHCR
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,format=long
type=ref,event=tag
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Кеш слоёв между прогонами — повторные сборки в разы быстрее.
cache-from: type=gha
cache-to: type=gha,mode=max
# Кастомная сборка Caddy (модуль caddy-dns/cloudflare, нужен для TLS на
# dev-стенде — см. Caddyfile.dev). Компилировать xcaddy'ем прямо на VPS
# оказалось слишком долго и один раз реально уронило SSH-деплой по таймауту
# (компиляция go-модулей на слабой машине не укладывалась в command_timeout
# appleboy/ssh-action) — та же причина, по которой Rust вообще не собирается
# на VPS. Собираем здесь и просто тянем готовый образ, как и основной.
build-caddy:
name: Build and push Caddy (Cloudflare DNS) image
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.caddy
push: true
# Захардкожено, не ${{ github.repository_owner }}: он резолвится в
# "nineAp" (смешанный регистр), Docker такие теги отклоняет — та же
# причина, по которой deploy.yml тоже хардкодит "nineap" для
# основного образа вместо репозиторного контекста.
tags: ${{ env.REGISTRY }}/nineap/netrunner-caddy-cloudflare:latest
cache-from: type=gha,scope=caddy
cache-to: type=gha,mode=max,scope=caddy
+54
View File
@@ -0,0 +1,54 @@
name: CI
on:
pull_request:
push:
branches: [main]
env:
CARGO_TERM_COLOR: always
SQLX_OFFLINE: "true"
jobs:
check:
name: fmt + clippy + test
runs-on: ubuntu-24.04
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
# sqlx::test сам создаёт временные БД и накатывает ./migrations.
DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Cache cargo
uses: Swatinem/rust-cache@v2
- name: Format check
run: cargo fmt --all -- --check
- name: Clippy
run: cargo clippy --all-targets -- -D warnings
- name: Tests
run: cargo test --all-targets
+98
View File
@@ -0,0 +1,98 @@
# Копия .github/workflows/deploy.yml для Gitea Actions. secrets.GHCR_TOKEN
# здесь уже был настоящим PAT (не автотокеном), менять не пришлось.
# Триггер workflow_run (авто-деплой после успешной сборки) поддерживается не
# во всех версиях Gitea Actions — если deploy-dev не срабатывает автоматически
# после build, проверить версию Gitea или временно дергать деплой вручную
# через workflow_dispatch.
name: Deploy
# Один пайплайн, две джобы:
# - deploy-dev: авто, срабатывает после успешного "Build & Push Image" на main.
# - deploy-prod: вручную из вкладки Actions ("Run workflow"), как и раньше.
on:
workflow_run:
workflows: ["Build & Push Image"]
types: [completed]
branches: [main]
workflow_dispatch:
inputs:
image_tag:
description: "Тег образа в GHCR для прод-деплоя"
required: true
default: "latest"
jobs:
deploy-dev:
name: Auto-deploy to Dev VPS
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_sha }}
# Копируем compose-файл + Caddyfile (примонтированный конфиг, не образ —
# сам Caddy-образ, как и образ бэкенда, дев-сервер тянет из GHCR, собран
# в build.yml::build-caddy. Собирать xcaddy прямо на VPS один раз уже не
# уложилось в таймаут SSH-деплоя и уронило весь прогон).
- name: Sync compose file to Dev VPS
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.DEV_VPS_IP }}
username: root
key: ${{ secrets.DEV_SSH_PRIVATE_KEY }}
source: "docker-compose.dev-remote.yml,Caddyfile.dev"
target: "/root/netrunner-core"
strip_components: 0
- name: Remote deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEV_VPS_IP }}
username: root
key: ${{ secrets.DEV_SSH_PRIVATE_KEY }}
script: |
set -e
cd /root/netrunner-core
export IMAGE="ghcr.io/nineap/netrunner-control-plane:latest"
export CADDY_IMAGE="ghcr.io/nineap/netrunner-caddy-cloudflare:latest"
echo "🚀 Deploying ${IMAGE} to DEV"
echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.repository_owner }} --password-stdin
docker compose -f docker-compose.dev-remote.yml pull
docker compose -f docker-compose.dev-remote.yml up -d --remove-orphans
docker image prune -f
echo "✅ Dev synchronized."
deploy-prod:
name: Pull & restart on Prod VPS
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
steps:
- name: Remote deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_IP }}
username: root
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
set -e
cd /root/netrunner-core
export IMAGE="ghcr.io/nineap/netrunner-control-plane:${{ github.event.inputs.image_tag }}"
echo "🚀 Deploying ${IMAGE}"
echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.repository_owner }} --password-stdin
# Тянем свежий образ, прогоняем миграции (one-shot) и поднимаем сервисы.
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d --remove-orphans
# Чистим старые слои.
docker image prune -f
echo "✅ Uplink synchronized."
+39
View File
@@ -51,3 +51,42 @@ jobs:
# Кеш слоёв между прогонами — повторные сборки в разы быстрее. # Кеш слоёв между прогонами — повторные сборки в разы быстрее.
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
# Кастомная сборка Caddy (модуль caddy-dns/cloudflare, нужен для TLS на
# dev-стенде — см. Caddyfile.dev). Компилировать xcaddy'ем прямо на VPS
# оказалось слишком долго и один раз реально уронило SSH-деплой по таймауту
# (компиляция go-модулей на слабой машине не укладывалась в command_timeout
# appleboy/ssh-action) — та же причина, по которой Rust вообще не собирается
# на VPS. Собираем здесь и просто тянем готовый образ, как и основной.
build-caddy:
name: Build and push Caddy (Cloudflare DNS) image
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.caddy
push: true
# Захардкожено, не ${{ github.repository_owner }}: он резолвится в
# "nineAp" (смешанный регистр), Docker такие теги отклоняет — та же
# причина, по которой deploy.yml тоже хардкодит "nineap" для
# основного образа вместо репозиторного контекста.
tags: ${{ env.REGISTRY }}/nineap/netrunner-caddy-cloudflare:latest
cache-from: type=gha,scope=caddy
cache-to: type=gha,mode=max,scope=caddy
+6 -3
View File
@@ -25,15 +25,17 @@ jobs:
with: with:
ref: ${{ github.event.workflow_run.head_sha }} ref: ${{ github.event.workflow_run.head_sha }}
# Копируем только compose-файл — сам образ дев-сервер тянет из GHCR, # Копируем compose-файл + Caddyfile (примонтированный конфиг, не образ —
# полный клон репозитория на нём не нужен. # сам Caddy-образ, как и образ бэкенда, дев-сервер тянет из GHCR, собран
# в build.yml::build-caddy. Собирать xcaddy прямо на VPS один раз уже не
# уложилось в таймаут SSH-деплоя и уронило весь прогон).
- name: Sync compose file to Dev VPS - name: Sync compose file to Dev VPS
uses: appleboy/scp-action@v0.1.7 uses: appleboy/scp-action@v0.1.7
with: with:
host: ${{ secrets.DEV_VPS_IP }} host: ${{ secrets.DEV_VPS_IP }}
username: root username: root
key: ${{ secrets.DEV_SSH_PRIVATE_KEY }} key: ${{ secrets.DEV_SSH_PRIVATE_KEY }}
source: "docker-compose.dev-remote.yml" source: "docker-compose.dev-remote.yml,Caddyfile.dev"
target: "/root/netrunner-core" target: "/root/netrunner-core"
strip_components: 0 strip_components: 0
@@ -48,6 +50,7 @@ jobs:
cd /root/netrunner-core cd /root/netrunner-core
export IMAGE="ghcr.io/nineap/netrunner-control-plane:latest" export IMAGE="ghcr.io/nineap/netrunner-control-plane:latest"
export CADDY_IMAGE="ghcr.io/nineap/netrunner-caddy-cloudflare:latest"
echo "🚀 Deploying ${IMAGE} to DEV" echo "🚀 Deploying ${IMAGE} to DEV"
echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.repository_owner }} --password-stdin echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.repository_owner }} --password-stdin
+17
View File
@@ -0,0 +1,17 @@
# Реверс-прокси с реальным TLS для dev VPS — нужен и потому, что Telegram Web
# Apps (кнопки бота "Личный Кабинет"/"Тарифы"/"Синдикат") требуют https:// URL
# (см. teloxide WebAppInfo::url), и потому, что Telegram Login Widget в
# обычном браузере требует именно порт 443 — его CSP `frame-ancestors` от
# oauth.telegram.org не учитывает порт в зарегистрированном в BotFather домене
# и всегда подразумевает дефолтный порт схемы (443 для https); на 8443 виджет
# рендерился, но браузер блокировал iframe как cross-origin.
#
# Порт 80 на этом VPS закрыт — обычный HTTP-01 ACME challenge не пройдёт,
# поэтому TLS через DNS-01 (Cloudflare, см. Dockerfile.caddy), которому
# открытые 80/443 не нужны для самого выпуска сертификата.
dev.netrunner-vpn.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
reverse_proxy app:8080
}
Generated
+25
View File
@@ -2181,6 +2181,7 @@ dependencies = [
"rand 0.8.6", "rand 0.8.6",
"redis", "redis",
"reqwest 0.11.27", "reqwest 0.11.27",
"rpassword",
"rust_decimal", "rust_decimal",
"serde", "serde",
"serde_json", "serde_json",
@@ -3035,6 +3036,17 @@ dependencies = [
"syn 1.0.109", "syn 1.0.109",
] ]
[[package]]
name = "rpassword"
version = "7.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
dependencies = [
"libc",
"rtoolbox",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "rsa" name = "rsa"
version = "0.9.10" version = "0.9.10"
@@ -3055,6 +3067,16 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rtoolbox"
version = "0.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "rust_decimal" name = "rust_decimal"
version = "1.42.0" version = "1.42.0"
@@ -3553,6 +3575,7 @@ dependencies = [
"memchr", "memchr",
"once_cell", "once_cell",
"percent-encoding", "percent-encoding",
"rust_decimal",
"rustls", "rustls",
"serde", "serde",
"serde_json", "serde_json",
@@ -3637,6 +3660,7 @@ dependencies = [
"percent-encoding", "percent-encoding",
"rand 0.8.6", "rand 0.8.6",
"rsa", "rsa",
"rust_decimal",
"serde", "serde",
"sha1", "sha1",
"sha2 0.10.9", "sha2 0.10.9",
@@ -3676,6 +3700,7 @@ dependencies = [
"memchr", "memchr",
"once_cell", "once_cell",
"rand 0.8.6", "rand 0.8.6",
"rust_decimal",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.10.9", "sha2 0.10.9",
+2 -1
View File
@@ -12,7 +12,7 @@ tower_governor = "0.8"
teloxide = { version = "0.17", features = ["macros", "rustls"] } teloxide = { version = "0.17", features = ["macros", "rustls"] }
# 🔥 ИСПРАВЛЕНО: Включаем "migrate" для автоматического создания баз в тестах # 🔥 ИСПРАВЛЕНО: Включаем "migrate" для автоматического создания баз в тестах
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "migrate"] } sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "migrate", "rust_decimal"] }
redis = { version = "1.2.0", features = ["tokio-comp", "aio", "connection-manager"] } redis = { version = "1.2.0", features = ["tokio-comp", "aio", "connection-manager"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
@@ -37,6 +37,7 @@ clap = { version = "4.6.1", features = ["derive"] }
ssh2 = "0.9.5" ssh2 = "0.9.5"
rand = "0.8" rand = "0.8"
argon2 = "0.5" argon2 = "0.5"
rpassword = "7.5"
rust_decimal = { version = "1.42.0", features = ["serde"] } rust_decimal = { version = "1.42.0", features = ["serde"] }
reqwest = { version = "0.11", features = ["json"] } reqwest = { version = "0.11", features = ["json"] }
+20 -5
View File
@@ -61,6 +61,11 @@ Dev-окружение отдельного ручного шага не тре
`GITHUB_TOKEN` для push в GHCR выдаётся автоматически. `GITHUB_TOKEN` для push в GHCR выдаётся автоматически.
Отдельно на самом dev VPS (не GitHub-секрет, а переменная в `.env` рядом с
`docker-compose.dev-remote.yml`) — `CF_API_TOKEN`: Cloudflare-токен для Caddy
(DNS-01 challenge, см. ниже), шаблон "Edit zone DNS", ограниченный зоной
`netrunner-vpn.com`. Не Global API Key.
### Что должно лежать на VPS (`/root/netrunner-core`) ### Что должно лежать на VPS (`/root/netrunner-core`)
- `docker-compose.prod.yml` - `docker-compose.prod.yml`
@@ -73,14 +78,24 @@ Dev-окружение отдельного ручного шага не тре
### Что должно лежать на dev VPS (`/root/netrunner-core`) ### Что должно лежать на dev VPS (`/root/netrunner-core`)
- `docker-compose.dev-remote.yml` - `docker-compose.dev-remote.yml`, `Dockerfile.caddy`, `Caddyfile.dev` (все три
синкает CI при каждом `deploy-dev`, руками копировать не нужно)
- `.env` (см. [`.env.dev-remote.example`](.env.dev-remote.example) — свои - `.env` (см. [`.env.dev-remote.example`](.env.dev-remote.example) — свои
`JWT_SECRET`/`ARGON_SALT`/`DB_PASSWORD`, `CORS_ALLOWED_ORIGINS`/`CSRF_ALLOWED_ORIGINS` `JWT_SECRET`/`ARGON_SALT`/`DB_PASSWORD`, `CF_API_TOKEN`,
включают origin dev-лендинга на этом же хосте) `CORS_ALLOWED_ORIGINS`/`CSRF_ALLOWED_ORIGINS` включают origin dev-лендинга и
Caddy-порта на этом же хосте)
- `keys/netrunner_master_ed25519[.pub]` — свой мастер-ключ, отдельный от прода - `keys/netrunner_master_ed25519[.pub]` — свой мастер-ключ, отдельный от прода
Dev-стек без мониторинга: `migrate``app` (порт `8080`, публично, без reverse-proxy) Dev-стек: `migrate``app` (порт `8080`, обычный HTTP, без TLS) + `db` + `redis`
+ `db` + `redis`. + `caddy` (порт `443`, реальный TLS через DNS-01/Cloudflare — см.
[`Caddyfile.dev`](Caddyfile.dev)). Caddy здесь нужен по двум причинам: Telegram
Web Apps (кнопки бота "Личный Кабинет"/"Тарифы"/"Синдикат") требуют `https://`
URL, а Telegram Login Widget в обычном браузере требует именно порт 443 (его
CSP `frame-ancestors` не учитывает порт и всегда подразумевает дефолтный порт
схемы). Порт 80 на этом VPS закрыт — обычный HTTP-01 challenge не проходит,
поэтому DNS-01. Все браузерные auth-флоу (бот, лендинг) должны идти через
Caddy на 443, не напрямую в `:8080``COOKIE_SECURE=true` (как в проде), и
Secure-cookie по голому HTTP на `:8080` браузер по-прежнему отбросит.
### Локальная сборка образа (фолбэк, обычно не нужно) ### Локальная сборка образа (фолбэк, обычно не нужно)
+9
View File
@@ -0,0 +1,9 @@
# syntax=docker/dockerfile:1
# Кастомная сборка Caddy с DNS-провайдером Cloudflare — нужна для DNS-01
# ACME challenge на dev VPS, где порты 80/443 недоступны/заняты (443 —
# VPN-нодой, 80 закрыт). DNS-01 не требует открытых входящих 80/443 вообще.
FROM caddy:2-builder AS builder
RUN xcaddy build --with github.com/caddy-dns/cloudflare
FROM caddy:2
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
+27
View File
@@ -76,9 +76,36 @@ services:
networks: networks:
- netrunner_grid_dev - netrunner_grid_dev
# Реверс-прокси с реальным TLS (DNS-01 через Cloudflare) — нужен для
# Telegram Web Apps (требуют https://) и для Telegram Login Widget в
# обычном браузере (требует именно порт 443 — см. Caddyfile.dev). VPN-нода,
# которая раньше занимала 443 на этом VPS, перенесена на другой порт.
#
# Образ собирается в GitHub Actions (build.yml::build-caddy), не на VPS:
# компиляция xcaddy (Go, тянет много модулей) на этой машине один раз уже
# не уложилась в таймаут SSH-деплоя и уронила весь прогон.
caddy:
image: ${CADDY_IMAGE:-ghcr.io/nineap/netrunner-caddy-cloudflare:latest}
container_name: netrunner-caddy-dev
restart: always
ports:
- "443:443"
environment:
- CF_API_TOKEN=${CF_API_TOKEN}
volumes:
- ./Caddyfile.dev:/etc/caddy/Caddyfile:ro
- caddy_data_dev:/data
- caddy_config_dev:/config
depends_on:
- app
networks:
- netrunner_grid_dev
networks: networks:
netrunner_grid_dev: netrunner_grid_dev:
driver: bridge driver: bridge
volumes: volumes:
netrunner_db_data_dev: netrunner_db_data_dev:
caddy_data_dev:
caddy_config_dev:
+8 -1
View File
@@ -39,4 +39,11 @@ pnpm lint
``` ```
Переменные окружения (`.env.production` / Vite `import.meta.env`): Переменные окружения (`.env.production` / Vite `import.meta.env`):
`VITE_API_BASE` (по умолчанию `/api/v1`), `VITE_BOT_USERNAME`. `VITE_API_BASE` (по умолчанию `/api/v1`).
Юзернейм бота (для Telegram Login Widget и реферальной ссылки) **не** build-time
переменная — этот SPA собирается один раз в CI и раздаётся одинаково и прод-, и
dev-стендом, так что зашитое в бандл значение не различалось бы между ними. Вместо
`VITE_BOT_USERNAME` `Auth.tsx`/`Profile.tsx` берут его рантаймом через
`GET /api/v1/config` (`api.ts::getPublicConfig`), который читает `BOT_USERNAME` из
окружения сервера заново на каждый запрос.
+85
View File
@@ -0,0 +1,85 @@
import { useEffect, useState } from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { Loader2, Server, Users, Wallet, DollarSign, LogOut } from "lucide-react";
import { fetchMyRole, logout, type StaffRole } from "./api";
const STAFF_ROLES = ["owner", "moderator", "partner"];
export default function AdminLayout() {
const navigate = useNavigate();
const [role, setRole] = useState<StaffRole | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchMyRole()
.then((r) => {
if (!STAFF_ROLES.includes(r.role)) {
navigate("/admin/login");
return;
}
setRole(r);
})
.catch(() => navigate("/admin/login"))
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-[#05050A]">
<Loader2 className="w-8 h-8 text-primary animate-spin" />
</div>
);
}
if (!role) return null;
const navClass = ({ isActive }: { isActive: boolean }) =>
`flex items-center gap-2 px-4 py-2.5 rounded-lg text-xs font-bold uppercase tracking-widest transition-colors ${
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-white/5 hover:text-foreground"
}`;
const canSeeNodes = role.role === "owner" || (role.role === "moderator" && role.can_manage_nodes);
return (
<div className="min-h-screen bg-[#05050A] font-mono">
<nav className="border-b border-white/10 px-6 py-4 flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-2 flex-wrap">
{canSeeNodes && (
<NavLink to="/admin" end className={navClass}>
<Server className="w-4 h-4" /> Ноды
</NavLink>
)}
{role.role === "owner" && (
<NavLink to="/admin/staff" className={navClass}>
<Users className="w-4 h-4" /> Staff
</NavLink>
)}
{role.role === "moderator" && (
<NavLink to="/admin/staff" className={navClass}>
<Users className="w-4 h-4" /> Партнёры
</NavLink>
)}
{(role.role === "owner" || role.role === "moderator") && (
<NavLink to="/admin/withdrawals" className={navClass}>
<Wallet className="w-4 h-4" /> Выводы
</NavLink>
)}
{role.role === "partner" && (
<NavLink to="/admin/earnings" className={navClass}>
<DollarSign className="w-4 h-4" /> Доходы
</NavLink>
)}
</div>
<button
onClick={logout}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-bold uppercase tracking-widest text-muted-foreground hover:bg-white/5 hover:text-red-500 transition-colors"
>
<LogOut className="w-4 h-4" /> Выход
</button>
</nav>
<Outlet context={role} />
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Shield, Loader2 } from "lucide-react";
import { adminLogin } from "./api";
export default function AdminLogin() {
const navigate = useNavigate();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
await adminLogin(username, password);
navigate("/admin");
} catch {
setError("Неверный логин или пароль.");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-[#05050A] font-mono px-4">
<form
onSubmit={handleSubmit}
className="w-full max-w-sm bg-white/[0.02] border border-white/10 rounded-2xl p-8 space-y-6"
>
<div className="flex flex-col items-center gap-3 text-center">
<Shield className="w-10 h-10 text-primary" />
<h1 className="text-lg font-black tracking-widest text-primary uppercase">
Admin Access
</h1>
</div>
<div className="space-y-2">
<label className="text-[10px] text-muted-foreground uppercase tracking-widest">
Логин
</label>
<input
required
autoFocus
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-lg p-3 text-sm outline-none focus:border-primary transition-colors"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] text-muted-foreground uppercase tracking-widest">
Пароль
</label>
<input
required
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-lg p-3 text-sm outline-none focus:border-primary transition-colors"
/>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full bg-primary/10 hover:bg-primary/20 text-primary border border-primary/30 font-bold uppercase tracking-widest text-xs py-3 rounded-lg transition-all flex items-center justify-center gap-2 disabled:opacity-50"
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Войти"}
</button>
</form>
</div>
);
}
+12 -1
View File
@@ -7,6 +7,11 @@ const Profile = lazy(() => import("./Profile"));
const HackGame = lazy(() => import("./HackGame")); const HackGame = lazy(() => import("./HackGame"));
const AdminDashboard = lazy(() => import("./AdminDashboard")); const AdminDashboard = lazy(() => import("./AdminDashboard"));
const AuthClient = lazy(() => import("./Auth")); const AuthClient = lazy(() => import("./Auth"));
const AdminLogin = lazy(() => import("./AdminLogin"));
const AdminLayout = lazy(() => import("./AdminLayout"));
const StaffPage = lazy(() => import("./StaffPage"));
const WithdrawalsPage = lazy(() => import("./WithdrawalsPage"));
const EarningsPage = lazy(() => import("./EarningsPage"));
const DICT = { const DICT = {
en: { loading: "Loading protocols..." }, en: { loading: "Loading protocols..." },
@@ -83,7 +88,13 @@ export default function App() {
<Route path="/" element={<AuthClient />} /> <Route path="/" element={<AuthClient />} />
<Route path="/profile" element={<Profile />} /> <Route path="/profile" element={<Profile />} />
<Route path="/hack" element={<HackGame />} /> <Route path="/hack" element={<HackGame />} />
<Route path="/admin" element={<AdminDashboard />} /> <Route path="/admin/login" element={<AdminLogin />} />
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<AdminDashboard />} />
<Route path="staff" element={<StaffPage />} />
<Route path="withdrawals" element={<WithdrawalsPage />} />
<Route path="earnings" element={<EarningsPage />} />
</Route>
</Routes> </Routes>
</AuthHandler> </AuthHandler>
</BrowserRouter> </BrowserRouter>
+12 -3
View File
@@ -10,7 +10,7 @@ import {
Key, Key,
Download, Download,
} from "lucide-react"; } from "lucide-react";
import { apiFetch } from "./api"; import { apiFetch, getPublicConfig } from "./api";
export interface TelegramAuthUser { export interface TelegramAuthUser {
id: number; id: number;
@@ -107,7 +107,15 @@ export default function AuthClient() {
const widgetInjected = useRef(false); const widgetInjected = useRef(false);
const API_BASE = import.meta.env.VITE_API_BASE || "/api/v1"; const API_BASE = import.meta.env.VITE_API_BASE || "/api/v1";
const BOT_USERNAME = import.meta.env.VITE_BOT_USERNAME || "ntrnr_vpn_bot"; const [BOT_USERNAME, setBotUsername] = useState("ntrnr_vpn_bot");
const [configLoaded, setConfigLoaded] = useState(false);
useEffect(() => {
getPublicConfig().then((cfg) => {
setBotUsername(cfg.bot_username);
setConfigLoaded(true);
});
}, []);
useEffect(() => { useEffect(() => {
if (initialized.current) return; if (initialized.current) return;
@@ -179,6 +187,7 @@ export default function AuthClient() {
useEffect(() => { useEffect(() => {
if ( if (
status === "awaiting" && status === "awaiting" &&
configLoaded &&
tgContainerRef.current && tgContainerRef.current &&
!widgetInjected.current !widgetInjected.current
) { ) {
@@ -192,7 +201,7 @@ export default function AuthClient() {
script.setAttribute("data-request-access", "write"); script.setAttribute("data-request-access", "write");
tgContainerRef.current.appendChild(script); tgContainerRef.current.appendChild(script);
} }
}, [status, BOT_USERNAME]); }, [status, BOT_USERNAME, configLoaded]);
const handleGhostGenerate = async () => { const handleGhostGenerate = async () => {
setStatus("processing"); setStatus("processing");
+197
View File
@@ -0,0 +1,197 @@
import { useEffect, useState } from "react";
import { RefreshCw, DollarSign, Send, Copy, Check } from "lucide-react";
import {
fetchMyEarnings,
requestWithdrawal,
getPublicConfig,
type MyEarnings,
} from "./api";
export default function EarningsPage() {
const [data, setData] = useState<MyEarnings | null>(null);
const [loading, setLoading] = useState(true);
const [botUsername, setBotUsername] = useState("");
const [copied, setCopied] = useState(false);
const [currency, setCurrency] = useState("");
const [amount, setAmount] = useState("");
const [note, setNote] = useState("");
const [error, setError] = useState("");
const load = async () => {
setLoading(true);
try {
const earnings = await fetchMyEarnings();
setData(earnings);
if (!currency && earnings.earnings.length > 0) {
setCurrency(earnings.earnings[0].currency);
}
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
getPublicConfig().then((cfg) => setBotUsername(cfg.bot_username));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const referralLink = data?.partner_code
? `https://t.me/${botUsername}?start=${data.partner_code}`
: "";
const handleCopy = async () => {
if (!referralLink) return;
await navigator.clipboard.writeText(referralLink);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
try {
await requestWithdrawal(currency, Math.round(Number(amount) * 100), note || undefined);
setAmount("");
setNote("");
await load();
} catch {
setError("Не удалось создать заявку (сумма превышает доступный остаток?).");
}
};
return (
<div className="p-6 sm:p-10 max-w-3xl mx-auto space-y-8">
<div className="flex items-center justify-between">
<h1 className="text-xl font-black tracking-widest text-primary uppercase">
Мои доходы
</h1>
<button
onClick={load}
className="p-2 bg-white/5 hover:bg-white/10 rounded-full transition-colors"
>
<RefreshCw className={`w-4 h-4 text-secondary ${loading ? "animate-spin" : ""}`} />
</button>
</div>
{referralLink && (
<div className="bg-black/40 border border-white/10 rounded-2xl p-5 flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-[10px] text-muted-foreground uppercase tracking-widest mb-1">
Ваша реферальная ссылка
</p>
<p className="font-mono text-sm truncate">{referralLink}</p>
</div>
<button
onClick={handleCopy}
className="p-2 bg-white/5 hover:bg-white/10 rounded-lg transition-colors shrink-0"
>
{copied ? <Check className="w-4 h-4 text-secondary" /> : <Copy className="w-4 h-4" />}
</button>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{(data?.earnings ?? []).map((e) => (
<div
key={e.currency}
className="bg-black/60 border border-white/5 rounded-xl p-5 space-y-2"
>
<div className="flex items-center gap-2 text-primary">
<DollarSign className="w-4 h-4" />
<span className="font-black text-lg">{e.currency}</span>
</div>
<p className="text-xs text-muted-foreground">
Заработано: <span className="text-foreground font-bold">{(e.total_earned / 100).toFixed(2)}</span>
</p>
<p className="text-xs text-muted-foreground">
Выведено: {(e.total_withdrawn / 100).toFixed(2)}
</p>
<p className="text-xs text-muted-foreground">
В обработке: {(e.pending_withdrawal / 100).toFixed(2)}
</p>
<p className="text-sm font-bold text-secondary">
Доступно: {(e.available / 100).toFixed(2)}
</p>
</div>
))}
{(data?.earnings ?? []).length === 0 && !loading && (
<p className="text-center py-10 text-muted-foreground text-sm uppercase tracking-widest sm:col-span-2">
Пока нет заработка
</p>
)}
</div>
<form
onSubmit={handleSubmit}
className="bg-black/40 border border-white/10 rounded-2xl p-6 space-y-3"
>
<h2 className="text-xs font-bold uppercase tracking-widest text-secondary flex items-center gap-2">
<Send className="w-4 h-4" /> Заявка на вывод
</h2>
<div className="flex gap-3">
<select
required
value={currency}
onChange={(e) => setCurrency(e.target.value)}
className="bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-secondary"
>
<option value="" disabled>
Валюта
</option>
{(data?.earnings ?? []).map((e) => (
<option key={e.currency} value={e.currency}>
{e.currency}
</option>
))}
</select>
<input
required
type="number"
min="0.01"
step="0.01"
placeholder="Сумма"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="flex-1 bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-secondary"
/>
</div>
<input
placeholder="Комментарий (реквизиты и т.п., необязательно)"
value={note}
onChange={(e) => setNote(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-secondary"
/>
{error && <p className="text-xs text-red-500">{error}</p>}
<button
type="submit"
className="w-full bg-secondary/10 hover:bg-secondary/20 text-secondary border border-secondary/30 font-bold uppercase tracking-widest text-xs py-2.5 rounded-lg transition-all"
>
Отправить заявку
</button>
</form>
<div className="space-y-3">
<h2 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
История заявок
</h2>
{(data?.withdrawals ?? []).map((w) => (
<div
key={w.id}
className="bg-black/60 border border-white/5 rounded-xl p-4 flex items-center justify-between"
>
<span className="font-mono text-sm">
{(w.amount / 100).toFixed(2)} {w.currency}
</span>
<span className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground">
{w.status}
</span>
</div>
))}
</div>
</div>
);
}
+6 -1
View File
@@ -25,6 +25,7 @@ import {
activateTrial, activateTrial,
initiatePayment, initiatePayment,
confirmPayment, confirmPayment,
getPublicConfig,
} from "./api"; } from "./api";
// --- ТИПИЗАЦИЯ --- // --- ТИПИЗАЦИЯ ---
@@ -199,12 +200,16 @@ export default function Profile() {
const [lang, setLang] = useState<"en" | "ru">(getLang); const [lang, setLang] = useState<"en" | "ru">(getLang);
const t = DICT[lang]; const t = DICT[lang];
const botUsername = import.meta.env.VITE_BOT_USERNAME || "ntrnr_vpn_bot"; const [botUsername, setBotUsername] = useState("ntrnr_vpn_bot");
// TON Connect Hooks // TON Connect Hooks
const [tonConnectUI] = useTonConnectUI(); const [tonConnectUI] = useTonConnectUI();
const userFriendlyAddress = useTonAddress(); const userFriendlyAddress = useTonAddress();
useEffect(() => {
getPublicConfig().then((cfg) => setBotUsername(cfg.bot_username));
}, []);
useEffect(() => { useEffect(() => {
fetchProfile() fetchProfile()
.then((res: ProfileDataResponse) => setData(res)) .then((res: ProfileDataResponse) => setData(res))
+225
View File
@@ -0,0 +1,225 @@
import { useEffect, useState } from "react";
import { useOutletContext } from "react-router-dom";
import { UserPlus, RefreshCw } from "lucide-react";
import {
createModerator,
createPartner,
fetchStaffUsers,
setNodePermission,
type StaffRole,
type StaffUser,
} from "./api";
export default function StaffPage() {
const role = useOutletContext<StaffRole>();
const [staff, setStaff] = useState<StaffUser[]>([]);
const [loading, setLoading] = useState(true);
const [modUsername, setModUsername] = useState("");
const [modPassword, setModPassword] = useState("");
const [modCanManageNodes, setModCanManageNodes] = useState(false);
const [partnerUsername, setPartnerUsername] = useState("");
const [partnerPassword, setPartnerPassword] = useState("");
const [partnerPercent, setPartnerPercent] = useState("10");
const [error, setError] = useState("");
const load = async () => {
setLoading(true);
try {
setStaff(await fetchStaffUsers());
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const handleCreateModerator = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
try {
await createModerator(modUsername, modPassword, modCanManageNodes);
setModUsername("");
setModPassword("");
setModCanManageNodes(false);
await load();
} catch {
setError("Не удалось создать модератора (логин уже занят?).");
}
};
const handleCreatePartner = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
try {
await createPartner(partnerUsername, partnerPassword, Number(partnerPercent));
setPartnerUsername("");
setPartnerPassword("");
setPartnerPercent("10");
await load();
} catch {
setError("Не удалось создать партнёра (логин уже занят?).");
}
};
const handleTogglePermission = async (userId: string, current: boolean) => {
try {
await setNodePermission(userId, !current);
await load();
} catch (e) {
console.error(e);
}
};
return (
<div className="p-6 sm:p-10 max-w-5xl mx-auto space-y-8">
<div className="flex items-center justify-between">
<h1 className="text-xl font-black tracking-widest text-primary uppercase">
Staff
</h1>
<button
onClick={load}
className="p-2 bg-white/5 hover:bg-white/10 rounded-full transition-colors"
>
<RefreshCw className={`w-4 h-4 text-secondary ${loading ? "animate-spin" : ""}`} />
</button>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{role.role === "owner" && (
<form
onSubmit={handleCreateModerator}
className="bg-black/40 border border-white/10 rounded-2xl p-6 space-y-3"
>
<h2 className="text-xs font-bold uppercase tracking-widest text-secondary flex items-center gap-2">
<UserPlus className="w-4 h-4" /> Новый модератор
</h2>
<input
required
placeholder="Логин"
value={modUsername}
onChange={(e) => setModUsername(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-secondary"
/>
<input
required
type="password"
placeholder="Пароль"
value={modPassword}
onChange={(e) => setModPassword(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-secondary"
/>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
<input
type="checkbox"
checked={modCanManageNodes}
onChange={(e) => setModCanManageNodes(e.target.checked)}
className="accent-secondary"
/>
Право управлять нодами
</label>
<button
type="submit"
className="w-full bg-secondary/10 hover:bg-secondary/20 text-secondary border border-secondary/30 font-bold uppercase tracking-widest text-xs py-2.5 rounded-lg transition-all"
>
Создать
</button>
</form>
)}
<form
onSubmit={handleCreatePartner}
className="bg-black/40 border border-white/10 rounded-2xl p-6 space-y-3"
>
<h2 className="text-xs font-bold uppercase tracking-widest text-primary flex items-center gap-2">
<UserPlus className="w-4 h-4" /> Новый партнёр
</h2>
<input
required
placeholder="Логин"
value={partnerUsername}
onChange={(e) => setPartnerUsername(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-primary"
/>
<input
required
type="password"
placeholder="Пароль"
value={partnerPassword}
onChange={(e) => setPartnerPassword(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-primary"
/>
<div className="flex items-center gap-2">
<input
required
type="number"
min="0"
max="100"
step="0.01"
value={partnerPercent}
onChange={(e) => setPartnerPercent(e.target.value)}
className="w-24 bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-primary"
/>
<span className="text-xs text-muted-foreground">% с продаж</span>
</div>
<button
type="submit"
className="w-full bg-primary/10 hover:bg-primary/20 text-primary border border-primary/30 font-bold uppercase tracking-widest text-xs py-2.5 rounded-lg transition-all"
>
Создать
</button>
</form>
</div>
<div className="space-y-3">
<h2 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
Список staff
</h2>
{staff.map((u) => (
<div
key={u.id}
className="bg-black/60 border border-white/5 rounded-xl p-4 flex items-center justify-between gap-4"
>
<div>
<div className="flex items-center gap-2">
<span className="font-bold text-sm">{u.username}</span>
<span className="text-[10px] bg-white/10 px-2 py-0.5 rounded uppercase text-muted-foreground">
{u.role}
</span>
</div>
{u.role === "partner" && (
<p className="text-xs text-muted-foreground font-mono mt-1">
{u.commission_percent}% · код: {u.partner_code}
</p>
)}
</div>
{role.role === "owner" && u.role === "moderator" && (
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
<input
type="checkbox"
checked={u.can_manage_nodes}
onChange={() => handleTogglePermission(u.id, u.can_manage_nodes)}
className="accent-secondary"
/>
Ноды
</label>
)}
</div>
))}
{staff.length === 0 && !loading && (
<p className="text-center py-10 text-muted-foreground text-sm uppercase tracking-widest">
Пока никого нет
</p>
)}
</div>
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
import { useEffect, useState } from "react";
import { RefreshCw, Check, X, Banknote } from "lucide-react";
import { fetchWithdrawals, updateWithdrawal, type WithdrawalRequest } from "./api";
const STATUS_COLOR: Record<string, string> = {
pending: "text-yellow-500",
approved: "text-secondary",
paid: "text-green-500",
rejected: "text-red-500",
};
export default function WithdrawalsPage() {
const [list, setList] = useState<WithdrawalRequest[]>([]);
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState<string | null>(null);
const load = async () => {
setLoading(true);
try {
setList(await fetchWithdrawals());
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const handleUpdate = async (id: string, status: "approved" | "rejected" | "paid") => {
setBusyId(id);
try {
await updateWithdrawal(id, status);
await load();
} catch (e) {
console.error(e);
} finally {
setBusyId(null);
}
};
return (
<div className="p-6 sm:p-10 max-w-4xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-black tracking-widest text-primary uppercase">
Заявки на вывод
</h1>
<button
onClick={load}
className="p-2 bg-white/5 hover:bg-white/10 rounded-full transition-colors"
>
<RefreshCw className={`w-4 h-4 text-secondary ${loading ? "animate-spin" : ""}`} />
</button>
</div>
<div className="space-y-3">
{list.map((w) => (
<div
key={w.id}
className="bg-black/60 border border-white/5 rounded-xl p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-3"
>
<div>
<div className="flex items-center gap-2">
<Banknote className="w-4 h-4 text-muted-foreground" />
<span className="font-bold font-mono">
{(w.amount / 100).toFixed(2)} {w.currency}
</span>
<span className={`text-[10px] uppercase tracking-widest font-bold ${STATUS_COLOR[w.status]}`}>
{w.status}
</span>
</div>
{w.note && (
<p className="text-xs text-muted-foreground mt-1">{w.note}</p>
)}
<p className="text-[10px] text-muted-foreground/60 font-mono mt-1">
{new Date(w.created_at).toLocaleString()}
</p>
</div>
{w.status === "pending" && (
<div className="flex items-center gap-2">
<button
disabled={busyId === w.id}
onClick={() => handleUpdate(w.id, "approved")}
className="p-2 bg-secondary/10 hover:bg-secondary/20 text-secondary border border-secondary/20 rounded-lg transition-colors disabled:opacity-40"
title="Одобрить"
>
<Check className="w-4 h-4" />
</button>
<button
disabled={busyId === w.id}
onClick={() => handleUpdate(w.id, "rejected")}
className="p-2 bg-red-500/10 hover:bg-red-500/20 text-red-500 border border-red-500/20 rounded-lg transition-colors disabled:opacity-40"
title="Отклонить"
>
<X className="w-4 h-4" />
</button>
</div>
)}
{w.status === "approved" && (
<button
disabled={busyId === w.id}
onClick={() => handleUpdate(w.id, "paid")}
className="px-3 py-2 bg-green-500/10 hover:bg-green-500/20 text-green-500 border border-green-500/20 rounded-lg text-xs font-bold uppercase tracking-widest transition-colors disabled:opacity-40"
>
Отметить выплаченным
</button>
)}
</div>
))}
{list.length === 0 && !loading && (
<p className="text-center py-10 text-muted-foreground text-sm uppercase tracking-widest">
Заявок нет
</p>
)}
</div>
</div>
);
}
+170 -1
View File
@@ -1,5 +1,23 @@
export const API_BASE = import.meta.env.VITE_API_BASE || "/api/v1"; export const API_BASE = import.meta.env.VITE_API_BASE || "/api/v1";
let configPromise: Promise<{ bot_username: string }> | null = null;
/// Юзернейм бота — намеренно не `import.meta.env.VITE_BOT_USERNAME`: этот SPA
/// собирается один раз в CI, и один и тот же статический бандл раздаётся и
/// прод-, и dev-стендом — build-time значение зашилось бы в файлы намертво и
/// не различалось бы между стендами (ровно так и вышло на практике: dev видел
/// прод-бота, у которого в BotFather другой домен — виджет логина отвечал
/// "Bot domain invalid"). `/api/v1/config` читает env на сервере заново на
/// каждый запрос, поэтому корректно отдаёт "свой" бот для каждого стенда.
export function getPublicConfig(): Promise<{ bot_username: string }> {
if (!configPromise) {
configPromise = fetch(`${API_BASE}/config`)
.then((res) => res.json())
.catch(() => ({ bot_username: "ntrnr_vpn_bot" }));
}
return configPromise;
}
// Сессия живёт только в HttpOnly cookie, которую ставит сервер — JS её не видит // Сессия живёт только в HttpOnly cookie, которую ставит сервер — JS её не видит
// и не должен: раньше здесь читался `document.cookie` в расчёте на токен, // и не должен: раньше здесь читался `document.cookie` в расчёте на токен,
// который сервер ставит как HttpOnly, поэтому чтение всегда возвращало null, а // который сервер ставит как HttpOnly, поэтому чтение всегда возвращало null, а
@@ -35,6 +53,13 @@ function toRequestInit(options: RequestInit): RequestInit {
/// Единая обёртка над fetch для авторизованных запросов: всегда с cookie, /// Единая обёртка над fetch для авторизованных запросов: всегда с cookie,
/// при первом 401 пробует silent-refresh и повторяет запрос один раз, при /// при первом 401 пробует silent-refresh и повторяет запрос один раз, при
/// провале — чистит сторону клиента и уводит на экран логина. /// провале — чистит сторону клиента и уводит на экран логина.
///
/// Редирект пропускается, если мы и так уже на `/` (экран логина): иначе
/// `window.location.href = "/"` там же, где мы уже находимся, всё равно
/// вызывает полную перезагрузку страницы — React-приложение перемонтируется,
/// Auth.tsx заново дёргает тот же `/users/me`, получает тот же 401 (для
/// анонимного визита это нормальное, ожидаемое состояние, а не сбой) — и
/// уходит в бесконечный цикл перезагрузок.
export async function apiFetch( export async function apiFetch(
path: string, path: string,
options: RequestInit = {}, options: RequestInit = {},
@@ -46,7 +71,13 @@ export async function apiFetch(
const refreshed = await refreshSession(); const refreshed = await refreshSession();
if (!refreshed) { if (!refreshed) {
window.location.href = "/"; // Админка (Owner/Moderator/Partner) логинится отдельно от обычных
// Ghost/Telegram-юзеров — редирект на "/" был бы неверным экраном для неё.
const isAdminPath = window.location.pathname.startsWith("/admin");
const loginPath = isAdminPath ? "/admin/login" : "/";
if (window.location.pathname !== loginPath) {
window.location.href = loginPath;
}
return res; return res;
} }
@@ -134,6 +165,144 @@ export const exchangeBootstrapToken = async (token: string): Promise<boolean> =>
return res.ok; return res.ok;
}; };
// --- Ролевая админка (Owner/Moderator/Partner) ---
export const adminLogin = async (username: string, password: string) => {
const res = await fetch(`${API_BASE}/auth/admin/login`, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
return parseOrThrow(res, "Login failed");
};
export interface StaffRole {
role: "owner" | "moderator" | "partner" | "user" | "admin";
can_manage_nodes: boolean;
username: string | null;
partner_code: string | null;
commission_percent: string | null;
}
export const fetchMyRole = async (): Promise<StaffRole> => {
const res = await apiFetch("/admin/staff/me");
return parseOrThrow(res, "Failed to fetch role");
};
export const createModerator = async (
username: string,
password: string,
canManageNodes: boolean,
) => {
const res = await apiFetch("/admin/staff/moderators", {
method: "POST",
body: JSON.stringify({
username,
password,
can_manage_nodes: canManageNodes,
}),
});
return parseOrThrow(res, "Failed to create moderator");
};
export const createPartner = async (
username: string,
password: string,
commissionPercent: number,
) => {
const res = await apiFetch("/admin/staff/partners", {
method: "POST",
body: JSON.stringify({
username,
password,
commission_percent: commissionPercent,
}),
});
return parseOrThrow(res, "Failed to create partner");
};
export interface StaffUser {
id: string;
username: string | null;
role: string;
can_manage_nodes: boolean;
commission_percent: string | null;
partner_code: string | null;
}
export const fetchStaffUsers = async (): Promise<StaffUser[]> => {
const res = await apiFetch("/admin/staff/staff-users");
return parseOrThrow(res, "Failed to fetch staff users");
};
export const setNodePermission = async (userId: string, canManageNodes: boolean) => {
const res = await apiFetch(`/admin/staff/users/${userId}/permissions`, {
method: "PATCH",
body: JSON.stringify({ can_manage_nodes: canManageNodes }),
});
return parseOrThrow(res, "Failed to update permissions");
};
export interface WithdrawalRequest {
id: string;
partner_id: string;
currency: string;
amount: number;
status: "pending" | "approved" | "rejected" | "paid";
note: string | null;
created_at: string;
processed_at: string | null;
processed_by: string | null;
}
export const fetchWithdrawals = async (): Promise<WithdrawalRequest[]> => {
const res = await apiFetch("/admin/staff/withdrawals");
return parseOrThrow(res, "Failed to fetch withdrawals");
};
export const updateWithdrawal = async (
id: string,
status: "approved" | "rejected" | "paid",
) => {
const res = await apiFetch(`/admin/staff/withdrawals/${id}`, {
method: "PATCH",
body: JSON.stringify({ status }),
});
return parseOrThrow(res, "Failed to update withdrawal");
};
export interface PartnerEarnings {
currency: string;
total_earned: number;
total_withdrawn: number;
pending_withdrawal: number;
available: number;
}
export interface MyEarnings {
earnings: PartnerEarnings[];
withdrawals: WithdrawalRequest[];
partner_code: string | null;
}
export const fetchMyEarnings = async (): Promise<MyEarnings> => {
const res = await apiFetch("/admin/partners/earnings");
return parseOrThrow(res, "Failed to fetch earnings");
};
export const requestWithdrawal = async (
currency: string,
amount: number,
note?: string,
) => {
const res = await apiFetch("/admin/partners/withdrawals", {
method: "POST",
body: JSON.stringify({ currency, amount, note }),
});
return parseOrThrow(res, "Failed to submit withdrawal request");
};
export const logout = async () => { export const logout = async () => {
await fetch(`${API_BASE}/auth/logout`, { await fetch(`${API_BASE}/auth/logout`, {
method: "POST", method: "POST",
+35
View File
@@ -0,0 +1,35 @@
-- =====================================================================
-- ДИНАМИЧЕСКИЕ ЛИМИТЫ ТРАФИКА ПРОКСИ
-- =====================================================================
-- data_limit_bytes = NULL означает безлимит. Лимит читается прокси-сервером
-- на каждой проверке (POST /api/v1/internal/validate) и на каждом отчёте о
-- расходе (POST /api/v1/internal/usage) — админ меняет его в любой момент
-- через PATCH /api/v1/admin/users/{id}/limit без перезапуска прокси.
ALTER TABLE users ADD COLUMN IF NOT EXISTS data_limit_bytes BIGINT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS data_used_bytes BIGINT NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN IF NOT EXISTS data_period_reset_at TIMESTAMPTZ;
-- Дефолтный лимит тарифа — проставляется юзеру в data_limit_bytes при покупке
-- подписки (см. AppRepository::buy_subscription), чтобы не заводить отдельный
-- админ-флоу на каждую покупку. NULL = тариф без предустановленного лимита.
ALTER TABLE plans ADD COLUMN IF NOT EXISTS default_limit_bytes BIGINT;
-- =====================================================================
-- MAGIC-LINK ВХОД ЧЕРЕЗ TELEGRAM-БОТА ДЛЯ ДЕСКТОП/МОБИЛЬНОГО ПРИЛОЖЕНИЯ
-- =====================================================================
-- auth_sessions была создана в 0001_init.sql, но 0002_refresh_sessions.sql
-- (репурпоз черновика Magic Link под refresh-токены) её DROP'нул и не
-- пересоздал — на любой БД, накатываемой с нуля (CI, sqlx::test, новый
-- инстанс), следующий ALTER TABLE падал на несуществующей таблице. CREATE
-- TABLE IF NOT EXISTS чинит fresh-инсталл; ALTER/CREATE INDEX ниже остаются
-- IF NOT EXISTS на случай окружений, где таблица уже существует в старом виде.
CREATE TABLE IF NOT EXISTS auth_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID,
auth_code VARCHAR(8) NOT NULL UNIQUE,
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE auth_sessions ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
CREATE INDEX IF NOT EXISTS idx_auth_sessions_code ON auth_sessions(auth_code);
+68
View File
@@ -0,0 +1,68 @@
-- =====================================================================
-- РОЛЕВАЯ АДМИНКА: Owner / Moderator / Partner
-- =====================================================================
-- Раньше единственным способом получить админ-доступ была привязка к
-- ADMIN_TG_ID (см. AuthGuard::admin_guard) — один захардкоженный Telegram-
-- аккаунт на весь деплой. Теперь роль полноценно хранится в БД, у
-- Owner/Moderator/Partner есть логин+пароль (не Telegram/seed), а
-- ADMIN_TG_ID выпиливается из кода вместе с этой миграцией.
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check;
ALTER TABLE users
ADD CONSTRAINT users_role_check
CHECK (role IN ('user', 'owner', 'moderator', 'partner', 'admin'));
-- 'admin' оставлен в допустимых значениях только ради обратной
-- совместимости с уже существующими строками — новый код его не
-- присваивает нигде.
ALTER TABLE users ADD COLUMN IF NOT EXISTS username VARCHAR(50) UNIQUE;
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT;
-- Право модератора управлять нодами — переключается Owner'ом поштучно,
-- а не зашито в саму роль (см. AuthGuard::node_manage_guard).
ALTER TABLE users ADD COLUMN IF NOT EXISTS can_manage_nodes BOOLEAN NOT NULL DEFAULT FALSE;
-- Фиксированный процент партнёра с продаж приведённых им покупателей.
ALTER TABLE users ADD COLUMN IF NOT EXISTS commission_percent NUMERIC(5,2);
-- Короткий код для диплинка t.me/<bot>?start=<partner_code> — префикс "p_"
-- нужен боту, чтобы однозначно отличать партнёрский код от numeric
-- referrer-tg_id и от auth_sessions.auth_code (см. bot.rs::Command::Start).
ALTER TABLE users ADD COLUMN IF NOT EXISTS partner_code VARCHAR(20) UNIQUE;
-- Постоянная привязка покупателя к партнёру, выставляется один раз при
-- регистрации через диплинк (first-wins, см. set_referred_by_partner).
ALTER TABLE users ADD COLUMN IF NOT EXISTS referred_by_partner_id UUID REFERENCES users(id);
CREATE INDEX IF NOT EXISTS idx_users_referred_by_partner ON users(referred_by_partner_id)
WHERE referred_by_partner_id IS NOT NULL;
-- Леджер начислений — по одной записи на оплаченный инвойс приведённого
-- покупателя, а не бегущий итог на одной строке (нужна история для вывода
-- средств). Валюта — валюта самого инвойса, без конвертации (см. BillingService::confirm_payment).
CREATE TABLE IF NOT EXISTS partner_commissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
partner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
currency VARCHAR(10) NOT NULL,
amount BIGINT NOT NULL,
percent_at_time NUMERIC(5,2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(invoice_id) -- защита от повторного начисления при retry confirm_payment
);
CREATE INDEX IF NOT EXISTS idx_partner_commissions_partner_currency
ON partner_commissions(partner_id, currency);
CREATE TABLE IF NOT EXISTS withdrawal_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
partner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
currency VARCHAR(10) NOT NULL,
amount BIGINT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'approved', 'rejected', 'paid')),
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed_at TIMESTAMPTZ,
processed_by UUID REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_withdrawal_requests_partner_status
ON withdrawal_requests(partner_id, status);
CREATE INDEX IF NOT EXISTS idx_withdrawal_requests_status ON withdrawal_requests(status);
+44 -3
View File
@@ -29,7 +29,9 @@ use crate::modules::{
auth::{controller as auth_ctrl, service::AuthService}, auth::{controller as auth_ctrl, service::AuthService},
billing::{controller as bill_ctrl, service::BillingService}, billing::{controller as bill_ctrl, service::BillingService},
nodes::{controller as node_ctrl, service::NodeService}, nodes::{controller as node_ctrl, service::NodeService},
proxy_internal,
referrals::{controller as ref_ctrl, service::ReferralService}, referrals::{controller as ref_ctrl, service::ReferralService},
staff::{controller as staff_ctrl, service::StaffService},
users::{controller as user_ctrl, service::UserService}, users::{controller as user_ctrl, service::UserService},
}; };
@@ -40,9 +42,18 @@ pub struct ApiState {
pub node_service: NodeService, pub node_service: NodeService,
pub referral_service: ReferralService, pub referral_service: ReferralService,
pub user_service: UserService, pub user_service: UserService,
pub staff_service: StaffService,
pub jwt_secret: String, pub jwt_secret: String,
pub bot_token: String, pub bot_token: String,
pub admin_tg_id: i64, /// Юзернейм бота (без `@`) — отдаётся фронту через `GET /api/v1/config`.
/// Не публичный build-time env (`VITE_BOT_USERNAME`): фронт — статический
/// SPA-бандл, собираемый один раз в CI и одинаковый что для прод-, что для
/// dev-деплоя, значение зашилось бы намертво в конкретную сборку и не
/// подхватывало бы разные боты на разных стендах (ровно так и оказалось на
/// практике — dev-стенд показывал прод-бота, у которого домен в BotFather
/// не совпадает с dev-доменом → "Bot domain invalid"). Читается на сервере
/// заново на каждый запрос, поэтому корректно различается между стендами.
pub bot_username: String,
/// `Domain` для сессионных cookie. Пусто (по умолчанию, локальная разработка) → /// `Domain` для сессионных cookie. Пусто (по умолчанию, локальная разработка) →
/// cookie host-only. В проде — `.netrunner-vpn.com`, чтобы одна сессия работала /// cookie host-only. В проде — `.netrunner-vpn.com`, чтобы одна сессия работала
/// и на лендинге, и на ЛК (общий родительский домен, разные сабдомены). /// и на лендинге, и на ЛК (общий родительский домен, разные сабдомены).
@@ -58,6 +69,9 @@ pub struct ApiState {
/// (CSRF-проверка в `auth::guard`). Отдельно от `CORS_ALLOWED_ORIGINS`, т.к. туда /// (CSRF-проверка в `auth::guard`). Отдельно от `CORS_ALLOWED_ORIGINS`, т.к. туда
/// же попадают Bearer-клиенты (Tauri), для которых CSRF неактуален. /// же попадают Bearer-клиенты (Tauri), для которых CSRF неактуален.
pub csrf_allowed_origins: Vec<String>, pub csrf_allowed_origins: Vec<String>,
/// Общий секрет для `netrunner-proxy` (`X-Internal-Secret`) — не пользовательская
/// авторизация, см. `modules::proxy_internal::internal_secret_guard`.
pub proxy_internal_secret: String,
} }
impl Clone for ApiState { impl Clone for ApiState {
@@ -69,12 +83,14 @@ impl Clone for ApiState {
node_service: self.node_service.clone(), node_service: self.node_service.clone(),
referral_service: self.referral_service.clone(), referral_service: self.referral_service.clone(),
user_service: self.user_service.clone(), user_service: self.user_service.clone(),
staff_service: self.staff_service.clone(),
jwt_secret: self.jwt_secret.clone(), jwt_secret: self.jwt_secret.clone(),
bot_token: self.bot_token.clone(), bot_token: self.bot_token.clone(),
admin_tg_id: self.admin_tg_id, bot_username: self.bot_username.clone(),
cookie_domain: self.cookie_domain.clone(), cookie_domain: self.cookie_domain.clone(),
cookie_secure: self.cookie_secure, cookie_secure: self.cookie_secure,
csrf_allowed_origins: self.csrf_allowed_origins.clone(), csrf_allowed_origins: self.csrf_allowed_origins.clone(),
proxy_internal_secret: self.proxy_internal_secret.clone(),
} }
} }
} }
@@ -119,9 +135,27 @@ pub fn create_router(state: ApiState, frontend_dir: &str) -> Router {
.nest("/billing", bill_ctrl::router(state.clone())) .nest("/billing", bill_ctrl::router(state.clone()))
.nest("/nodes", node_ctrl::public_router(state.clone())) .nest("/nodes", node_ctrl::public_router(state.clone()))
.nest("/referrals", ref_ctrl::router(state.clone())) .nest("/referrals", ref_ctrl::router(state.clone()))
.route("/config", get(get_public_config))
.layer(GovernorLayer::new(gov_conf)); .layer(GovernorLayer::new(gov_conf));
let admin_routes_v1 = Router::new().nest("/nodes", node_ctrl::admin_router(state.clone())); let admin_routes_v1 = Router::new()
.nest("/nodes", node_ctrl::admin_router(state.clone()))
.nest("/users", user_ctrl::admin_router(state.clone()))
.nest("/staff", staff_ctrl::router(state.clone()))
.nest("/staff", staff_ctrl::owner_router(state.clone()))
.nest("/staff", staff_ctrl::staff_router(state.clone()))
.nest("/partners", staff_ctrl::partner_router(state.clone()));
// Внутренний контракт для прокси-нод — отдельный секрет, не auth_guard
// (прокси не пользовательская сессия) и не участвует в CORS ниже (никогда
// не вызывается из браузера).
let internal_routes = Router::new().nest(
"/internal",
proxy_internal::router().layer(middleware::from_fn_with_state(
state.clone(),
proxy_internal::internal_secret_guard,
)),
);
// Строгий CORS. Список разрешённых origin берём из env (через запятую), // Строгий CORS. Список разрешённых origin берём из env (через запятую),
// чтобы помимо веб-фронта пускать и десктоп/мобайл-приложение (Tauri webview). // чтобы помимо веб-фронта пускать и десктоп/мобайл-приложение (Tauri webview).
@@ -145,6 +179,7 @@ pub fn create_router(state: ApiState, frontend_dir: &str) -> Router {
Router::new() Router::new()
.nest("/api/v1", api_routes_v1) .nest("/api/v1", api_routes_v1)
.nest("/api/v1/admin", admin_routes_v1) .nest("/api/v1/admin", admin_routes_v1)
.nest("/api/v1", internal_routes)
// Liveness/readiness пробы для Docker/оркестратора и балансировщика. // Liveness/readiness пробы для Docker/оркестратора и балансировщика.
.route("/health", get(health_live)) .route("/health", get(health_live))
.route("/health/ready", get(health_ready)) .route("/health/ready", get(health_ready))
@@ -163,6 +198,12 @@ pub fn create_router(state: ApiState, frontend_dir: &str) -> Router {
.with_state(state) .with_state(state)
} }
/// Публичная (без auth_guard) рантайм-конфигурация для статического SPA —
/// см. `ApiState::bot_username` про то, почему это не build-time env фронта.
async fn get_public_config(State(state): State<ApiState>) -> impl IntoResponse {
Json(json!({ "bot_username": state.bot_username }))
}
/// Liveness: процесс жив и отвечает. Не трогает зависимости. /// Liveness: процесс жив и отвечает. Не трогает зависимости.
async fn health_live() -> impl IntoResponse { async fn health_live() -> impl IntoResponse {
(StatusCode::OK, Json(json!({ "status": "ok" }))) (StatusCode::OK, Json(json!({ "status": "ok" })))
+245 -20
View File
@@ -7,7 +7,8 @@
//! `POST /auth/exchange`. //! `POST /auth/exchange`.
use crate::modules::{ use crate::modules::{
auth::service::AuthService, billing::service::BillingService, users::service::UserService, auth::service::AuthService, billing::service::BillingService,
referrals::service::ReferralService, users::service::UserService,
}; };
use teloxide::{ use teloxide::{
prelude::*, prelude::*,
@@ -38,6 +39,7 @@ pub async fn run_bot(
auth: AuthService, auth: AuthService,
users: UserService, users: UserService,
billing: BillingService, billing: BillingService,
referrals: ReferralService,
cancel_token: CancellationToken, cancel_token: CancellationToken,
) { ) {
let config = BotConfig { let config = BotConfig {
@@ -59,7 +61,7 @@ pub async fn run_bot(
bot, bot,
dptree::entry().branch(handler).branch(callback_handler), dptree::entry().branch(handler).branch(callback_handler),
) )
.dependencies(dptree::deps![auth, users, billing, config]) .dependencies(dptree::deps![auth, users, billing, referrals, config])
.build(); .build();
tokio::select! { tokio::select! {
@@ -82,14 +84,46 @@ async fn handle_commands(
let code = if ref_code.is_empty() { let code = if ref_code.is_empty() {
None None
} else { } else {
Some(ref_code) Some(ref_code.clone())
}; };
// Авторизация теперь в AuthService // Авторизация теперь в AuthService
let _ = auth let login_result = auth
.process_login(tg_id, msg.chat.username().map(String::from), code) .process_login(tg_id, msg.chat.username().map(String::from), code)
.await; .await;
// Стартовый параметр, который не парсится как tg_id реферера — три
// взаимоисключающих случая по формату: партнёрский код (префикс
// "p_" — см. StaffService::generate_partner_code), иначе
// auth_code из десктоп/мобильного приложения (magic-link вход,
// см. AuthService::create_telegram_login_code и
// POST /auth/telegram/session).
if !ref_code.is_empty() && ref_code.parse::<i64>().is_err() {
if ref_code.starts_with("p_") {
// Партнёрская привязка — постоянная, first-wins (см.
// set_referred_by_partner), безопасно вызывать даже для
// уже существующих юзеров, поэтому не гейтим на "новый юзер".
if let Ok(user) = &login_result {
if let Ok(Some(partner)) = auth.find_partner_by_code(&ref_code).await {
let _ = auth.attribute_partner_referral(partner.id, user.id).await;
}
}
} else if let Ok(user) = &login_result {
if auth
.confirm_telegram_login_code(&ref_code, user.id)
.await
.unwrap_or(false)
{
bot.send_message(
msg.chat.id,
"✅ Вход подтверждён. Вернитесь в приложение Netrunner.",
)
.await?;
return Ok(());
}
}
}
bot.send_message(msg.chat.id, main_menu_text()) bot.send_message(msg.chat.id, main_menu_text())
.parse_mode(ParseMode::Html) .parse_mode(ParseMode::Html)
.reply_markup(main_menu_keyboard()) .reply_markup(main_menu_keyboard())
@@ -105,13 +139,14 @@ async fn handle_commands(
Ok(()) Ok(())
} }
#[instrument(skip(bot, auth, users, billing, config), fields(user_id = q.from.id.0))] #[instrument(skip(bot, auth, users, billing, referrals, config), fields(user_id = q.from.id.0))]
async fn handle_callbacks( async fn handle_callbacks(
bot: Bot, bot: Bot,
q: CallbackQuery, q: CallbackQuery,
auth: AuthService, auth: AuthService,
users: UserService, users: UserService,
billing: BillingService, billing: BillingService,
referrals: ReferralService,
config: BotConfig, config: BotConfig,
) -> ResponseResult<()> { ) -> ResponseResult<()> {
if let (Some(data), Some(msg)) = (q.data, q.message) { if let (Some(data), Some(msg)) = (q.data, q.message) {
@@ -129,12 +164,21 @@ async fn handle_callbacks(
), ),
_ => "📡 Статус: <b>НЕТ ПОДПИСКИ</b>".to_string(), _ => "📡 Статус: <b>НЕТ ПОДПИСКИ</b>".to_string(),
}; };
let traffic_status = match u.data_limit_bytes {
Some(limit) => format!(
"📶 Трафик: {} из {}",
format_bytes(u.data_used_bytes),
format_bytes(limit)
),
None => format!("📶 Трафик: {} (безлимит)", format_bytes(u.data_used_bytes)),
};
let text = format!( let text = format!(
"🤖 <b>ПРОФИЛЬ ОПЕРАТИВНИКА</b>\n\n👤 Логин: @{}\n💰 Баланс: <code>{} E$</code>\n🎟 Билеты: <code>{}</code>\n\n{}", "🤖 <b>ПРОФИЛЬ ОПЕРАТИВНИКА</b>\n\n👤 Логин: @{}\n💰 Баланс: <code>{} E$</code>\n🎟 Билеты: <code>{}</code>\n\n{}\n{}",
u.tg_username.unwrap_or_else(|| "Anon".into()), u.tg_username.unwrap_or_else(|| "Anon".into()),
u.balance, u.balance,
u.game_tickets, u.game_tickets,
sub_status sub_status,
traffic_status
); );
(text, profile_keyboard()) (text, profile_keyboard())
} }
@@ -159,11 +203,27 @@ async fn handle_callbacks(
"🖥 Открыть Терминал (WebApp)", "🖥 Открыть Терминал (WebApp)",
) )
.await, .await,
// Тарифы/Синдикат ведут в тот же ЛК (Profile.tsx уже рисует и оплату через // Выбор способа оплаты: TonConnect (как раньше, через ЛК/Profile.tsx)
// TonConnect, и реферальный блок на одной странице) — единый WebApp-мост // или прямо в чате через @CryptoBot (см. menu_pay_crypto).
// через bootstrap-токен, который фронт меняет на cookie-сессию через "menu_pay" => (
// POST /auth/exchange (см. App.tsx::useBootstrapTokenExchange). "💳 <b>ТАРИФНЫЙ ПЛАН</b>\n\nВыберите способ оплаты:".into(),
"menu_pay" => webapp_bridge_response( InlineKeyboardMarkup::new(vec![
vec![InlineKeyboardButton::callback(
"💠 TonConnect (кошелёк)",
"menu_pay_ton",
)],
vec![InlineKeyboardButton::callback(
"💎 Крипта через @CryptoBot",
"menu_pay_crypto",
)],
vec![InlineKeyboardButton::callback("🔙 Назад", "menu_main")],
]),
),
// Тот же ЛК (Profile.tsx уже рисует оплату через TonConnect и
// реферальный блок) — единый WebApp-мост через bootstrap-токен,
// который фронт меняет на cookie-сессию через POST /auth/exchange
// (см. App.tsx::useBootstrapTokenExchange).
"menu_pay_ton" => webapp_bridge_response(
&auth, &auth,
&users, &users,
&config, &config,
@@ -172,15 +232,171 @@ async fn handle_callbacks(
"💳 Выбрать тариф (WebApp)", "💳 Выбрать тариф (WebApp)",
) )
.await, .await,
"menu_ref" => webapp_bridge_response( // Оплата криптой прямо в чате, без WebApp/кошелька — юзер платит
&auth, // внутри @CryptoBot, возвращается и жмёт "Я оплатил".
&users, "menu_pay_crypto" => match billing.list_plans_usd().await {
&config, Ok(plans) if !plans.is_empty() => {
tg_id, let rows = plans
"🔗 <b>СИНДИКАТ</b>\n\nОткройте Терминал, чтобы увидеть свою реферальную ссылку и статистику.", .chunks(1)
"🔗 Открыть Синдикат (WebApp)", .map(|chunk| {
chunk
.iter()
.map(|(name, cents)| {
InlineKeyboardButton::callback(
format!("{} — ${:.2}", name, *cents as f64 / 100.0),
format!("pay_plan:{}", name),
) )
.await, })
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
let mut kb = rows;
kb.push(vec![InlineKeyboardButton::callback("🔙 Назад", "menu_pay")]);
(
"💎 <b>ОПЛАТА ЧЕРЕЗ CRYPTOBOT</b>\n\nВыберите тариф:".into(),
InlineKeyboardMarkup::new(kb),
)
}
Ok(_) => (
"Тарифы временно недоступны.".into(),
back_keyboard(),
),
Err(e) => {
tracing::error!(error = ?e, "Не удалось получить список тарифов");
("Ошибка при получении тарифов.".into(), back_keyboard())
}
},
data if data.starts_with("pay_plan:") => {
let plan_name = &data["pay_plan:".len()..];
match users.get_user_profile(tg_id).await {
Ok(Some(u)) => match billing.initiate_payment(&u, plan_name, "CRYPTOBOT", "USD").await {
Ok(checkout) => {
let invoice_id = checkout["invoice_id"].as_str().unwrap_or_default();
let cryptobot_invoice_id =
checkout["cryptobot_invoice_id"].as_i64().unwrap_or_default();
let pay_url = checkout["pay_url"].as_str().unwrap_or_default();
let kb = InlineKeyboardMarkup::new(vec![
vec![InlineKeyboardButton::url(
"💎 Оплатить в CryptoBot",
pay_url.parse().unwrap_or_else(|_| {
"https://t.me/CryptoBot".parse().unwrap()
}),
)],
vec![InlineKeyboardButton::callback(
"✅ Я оплатил, проверить",
format!("pay_check:{}:{}", invoice_id, cryptobot_invoice_id),
)],
vec![InlineKeyboardButton::callback("🔙 Назад", "menu_pay_crypto")],
]);
(
format!(
"💎 <b>ОПЛАТА: {}</b>\n\nОткройте ссылку и оплатите в @CryptoBot, затем вернитесь и нажмите «Я оплатил».",
plan_name
),
kb,
)
}
Err(e) => {
tracing::error!(error = ?e, tg_id, plan_name, "Не удалось создать инвойс CryptoBot");
(
"Не удалось создать счёт на оплату. Попробуйте позже.".into(),
back_keyboard(),
)
}
},
Ok(None) => (
"Оперативник не найден в базе данных.".into(),
back_keyboard(),
),
Err(e) => {
tracing::error!(error = ?e, tg_id, "КРИТИЧЕСКАЯ ОШИБКА при загрузке профиля для оплаты");
("Ошибка связи с ядром системы.".into(), back_keyboard())
}
}
}
data if data.starts_with("pay_check:") => {
let rest = &data["pay_check:".len()..];
let (invoice_id_str, cryptobot_invoice_id) =
rest.split_once(':').unwrap_or((rest, ""));
match (
uuid::Uuid::parse_str(invoice_id_str),
users.get_user_profile(tg_id).await,
) {
(Ok(invoice_id), Ok(Some(u))) => {
match billing
.confirm_payment(u.id, invoice_id, cryptobot_invoice_id)
.await
{
Ok(()) => (
"✅ Оплата подтверждена, тариф активирован!".into(),
back_keyboard(),
),
Err(e) => {
tracing::warn!(error = ?e, tg_id, "Оплата CryptoBot ещё не подтверждена");
(
"⏳ Оплата пока не найдена. Если вы уже оплатили — подождите немного и нажмите ещё раз.".into(),
InlineKeyboardMarkup::new(vec![
vec![InlineKeyboardButton::callback(
"🔄 Проверить снова",
data,
)],
vec![InlineKeyboardButton::callback("🔙 Назад", "menu_main")],
]),
)
}
}
}
_ => (
"Некорректные данные платежа.".into(),
back_keyboard(),
),
}
}
// Рефералка выводится нативно (без WebApp) — все данные уже есть
// на бэкенде (ReferralService::get_stats), а оплата (единственное,
// что реально требует браузера/кошелька) сюда не входит.
"menu_ref" => match users.get_user_profile(tg_id).await {
Ok(Some(u)) => match referrals.get_stats(u.id).await {
Ok((count, total_earned)) => (
format!(
"🔗 <b>СИНДИКАТ</b>\n\nВаша реферальная ссылка:\n<code>https://t.me/{}?start={}</code>\n\n👥 Приглашено: <b>{}</b>\n💰 Заработано: <code>{} E$</code>",
config.bot_username, tg_id, count, total_earned
),
back_keyboard(),
),
Err(e) => {
tracing::error!(error = ?e, tg_id, "Не удалось получить статистику рефералки");
("Ошибка при получении статистики.".into(), back_keyboard())
}
},
Ok(None) => (
"Оперативник не найден в базе данных.".into(),
back_keyboard(),
),
Err(e) => {
tracing::error!(error = ?e, tg_id, "КРИТИЧЕСКАЯ ОШИБКА при загрузке профиля для рефералки");
("Ошибка связи с ядром системы.".into(), back_keyboard())
}
},
// Код для входа в десктоп/мобильное приложение — см. докстринг
// AuthService::create_bot_login_code про то, почему это надёжнее
// deep-link-варианта (`?start=`).
"menu_app_login" => {
let username = q.from.username.clone();
match auth.create_bot_login_code(tg_id, username).await {
Ok(code) => (
format!(
"🔑 <b>ВХОД В ПРИЛОЖЕНИЕ</b>\n\nВаш код: <code>{}</code>\n\nВведите его в Netrunner-приложении на экране входа (вкладка Telegram). Код действует 5 минут.",
code
),
back_keyboard(),
),
Err(e) => {
tracing::error!(error = ?e, tg_id, "Не удалось создать код входа для приложения");
("Не удалось создать код, попробуйте ещё раз.".into(), back_keyboard())
}
}
}
_ => ("В разработке...".into(), back_keyboard()), _ => ("В разработке...".into(), back_keyboard()),
}; };
@@ -232,6 +448,11 @@ async fn webapp_bridge_response(
} }
} }
fn format_bytes(bytes: i64) -> String {
const GB: f64 = 1_000_000_000.0;
format!("{:.2} ГБ", bytes as f64 / GB)
}
fn main_menu_text() -> String { fn main_menu_text() -> String {
"<b>NETRUNNER OS</b>\nДобро пожаловать в систему управления узлами.".into() "<b>NETRUNNER OS</b>\nДобро пожаловать в систему управления узлами.".into()
} }
@@ -245,6 +466,10 @@ fn main_menu_keyboard() -> InlineKeyboardMarkup {
"🔗 Синдикат (Рефералы)", "🔗 Синдикат (Рефералы)",
"menu_ref", "menu_ref",
)], )],
vec![InlineKeyboardButton::callback(
"🔑 Код для входа в приложение",
"menu_app_login",
)],
]) ])
} }
fn profile_keyboard() -> InlineKeyboardMarkup { fn profile_keyboard() -> InlineKeyboardMarkup {
+58
View File
@@ -19,6 +19,61 @@ pub struct User {
pub has_used_trial: bool, pub has_used_trial: bool,
pub seed_hash: Option<String>, // Новое поле pub seed_hash: Option<String>, // Новое поле
pub last_hack_at: Option<chrono::DateTime<chrono::Utc>>, pub last_hack_at: Option<chrono::DateTime<chrono::Utc>>,
/// NULL = безлимит. Читается/пишется прокси через internal-эндпоинты.
pub data_limit_bytes: Option<i64>,
pub data_used_bytes: i64,
pub data_period_reset_at: Option<chrono::DateTime<chrono::Utc>>,
/// Логин для входа Owner/Moderator/Partner (не Telegram/seed). NULL у
/// обычных VPN-пользователей.
pub username: Option<String>,
#[serde(skip_serializing)]
pub password_hash: Option<String>,
/// Право модератора управлять нодами — переключается Owner'ом поштучно
/// (см. `AuthGuard::node_manage_guard`), не зашито в саму роль.
pub can_manage_nodes: bool,
/// Фиксированный процент партнёра с продаж приведённых им покупателей.
pub commission_percent: Option<rust_decimal::Decimal>,
/// Код диплинка `t.me/<bot>?start=<partner_code>` — префикс `p_`.
pub partner_code: Option<String>,
/// Партнёр, чей диплинк привёл этого пользователя (проставляется один
/// раз при регистрации, first-wins — см. `set_referred_by_partner`).
pub referred_by_partner_id: Option<Uuid>,
}
#[derive(Debug, Clone, FromRow, Serialize)]
pub struct PartnerCommission {
pub id: Uuid,
pub partner_id: Uuid,
pub invoice_id: Uuid,
pub user_id: Uuid,
pub currency: String,
pub amount: i64,
pub percent_at_time: rust_decimal::Decimal,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, FromRow, Serialize)]
pub struct WithdrawalRequest {
pub id: Uuid,
pub partner_id: Uuid,
pub currency: String,
pub amount: i64,
pub status: String,
pub note: Option<String>,
pub created_at: DateTime<Utc>,
pub processed_at: Option<DateTime<Utc>>,
pub processed_by: Option<Uuid>,
}
/// Разбивка заработка/вывода партнёра по одной валюте — считается без
/// конвертации, партнёр видит несколько таких строк ("$45" + "₽690").
#[derive(Debug, Clone, Serialize)]
pub struct PartnerEarningsByCurrency {
pub currency: String,
pub total_earned: i64,
pub total_withdrawn: i64,
pub pending_withdrawal: i64,
pub available: i64,
} }
#[derive(Debug, Clone, FromRow)] #[derive(Debug, Clone, FromRow)]
@@ -86,6 +141,9 @@ pub struct ProfileData {
pub game_tickets: i32, pub game_tickets: i32,
pub subscription: Option<SubscriptionData>, pub subscription: Option<SubscriptionData>,
pub referrals: Vec<ReferralData>, pub referrals: Vec<ReferralData>,
/// `None` = безлимит. Расходуется прокси, читается на `VpnStats`-индикаторе приложения.
pub data_limit_bytes: Option<i64>,
pub data_used_bytes: i64,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
+602 -9
View File
@@ -16,6 +16,8 @@ pub struct InvoiceRecord {
pub id: Uuid, pub id: Uuid,
pub user_id: Uuid, pub user_id: Uuid,
pub plan_name: String, pub plan_name: String,
pub provider: String,
pub currency: String,
pub amount: i64, pub amount: i64,
pub status: String, pub status: String,
} }
@@ -39,6 +41,34 @@ pub trait AppRepository: Send + Sync {
async fn create_seed_user(&self, seed_hash: &str) -> Result<User, sqlx::Error>; async fn create_seed_user(&self, seed_hash: &str) -> Result<User, sqlx::Error>;
async fn get_user_by_seed_hash(&self, hash: &str) -> Result<Option<User>, sqlx::Error>; async fn get_user_by_seed_hash(&self, hash: &str) -> Result<Option<User>, sqlx::Error>;
// --- ЛИМИТЫ ТРАФИКА (используется прокси через internal-эндпоинты) ---
/// Атомарно прибавляет `delta_bytes` к счётчику расхода и возвращает
/// свежие `(data_used_bytes, data_limit_bytes)`. `None`, если юзера нет.
async fn apply_usage_delta(
&self,
user_id: Uuid,
delta_bytes: i64,
) -> Result<Option<(i64, Option<i64>)>, sqlx::Error>;
async fn set_data_limit(
&self,
user_id: Uuid,
limit_bytes: Option<i64>,
) -> Result<Option<User>, sqlx::Error>;
// --- MAGIC-LINK ВХОД ЧЕРЕЗ TELEGRAM-БОТА (см. modules::auth) ---
async fn create_auth_session(&self, auth_code: &str) -> Result<(), sqlx::Error>;
async fn get_auth_session(
&self,
auth_code: &str,
) -> Result<Option<(Option<Uuid>, bool, chrono::DateTime<chrono::Utc>)>, sqlx::Error>;
/// Помечает сессию подтверждённой; `false`, если код не найден, уже
/// подтверждён или истёк (защита от повторного использования/гонки).
async fn verify_auth_session(
&self,
auth_code: &str,
user_id: Uuid,
) -> Result<bool, sqlx::Error>;
// --- REFRESH-СЕССИИ (единая cookie-авторизация) --- // --- REFRESH-СЕССИИ (единая cookie-авторизация) ---
async fn create_refresh_session( async fn create_refresh_session(
&self, &self,
@@ -88,6 +118,12 @@ pub trait AppRepository: Send + Sync {
currency: &str, currency: &str,
) -> Result<Option<i64>, sqlx::Error>; ) -> Result<Option<i64>, sqlx::Error>;
/// Активные тарифы с USD-ценой (в центах) — для плиточного выбора тарифа
/// в боте (см. `bot.rs::menu_pay_crypto`). USD выбран как единственная
/// валюта, гарантированно заведённая на каждый план (см. сид в
/// `0001_init.sql`), а не потому что CryptoBot требует именно её.
async fn list_active_plans_usd(&self) -> Result<Vec<(String, i64)>, sqlx::Error>;
async fn create_invoice( async fn create_invoice(
&self, &self,
user_id: Uuid, user_id: Uuid,
@@ -124,6 +160,87 @@ pub trait AppRepository: Send + Sync {
async fn get_referral_stats(&self, user_id: Uuid) -> Result<(i64, i64), sqlx::Error>; async fn get_referral_stats(&self, user_id: Uuid) -> Result<(i64, i64), sqlx::Error>;
async fn apply_promo_code(&self, user_id: Uuid, code: &str) -> Result<i64, String>; async fn apply_promo_code(&self, user_id: Uuid, code: &str) -> Result<i64, String>;
// --- РОЛЕВАЯ АДМИНКА: OWNER / MODERATOR / PARTNER ---
/// Создаёт Owner/Moderator/Partner-аккаунт (логин+пароль, не Telegram/seed).
/// `can_manage_nodes` осмыслен только для "moderator", `commission_percent`/
/// `partner_code` — только для "partner" (для остальных ролей передавать `None`).
#[allow(clippy::too_many_arguments)]
async fn create_staff_user(
&self,
username: &str,
password_hash: &str,
role: &str,
can_manage_nodes: bool,
commission_percent: Option<rust_decimal::Decimal>,
partner_code: Option<&str>,
) -> Result<User, sqlx::Error>;
async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, sqlx::Error>;
async fn set_can_manage_nodes(
&self,
user_id: Uuid,
enabled: bool,
) -> Result<Option<User>, sqlx::Error>;
/// `role_filter` — `None` = все staff-роли (owner+moderator+partner).
async fn list_staff_users(&self, role_filter: Option<&str>) -> Result<Vec<User>, sqlx::Error>;
async fn find_partner_by_code(&self, code: &str) -> Result<Option<User>, sqlx::Error>;
/// Постоянная привязка покупателя к партнёру при первой регистрации по
/// диплинку. No-op (`Ok(false)`), если уже привязан — first-wins.
async fn set_referred_by_partner(
&self,
user_id: Uuid,
partner_id: Uuid,
) -> Result<bool, sqlx::Error>;
#[allow(clippy::too_many_arguments)]
async fn record_partner_commission(
&self,
partner_id: Uuid,
invoice_id: Uuid,
user_id: Uuid,
currency: &str,
amount: i64,
percent_at_time: rust_decimal::Decimal,
) -> Result<(), sqlx::Error>;
/// Разбивка заработка/вывода партнёра по валютам (без конвертации).
async fn get_partner_earnings(
&self,
partner_id: Uuid,
) -> Result<Vec<PartnerEarningsByCurrency>, sqlx::Error>;
async fn create_withdrawal_request(
&self,
partner_id: Uuid,
currency: &str,
amount: i64,
note: Option<&str>,
) -> Result<WithdrawalRequest, sqlx::Error>;
/// Атомарная версия для записи: блокирует строку партнёра (`FOR UPDATE`) и
/// пересчитывает доступный остаток внутри той же транзакции перед вставкой
/// заявки — без этого несколько параллельных заявок на весь остаток могут
/// пройти одновременно и увести партнёра в минус (та же гонка, от которой
/// защищает `FOR UPDATE` в `apply_promo_code`). `Err("INSUFFICIENT_BALANCE")`
/// — запрошенная сумма больше доступного остатка.
async fn request_partner_withdrawal(
&self,
partner_id: Uuid,
currency: &str,
amount: i64,
note: Option<&str>,
) -> Result<WithdrawalRequest, String>;
/// `status_filter` — `None` = все заявки (используется owner/moderator).
async fn list_withdrawal_requests(
&self,
status_filter: Option<&str>,
) -> Result<Vec<WithdrawalRequest>, sqlx::Error>;
async fn list_withdrawal_requests_for_partner(
&self,
partner_id: Uuid,
) -> Result<Vec<WithdrawalRequest>, sqlx::Error>;
async fn update_withdrawal_status(
&self,
id: Uuid,
new_status: &str,
processed_by: Uuid,
) -> Result<Option<WithdrawalRequest>, sqlx::Error>;
// --- VPN УЗЛЫ (CONTROL PLANE) --- // --- VPN УЗЛЫ (CONTROL PLANE) ---
/// Ноды в статусе `online` — то, что видят обычные юзеры (`/nodes`, `/routing`, `/stats`). /// Ноды в статусе `online` — то, что видят обычные юзеры (`/nodes`, `/routing`, `/stats`).
async fn get_active_nodes(&self) -> Result<Vec<VpnNode>, sqlx::Error>; async fn get_active_nodes(&self) -> Result<Vec<VpnNode>, sqlx::Error>;
@@ -175,7 +292,9 @@ impl AppRepository for PgRepository {
"INSERT INTO users (id, tg_id, tg_username) \ "INSERT INTO users (id, tg_id, tg_username) \
VALUES ($1, $2, $3) \ VALUES ($1, $2, $3) \
ON CONFLICT (tg_id) DO UPDATE SET tg_username = EXCLUDED.tg_username \ ON CONFLICT (tg_id) DO UPDATE SET tg_username = EXCLUDED.tg_username \
RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at" RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id"
) )
.bind(Uuid::new_v4()).bind(tg_id).bind(username).fetch_one(&self.pool).await .bind(Uuid::new_v4()).bind(tg_id).bind(username).fetch_one(&self.pool).await
} }
@@ -183,7 +302,9 @@ impl AppRepository for PgRepository {
#[instrument(skip(self))] #[instrument(skip(self))]
async fn get_user_by_id(&self, id: Uuid) -> Result<Option<User>, sqlx::Error> { async fn get_user_by_id(&self, id: Uuid) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>( sqlx::query_as::<_, User>(
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at \ "SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id \
FROM users WHERE id = $1" FROM users WHERE id = $1"
) )
.bind(id) .bind(id)
@@ -194,7 +315,9 @@ impl AppRepository for PgRepository {
#[instrument(skip(self))] #[instrument(skip(self))]
async fn get_user_by_tg_id(&self, tg_id: i64) -> Result<Option<User>, sqlx::Error> { async fn get_user_by_tg_id(&self, tg_id: i64) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>( sqlx::query_as::<_, User>(
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at \ "SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id \
FROM users WHERE tg_id = $1" FROM users WHERE tg_id = $1"
) )
.bind(tg_id) .bind(tg_id)
@@ -206,7 +329,9 @@ impl AppRepository for PgRepository {
async fn create_seed_user(&self, seed_hash: &str) -> Result<User, sqlx::Error> { async fn create_seed_user(&self, seed_hash: &str) -> Result<User, sqlx::Error> {
sqlx::query_as::<_, User>( sqlx::query_as::<_, User>(
"INSERT INTO users (id, seed_hash) VALUES ($1, $2) \ "INSERT INTO users (id, seed_hash) VALUES ($1, $2) \
RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at" RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id"
) )
.bind(Uuid::new_v4()).bind(seed_hash).fetch_one(&self.pool).await .bind(Uuid::new_v4()).bind(seed_hash).fetch_one(&self.pool).await
} }
@@ -214,7 +339,9 @@ impl AppRepository for PgRepository {
#[instrument(skip(self))] #[instrument(skip(self))]
async fn get_user_by_seed_hash(&self, hash: &str) -> Result<Option<User>, sqlx::Error> { async fn get_user_by_seed_hash(&self, hash: &str) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>( sqlx::query_as::<_, User>(
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at \ "SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id \
FROM users WHERE seed_hash = $1" FROM users WHERE seed_hash = $1"
) )
.bind(hash) .bind(hash)
@@ -222,6 +349,84 @@ impl AppRepository for PgRepository {
.await .await
} }
#[instrument(skip(self))]
async fn apply_usage_delta(
&self,
user_id: Uuid,
delta_bytes: i64,
) -> Result<Option<(i64, Option<i64>)>, sqlx::Error> {
let row: Option<(i64, Option<i64>)> = sqlx::query_as(
"UPDATE users SET data_used_bytes = data_used_bytes + $1 \
WHERE id = $2 RETURNING data_used_bytes, data_limit_bytes",
)
.bind(delta_bytes)
.bind(user_id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
#[instrument(skip(self))]
async fn set_data_limit(
&self,
user_id: Uuid,
limit_bytes: Option<i64>,
) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>(
"UPDATE users SET data_limit_bytes = $1 WHERE id = $2 \
RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id",
)
.bind(limit_bytes)
.bind(user_id)
.fetch_optional(&self.pool)
.await
}
#[instrument(skip(self))]
async fn create_auth_session(&self, auth_code: &str) -> Result<(), sqlx::Error> {
sqlx::query(
"INSERT INTO auth_sessions (id, auth_code, expires_at) \
VALUES ($1, $2, NOW() + INTERVAL '5 minutes')",
)
.bind(Uuid::new_v4())
.bind(auth_code)
.execute(&self.pool)
.await?;
Ok(())
}
#[instrument(skip(self))]
async fn get_auth_session(
&self,
auth_code: &str,
) -> Result<Option<(Option<Uuid>, bool, chrono::DateTime<chrono::Utc>)>, sqlx::Error> {
sqlx::query_as(
"SELECT user_id, is_verified, expires_at FROM auth_sessions WHERE auth_code = $1",
)
.bind(auth_code)
.fetch_optional(&self.pool)
.await
}
#[instrument(skip(self))]
async fn verify_auth_session(
&self,
auth_code: &str,
user_id: Uuid,
) -> Result<bool, sqlx::Error> {
let r = sqlx::query(
"UPDATE auth_sessions SET user_id = $1, is_verified = TRUE \
WHERE auth_code = $2 AND is_verified = FALSE AND expires_at > NOW()",
)
.bind(user_id)
.bind(auth_code)
.execute(&self.pool)
.await?;
Ok(r.rows_affected() > 0)
}
#[instrument(skip(self, token_hash))] #[instrument(skip(self, token_hash))]
async fn create_refresh_session( async fn create_refresh_session(
&self, &self,
@@ -295,10 +500,13 @@ impl AppRepository for PgRepository {
game_tickets: i32, game_tickets: i32,
subscription: Option<Json<SubscriptionData>>, subscription: Option<Json<SubscriptionData>>,
referrals: Json<Vec<ReferralData>>, referrals: Json<Vec<ReferralData>>,
data_limit_bytes: Option<i64>,
data_used_bytes: i64,
} }
let query = r#" let query = r#"
SELECT u.id, u.tg_username, u.role, u.balance, u.game_tickets, SELECT u.id, u.tg_username, u.role, u.balance, u.game_tickets,
u.data_limit_bytes, u.data_used_bytes,
(SELECT jsonb_build_object('plan_name', s.plan_name, 'expires_at', s.expires_at, 'status', s.status) (SELECT jsonb_build_object('plan_name', s.plan_name, 'expires_at', s.expires_at, 'status', s.status)
FROM public.subscriptions s WHERE s.user_id = u.id AND s.status != 'expired' LIMIT 1 FROM public.subscriptions s WHERE s.user_id = u.id AND s.status != 'expired' LIMIT 1
) AS "subscription", ) AS "subscription",
@@ -322,6 +530,8 @@ impl AppRepository for PgRepository {
game_tickets: r.game_tickets, game_tickets: r.game_tickets,
subscription: r.subscription.map(|json| json.0), subscription: r.subscription.map(|json| json.0),
referrals: r.referrals.0, referrals: r.referrals.0,
data_limit_bytes: r.data_limit_bytes,
data_used_bytes: r.data_used_bytes,
})) }))
} }
@@ -476,6 +686,18 @@ impl AppRepository for PgRepository {
Ok(res.map(|r| r.0)) Ok(res.map(|r| r.0))
} }
#[instrument(skip(self))]
async fn list_active_plans_usd(&self) -> Result<Vec<(String, i64)>, sqlx::Error> {
sqlx::query_as(
"SELECT pp.plan_name, pp.amount FROM plan_prices pp \
JOIN plans p ON p.name = pp.plan_name \
WHERE pp.currency = 'USD' AND pp.is_active = TRUE AND p.is_active = TRUE \
ORDER BY pp.amount ASC",
)
.fetch_all(&self.pool)
.await
}
#[instrument(skip(self))] #[instrument(skip(self))]
async fn create_invoice( async fn create_invoice(
&self, &self,
@@ -504,7 +726,7 @@ impl AppRepository for PgRepository {
#[instrument(skip(self))] #[instrument(skip(self))]
async fn get_invoice(&self, invoice_id: Uuid) -> Result<Option<InvoiceRecord>, sqlx::Error> { async fn get_invoice(&self, invoice_id: Uuid) -> Result<Option<InvoiceRecord>, sqlx::Error> {
sqlx::query_as::<_, InvoiceRecord>( sqlx::query_as::<_, InvoiceRecord>(
"SELECT id, user_id, plan_name, amount, status FROM invoices WHERE id = $1", "SELECT id, user_id, plan_name, provider, currency, amount, status FROM invoices WHERE id = $1",
) )
.bind(invoice_id) .bind(invoice_id)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
@@ -514,7 +736,7 @@ impl AppRepository for PgRepository {
#[instrument(skip(self))] #[instrument(skip(self))]
async fn get_pending_invoices(&self) -> Result<Vec<InvoiceRecord>, sqlx::Error> { async fn get_pending_invoices(&self) -> Result<Vec<InvoiceRecord>, sqlx::Error> {
sqlx::query_as::<_, InvoiceRecord>( sqlx::query_as::<_, InvoiceRecord>(
"SELECT id, user_id, plan_name, amount, status FROM invoices \ "SELECT id, user_id, plan_name, provider, currency, amount, status FROM invoices \
WHERE status = 'pending' AND created_at > NOW() - INTERVAL '24 hours' \ WHERE status = 'pending' AND created_at > NOW() - INTERVAL '24 hours' \
ORDER BY created_at DESC LIMIT 100", ORDER BY created_at DESC LIMIT 100",
) )
@@ -567,6 +789,17 @@ impl AppRepository for PgRepository {
DO UPDATE SET status = 'active', expires_at = NOW() + INTERVAL '30 days', plan_name = EXCLUDED.plan_name") DO UPDATE SET status = 'active', expires_at = NOW() + INTERVAL '30 days', plan_name = EXCLUDED.plan_name")
.bind(user_id).bind(plan_name).execute(&mut *tx).await?; .bind(user_id).bind(plan_name).execute(&mut *tx).await?;
// Проставляем дефолтный лимит трафика тарифа (NULL тарифа = не трогать
// текущий лимит юзера — например, если админ уже выставил кастомный).
sqlx::query(
"UPDATE users SET data_limit_bytes = (SELECT default_limit_bytes FROM plans WHERE name = $2) \
WHERE id = $1 AND (SELECT default_limit_bytes FROM plans WHERE name = $2) IS NOT NULL",
)
.bind(user_id)
.bind(plan_name)
.execute(&mut *tx)
.await?;
tx.commit().await?; tx.commit().await?;
Ok(()) Ok(())
} }
@@ -609,7 +842,10 @@ impl AppRepository for PgRepository {
#[instrument(skip(self))] #[instrument(skip(self))]
async fn reward_referrer(&self, referred_id: Uuid, amount: i64) -> Result<bool, sqlx::Error> { async fn reward_referrer(&self, referred_id: Uuid, amount: i64) -> Result<bool, sqlx::Error> {
let mut tx = self.pool.begin().await?; let mut tx = self.pool.begin().await?;
let res: Option<(Uuid,)> = sqlx::query_as("UPDATE referrals SET status = 'rewarded', total_earned = total_earned + $1 WHERE referred_id = $2 RETURNING referrer_id") // WHERE status = 'pending' — разовый бонус за первую покупку приведённого
// юзера, а не revenue share навсегда: без этого условия UPDATE срабатывал
// при каждом вызове (т.е. при каждой последующей покупке того же юзера).
let res: Option<(Uuid,)> = sqlx::query_as("UPDATE referrals SET status = 'rewarded', total_earned = total_earned + $1 WHERE referred_id = $2 AND status = 'pending' RETURNING referrer_id")
.bind(amount).bind(referred_id).fetch_optional(&mut *tx).await?; .bind(amount).bind(referred_id).fetch_optional(&mut *tx).await?;
if let Some((r_id,)) = res { if let Some((r_id,)) = res {
@@ -628,7 +864,7 @@ impl AppRepository for PgRepository {
#[instrument(skip(self))] #[instrument(skip(self))]
async fn get_referral_stats(&self, user_id: Uuid) -> Result<(i64, i64), sqlx::Error> { async fn get_referral_stats(&self, user_id: Uuid) -> Result<(i64, i64), sqlx::Error> {
let row: (Option<i64>, Option<i64>) = sqlx::query_as( let row: (Option<i64>, Option<i64>) = sqlx::query_as(
"SELECT COUNT(*), SUM(total_earned) FROM referrals WHERE referrer_id = $1", "SELECT COUNT(*), SUM(total_earned)::BIGINT FROM referrals WHERE referrer_id = $1",
) )
.bind(user_id) .bind(user_id)
.fetch_one(&self.pool) .fetch_one(&self.pool)
@@ -710,6 +946,363 @@ impl AppRepository for PgRepository {
Ok(reward) Ok(reward)
} }
// =====================================================================
// РОЛЕВАЯ АДМИНКА: OWNER / MODERATOR / PARTNER
// =====================================================================
#[instrument(skip(self, password_hash))]
async fn create_staff_user(
&self,
username: &str,
password_hash: &str,
role: &str,
can_manage_nodes: bool,
commission_percent: Option<rust_decimal::Decimal>,
partner_code: Option<&str>,
) -> Result<User, sqlx::Error> {
sqlx::query_as::<_, User>(
"INSERT INTO users (id, username, password_hash, role, can_manage_nodes, commission_percent, partner_code) \
VALUES ($1, $2, $3, $4, $5, $6, $7) \
RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id"
)
.bind(Uuid::new_v4())
.bind(username)
.bind(password_hash)
.bind(role)
.bind(can_manage_nodes)
.bind(commission_percent)
.bind(partner_code)
.fetch_one(&self.pool)
.await
}
#[instrument(skip(self))]
async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>(
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id \
FROM users WHERE username = $1"
)
.bind(username)
.fetch_optional(&self.pool)
.await
}
#[instrument(skip(self))]
async fn set_can_manage_nodes(
&self,
user_id: Uuid,
enabled: bool,
) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>(
"UPDATE users SET can_manage_nodes = $1 WHERE id = $2 \
RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id"
)
.bind(enabled)
.bind(user_id)
.fetch_optional(&self.pool)
.await
}
#[instrument(skip(self))]
async fn list_staff_users(&self, role_filter: Option<&str>) -> Result<Vec<User>, sqlx::Error> {
let roles: Vec<&str> = match role_filter {
Some(r) => vec![r],
None => vec!["owner", "moderator", "partner"],
};
sqlx::query_as::<_, User>(
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id \
FROM users WHERE role = ANY($1) ORDER BY role, username"
)
.bind(&roles)
.fetch_all(&self.pool)
.await
}
#[instrument(skip(self))]
async fn find_partner_by_code(&self, code: &str) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>(
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id \
FROM users WHERE partner_code = $1 AND role = 'partner'"
)
.bind(code)
.fetch_optional(&self.pool)
.await
}
#[instrument(skip(self))]
async fn set_referred_by_partner(
&self,
user_id: Uuid,
partner_id: Uuid,
) -> Result<bool, sqlx::Error> {
// first-wins: не перезаписывает уже проставленную привязку.
let result = sqlx::query(
"UPDATE users SET referred_by_partner_id = $1 \
WHERE id = $2 AND referred_by_partner_id IS NULL",
)
.bind(partner_id)
.bind(user_id)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() > 0)
}
#[instrument(skip(self))]
async fn record_partner_commission(
&self,
partner_id: Uuid,
invoice_id: Uuid,
user_id: Uuid,
currency: &str,
amount: i64,
percent_at_time: rust_decimal::Decimal,
) -> Result<(), sqlx::Error> {
sqlx::query(
"INSERT INTO partner_commissions (id, partner_id, invoice_id, user_id, currency, amount, percent_at_time) \
VALUES ($1, $2, $3, $4, $5, $6, $7) \
ON CONFLICT (invoice_id) DO NOTHING",
)
.bind(Uuid::new_v4())
.bind(partner_id)
.bind(invoice_id)
.bind(user_id)
.bind(currency)
.bind(amount)
.bind(percent_at_time)
.execute(&self.pool)
.await?;
Ok(())
}
#[instrument(skip(self))]
async fn get_partner_earnings(
&self,
partner_id: Uuid,
) -> Result<Vec<PartnerEarningsByCurrency>, sqlx::Error> {
// Три отдельных сгруппированных запроса и слияние в Rust — проще и
// надёжнее одного сложного JOIN, объёмы тут крошечные.
let earned_rows: Vec<(String, i64)> = sqlx::query_as(
"SELECT currency, COALESCE(SUM(amount), 0)::BIGINT FROM partner_commissions \
WHERE partner_id = $1 GROUP BY currency",
)
.bind(partner_id)
.fetch_all(&self.pool)
.await?;
let withdrawn_rows: Vec<(String, i64)> = sqlx::query_as(
"SELECT currency, COALESCE(SUM(amount), 0)::BIGINT FROM withdrawal_requests \
WHERE partner_id = $1 AND status IN ('approved', 'paid') GROUP BY currency",
)
.bind(partner_id)
.fetch_all(&self.pool)
.await?;
let pending_rows: Vec<(String, i64)> = sqlx::query_as(
"SELECT currency, COALESCE(SUM(amount), 0)::BIGINT FROM withdrawal_requests \
WHERE partner_id = $1 AND status = 'pending' GROUP BY currency",
)
.bind(partner_id)
.fetch_all(&self.pool)
.await?;
fn entry_for(
map: &mut std::collections::BTreeMap<String, PartnerEarningsByCurrency>,
currency: String,
) -> &mut PartnerEarningsByCurrency {
map.entry(currency.clone())
.or_insert_with(|| PartnerEarningsByCurrency {
currency,
total_earned: 0,
total_withdrawn: 0,
pending_withdrawal: 0,
available: 0,
})
}
let mut by_currency: std::collections::BTreeMap<String, PartnerEarningsByCurrency> =
std::collections::BTreeMap::new();
for (currency, amount) in earned_rows {
entry_for(&mut by_currency, currency).total_earned = amount;
}
for (currency, amount) in withdrawn_rows {
entry_for(&mut by_currency, currency).total_withdrawn = amount;
}
for (currency, amount) in pending_rows {
entry_for(&mut by_currency, currency).pending_withdrawal = amount;
}
Ok(by_currency
.into_values()
.map(|mut e| {
e.available = e.total_earned - e.total_withdrawn - e.pending_withdrawal;
e
})
.collect())
}
#[instrument(skip(self))]
async fn create_withdrawal_request(
&self,
partner_id: Uuid,
currency: &str,
amount: i64,
note: Option<&str>,
) -> Result<WithdrawalRequest, sqlx::Error> {
sqlx::query_as::<_, WithdrawalRequest>(
"INSERT INTO withdrawal_requests (id, partner_id, currency, amount, note) \
VALUES ($1, $2, $3, $4, $5) \
RETURNING id, partner_id, currency, amount, status, note, created_at, processed_at, processed_by",
)
.bind(Uuid::new_v4())
.bind(partner_id)
.bind(currency)
.bind(amount)
.bind(note)
.fetch_one(&self.pool)
.await
}
#[instrument(skip(self))]
async fn request_partner_withdrawal(
&self,
partner_id: Uuid,
currency: &str,
amount: i64,
note: Option<&str>,
) -> Result<WithdrawalRequest, String> {
let mut tx = self.pool.begin().await.map_err(|e| e.to_string())?;
// Блокируем строку партнёра — сериализует конкурентные заявки на вывод
// от одного и того же партнёра, пока эта транзакция не завершится.
sqlx::query("SELECT id FROM users WHERE id = $1 FOR UPDATE")
.bind(partner_id)
.fetch_optional(&mut *tx)
.await
.map_err(|e| e.to_string())?;
let earned: i64 = sqlx::query_scalar(
"SELECT COALESCE(SUM(amount), 0)::BIGINT FROM partner_commissions \
WHERE partner_id = $1 AND currency = $2",
)
.bind(partner_id)
.bind(currency)
.fetch_one(&mut *tx)
.await
.map_err(|e| e.to_string())?;
let withdrawn: i64 = sqlx::query_scalar(
"SELECT COALESCE(SUM(amount), 0)::BIGINT FROM withdrawal_requests \
WHERE partner_id = $1 AND currency = $2 AND status IN ('approved', 'paid')",
)
.bind(partner_id)
.bind(currency)
.fetch_one(&mut *tx)
.await
.map_err(|e| e.to_string())?;
let pending: i64 = sqlx::query_scalar(
"SELECT COALESCE(SUM(amount), 0)::BIGINT FROM withdrawal_requests \
WHERE partner_id = $1 AND currency = $2 AND status = 'pending'",
)
.bind(partner_id)
.bind(currency)
.fetch_one(&mut *tx)
.await
.map_err(|e| e.to_string())?;
let available = earned - withdrawn - pending;
if amount > available {
tx.rollback().await.map_err(|e| e.to_string())?;
return Err("INSUFFICIENT_BALANCE".to_string());
}
let req = sqlx::query_as::<_, WithdrawalRequest>(
"INSERT INTO withdrawal_requests (id, partner_id, currency, amount, note) \
VALUES ($1, $2, $3, $4, $5) \
RETURNING id, partner_id, currency, amount, status, note, created_at, processed_at, processed_by",
)
.bind(Uuid::new_v4())
.bind(partner_id)
.bind(currency)
.bind(amount)
.bind(note)
.fetch_one(&mut *tx)
.await
.map_err(|e| e.to_string())?;
tx.commit().await.map_err(|e| e.to_string())?;
Ok(req)
}
#[instrument(skip(self))]
async fn list_withdrawal_requests(
&self,
status_filter: Option<&str>,
) -> Result<Vec<WithdrawalRequest>, sqlx::Error> {
match status_filter {
Some(status) => {
sqlx::query_as::<_, WithdrawalRequest>(
"SELECT id, partner_id, currency, amount, status, note, created_at, processed_at, processed_by \
FROM withdrawal_requests WHERE status = $1 ORDER BY created_at DESC",
)
.bind(status)
.fetch_all(&self.pool)
.await
}
None => {
sqlx::query_as::<_, WithdrawalRequest>(
"SELECT id, partner_id, currency, amount, status, note, created_at, processed_at, processed_by \
FROM withdrawal_requests ORDER BY (status = 'pending') DESC, created_at DESC",
)
.fetch_all(&self.pool)
.await
}
}
}
#[instrument(skip(self))]
async fn list_withdrawal_requests_for_partner(
&self,
partner_id: Uuid,
) -> Result<Vec<WithdrawalRequest>, sqlx::Error> {
sqlx::query_as::<_, WithdrawalRequest>(
"SELECT id, partner_id, currency, amount, status, note, created_at, processed_at, processed_by \
FROM withdrawal_requests WHERE partner_id = $1 ORDER BY created_at DESC",
)
.bind(partner_id)
.fetch_all(&self.pool)
.await
}
#[instrument(skip(self))]
async fn update_withdrawal_status(
&self,
id: Uuid,
new_status: &str,
processed_by: Uuid,
) -> Result<Option<WithdrawalRequest>, sqlx::Error> {
sqlx::query_as::<_, WithdrawalRequest>(
"UPDATE withdrawal_requests SET status = $1, processed_at = NOW(), processed_by = $2 \
WHERE id = $3 \
RETURNING id, partner_id, currency, amount, status, note, created_at, processed_at, processed_by",
)
.bind(new_status)
.bind(processed_by)
.bind(id)
.fetch_optional(&self.pool)
.await
}
// ===================================================================== // =====================================================================
// VPN УЗЛЫ (CONTROL PLANE) // VPN УЗЛЫ (CONTROL PLANE)
// ===================================================================== // =====================================================================
+66 -8
View File
@@ -18,7 +18,7 @@ use netrunner_control_plane::bot;
use netrunner_control_plane::db::repository::{AppRepository, PgRepository}; use netrunner_control_plane::db::repository::{AppRepository, PgRepository};
use netrunner_control_plane::modules::{ use netrunner_control_plane::modules::{
auth::service::AuthService, billing::service::BillingService, nodes::service::NodeService, auth::service::AuthService, billing::service::BillingService, nodes::service::NodeService,
referrals::service::ReferralService, users::service::UserService, referrals::service::ReferralService, staff::service::StaffService, users::service::UserService,
}; };
use netrunner_control_plane::tasks; use netrunner_control_plane::tasks;
@@ -27,6 +27,12 @@ use netrunner_control_plane::tasks;
struct Args { struct Args {
#[arg(long)] #[arg(long)]
migrate: bool, migrate: bool,
/// Одноразовое создание первого Owner-аккаунта (логин+пароль, не
/// Telegram) — прогоняется руками на VPS: `--create-owner --username admin`.
#[arg(long)]
create_owner: bool,
#[arg(long)]
username: Option<String>,
} }
fn init_tracing() { fn init_tracing() {
@@ -149,6 +155,49 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
return Ok(()); return Ok(());
} }
// Бутстрап первого Owner'а — тоже требует только DATABASE_URL, тот же
// one-shot паттерн, что и --migrate. Пароль вводится скрыто в консоли
// (не через env/аргумент), чтобы не осесть в истории шелла.
if args.create_owner {
let username = args
.username
.clone()
.ok_or("--create-owner требует --username <имя>")?;
info!("🔐 Создание владельца (Owner): {}", username);
let password = rpassword::prompt_password("Пароль для владельца: ")?;
let password_confirm = rpassword::prompt_password("Повторите пароль: ")?;
if password != password_confirm {
return Err("Пароли не совпадают.".into());
}
if password.len() < 8 {
return Err("Пароль должен быть не короче 8 символов.".into());
}
let hash = AuthService::hash_password(&password)
.map_err(|e| format!("Ошибка хеширования пароля: {:?}", e))?;
let repo = PgRepository::new(pool.clone());
let user = repo
.create_staff_user(&username, &hash, "owner", false, None, None)
.await
.map_err(|e| {
if let sqlx::Error::Database(db_err) = &e {
if db_err.constraint() == Some("users_username_key") {
return format!("Логин '{}' уже занят.", username);
}
}
format!("Ошибка создания владельца: {e}")
})?;
info!("✅ Владелец создан: id={}, username={}", user.id, username);
println!(
"Владелец '{}' успешно создан. Войдите через /admin/login на фронтенде.",
username
);
return Ok(());
}
// --- Полный запуск сервера: проверяем все обязательные ресурсы --- // --- Полный запуск сервера: проверяем все обязательные ресурсы ---
ensure_master_ssh_key()?; ensure_master_ssh_key()?;
@@ -163,18 +212,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
"КРИТИЧЕСКАЯ ОШИБКА: BOT_ENABLED=true, но TELOXIDE_TOKEN не задан в .env!".into(), "КРИТИЧЕСКАЯ ОШИБКА: BOT_ENABLED=true, но TELOXIDE_TOKEN не задан в .env!".into(),
); );
} }
// Тот же бот, что читает bot.rs при старте — но здесь ещё и отдаётся фронту
// через GET /api/v1/config (см. ApiState::bot_username), не как VITE_-переменная,
// зашиваемая в SPA-бандл на этапе сборки в CI.
let bot_username =
std::env::var("BOT_USERNAME").unwrap_or_else(|_| "ntrnr_vpn_bot".to_string());
let jwt_secret = let jwt_secret =
std::env::var("JWT_SECRET").expect("КРИТИЧЕСКАЯ ОШИБКА: JWT_SECRET не задан в .env!"); std::env::var("JWT_SECRET").expect("КРИТИЧЕСКАЯ ОШИБКА: JWT_SECRET не задан в .env!");
let frontend_dir = let frontend_dir =
std::env::var("FRONTEND_DIR").expect("КРИТИЧЕСКАЯ ОШИБКА: FRONTEND_DIR не задан в .env!"); std::env::var("FRONTEND_DIR").expect("КРИТИЧЕСКАЯ ОШИБКА: FRONTEND_DIR не задан в .env!");
let port = std::env::var("PORT").expect("КРИТИЧЕСКАЯ ОШИБКА: PORT не задан в .env!"); let port = std::env::var("PORT").expect("КРИТИЧЕСКАЯ ОШИБКА: PORT не задан в .env!");
// Считываем ADMIN_TG_ID один раз при старте сервера
let admin_tg_id = std::env::var("ADMIN_TG_ID")
.expect("КРИТИЧЕСКАЯ ОШИБКА: ADMIN_TG_ID не задан в .env!")
.parse::<i64>()
.expect("ADMIN_TG_ID должен быть числом");
info!("🚀 Запуск Netrunner Control Plane v2.0 (Modular Architecture)"); info!("🚀 Запуск Netrunner Control Plane v2.0 (Modular Architecture)");
// Домен для Set-Cookie: пусто локально (host-only cookie), в проде // Домен для Set-Cookie: пусто локально (host-only cookie), в проде
@@ -193,6 +241,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Только для стендов без TLS, никогда не для прода!" Только для стендов без TLS, никогда не для прода!"
); );
} }
// Общий секрет, которым делятся все прокси-ноды при вызовах internal-эндпоинтов
// (`POST /internal/validate`, `POST /internal/usage`) — не пользовательская
// авторизация, поэтому отдельный env, не JWT_SECRET.
let proxy_internal_secret = std::env::var("PROXY_INTERNAL_SECRET")
.expect("КРИТИЧЕСКАЯ ОШИБКА: PROXY_INTERNAL_SECRET не задан в .env!");
let csrf_allowed_origins: Vec<String> = std::env::var("CSRF_ALLOWED_ORIGINS") let csrf_allowed_origins: Vec<String> = std::env::var("CSRF_ALLOWED_ORIGINS")
.unwrap_or_else(|_| { .unwrap_or_else(|_| {
"https://netrunner-vpn.com,https://account.netrunner-vpn.com".to_string() "https://netrunner-vpn.com,https://account.netrunner-vpn.com".to_string()
@@ -241,12 +295,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
node_service: NodeService::new(repo.clone()), node_service: NodeService::new(repo.clone()),
referral_service: ReferralService::new(repo.clone()), referral_service: ReferralService::new(repo.clone()),
user_service: UserService::new(repo.clone()), user_service: UserService::new(repo.clone()),
staff_service: StaffService::new(repo.clone()),
jwt_secret, jwt_secret,
bot_token: bot_token.clone(), bot_token: bot_token.clone(),
admin_tg_id, bot_username,
cookie_domain, cookie_domain,
cookie_secure, cookie_secure,
csrf_allowed_origins, csrf_allowed_origins,
proxy_internal_secret,
}; };
let app = create_router(state.clone(), &frontend_dir); let app = create_router(state.clone(), &frontend_dir);
@@ -266,6 +322,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let bot_auth = state.auth_service.clone(); let bot_auth = state.auth_service.clone();
let bot_users = state.user_service.clone(); let bot_users = state.user_service.clone();
let bot_billing = state.billing_service.clone(); let bot_billing = state.billing_service.clone();
let bot_referrals = state.referral_service.clone();
let bot_token_for_task = bot_token.clone(); let bot_token_for_task = bot_token.clone();
let bot_handle = tokio::spawn(async move { let bot_handle = tokio::spawn(async move {
@@ -276,6 +333,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
bot_auth, bot_auth,
bot_users, bot_users,
bot_billing, bot_billing,
bot_referrals,
bot_cancel_token, bot_cancel_token,
) )
.await; .await;
+87 -3
View File
@@ -3,7 +3,11 @@ use crate::{api::ApiState, error::AppError};
use axum::http::header::{COOKIE, SET_COOKIE, USER_AGENT}; use axum::http::header::{COOKIE, SET_COOKIE, USER_AGENT};
use axum::http::HeaderMap; use axum::http::HeaderMap;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::{extract::State, routing::post, Json, Router}; use axum::{
extract::{Path, State},
routing::{get, post},
Json, Router,
};
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
use tracing::instrument; use tracing::instrument;
@@ -29,11 +33,20 @@ pub struct ExchangePayload {
pub token: String, pub token: String,
} }
#[derive(Deserialize, Debug)]
pub struct AdminLoginPayload {
pub username: String,
pub password: String,
}
pub fn router() -> Router<ApiState> { pub fn router() -> Router<ApiState> {
Router::new() Router::new()
.route("/telegram", post(auth_telegram_api)) .route("/telegram", post(auth_telegram_api))
.route("/telegram/session", post(create_telegram_session_api))
.route("/telegram/session/{code}", get(get_telegram_session_api))
.route("/seed/generate", post(generate_seed_api)) .route("/seed/generate", post(generate_seed_api))
.route("/seed/login", post(login_seed_api)) .route("/seed/login", post(login_seed_api))
.route("/admin/login", post(admin_login_api))
.route("/exchange", post(exchange_bootstrap_api)) .route("/exchange", post(exchange_bootstrap_api))
.route("/refresh", post(refresh_api)) .route("/refresh", post(refresh_api))
.route("/logout", post(logout_api)) .route("/logout", post(logout_api))
@@ -156,6 +169,46 @@ async fn auth_telegram_api(
Ok((out_headers, Json(json!({ "status": "success" })))) Ok((out_headers, Json(json!({ "status": "success" }))))
} }
/// Приложение (десктоп/мобильный, без домена для Telegram Login Widget) создаёт
/// код и открывает `deep_link` в системном Telegram — юзер подтверждает вход
/// командой `/start <auth_code>` в боте (см. `bot.rs::handle_commands`), а
/// приложение тем временем поллит `GET .../session/{code}`.
async fn create_telegram_session_api(
State(state): State<ApiState>,
) -> Result<impl IntoResponse, AppError> {
let auth_code = state.auth_service.create_telegram_login_code().await?;
let deep_link = format!("https://t.me/{}?start={}", state.bot_username, auth_code);
Ok(Json(
json!({ "auth_code": auth_code, "deep_link": deep_link }),
))
}
#[instrument(skip(state, headers))]
async fn get_telegram_session_api(
State(state): State<ApiState>,
Path(code): Path<String>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
let session = state
.auth_service
.poll_telegram_login(&code, &state.jwt_secret, user_agent(&headers))
.await?;
let Some(session) = session else {
return Ok(with_headers(
HeaderMap::new(),
Json(json!({ "status": "pending" })),
));
};
let mut out_headers = HeaderMap::new();
set_session_cookies(&mut out_headers, &state, &session);
Ok(with_headers(
out_headers,
Json(json!({ "status": "success", "token": session.access_token })),
))
}
#[axum::debug_handler] #[axum::debug_handler]
async fn generate_seed_api( async fn generate_seed_api(
State(state): State<ApiState>, State(state): State<ApiState>,
@@ -171,9 +224,11 @@ async fn generate_seed_api(
let mut out_headers = HeaderMap::new(); let mut out_headers = HeaderMap::new();
set_session_cookies(&mut out_headers, &state, &session); set_session_cookies(&mut out_headers, &state, &session);
// Cookie — для веб-фронта; access_token в теле — для Tauri-приложения
// (кросс-origin custom scheme, Lax-cookie туда не долетают, см. `src/lib/api.ts`).
Ok(( Ok((
out_headers, out_headers,
Json(json!({ "status": "success", "seed": seed })), Json(json!({ "status": "success", "seed": seed, "token": session.access_token })),
)) ))
} }
@@ -196,7 +251,36 @@ async fn login_seed_api(
let mut out_headers = HeaderMap::new(); let mut out_headers = HeaderMap::new();
set_session_cookies(&mut out_headers, &state, &session); set_session_cookies(&mut out_headers, &state, &session);
Ok((out_headers, Json(json!({ "status": "success" })))) Ok((
out_headers,
Json(json!({ "status": "success", "token": session.access_token })),
))
}
/// Логин Owner/Moderator/Partner по логину+паролю — отдельная админка,
/// не завязанная на Telegram/seed (см. `AuthService::login_staff`).
async fn admin_login_api(
State(state): State<ApiState>,
headers: HeaderMap,
Json(payload): Json<AdminLoginPayload>,
) -> Result<impl IntoResponse, AppError> {
let session = state
.auth_service
.login_staff(
&payload.username,
&payload.password,
&state.jwt_secret,
user_agent(&headers),
)
.await?;
let mut out_headers = HeaderMap::new();
set_session_cookies(&mut out_headers, &state, &session);
Ok((
out_headers,
Json(json!({ "status": "success", "token": session.access_token })),
))
} }
/// Обменивает короткоживущий bootstrap-токен (см. `bot.rs`, кнопки /// Обменивает короткоживущий bootstrap-токен (см. `bot.rs`, кнопки
+259 -17
View File
@@ -95,27 +95,269 @@ pub async fn auth_guard(
Ok(next.run(req).await) Ok(next.run(req).await)
} }
/// Проверяет права администратора. // Ролевые гварды админки (Owner/Moderator/Partner) — заменяют старый
pub async fn admin_guard( // `admin_guard`, который держался на одном захардкоженном ADMIN_TG_ID.
State(state): State<ApiState>, // Роль читается из уже инжектнутого auth_guard'ом User, второй запрос к БД
// не нужен.
fn current_user(req: &Request) -> Result<&User, AppError> {
req.extensions()
.get::<User>()
.ok_or_else(|| AppError::Unauthorized("Context lost: Auth required".into()))
}
fn deny(user: &User) -> AppError {
warn!(
"SECURITY_ALERT: Unauthorized admin access attempt by user_id={} role={:?}",
user.id, user.role
);
AppError::Unauthorized("Insufficient clearance level".into())
}
/// Только Owner.
pub async fn owner_guard(
State(_state): State<ApiState>,
req: Request, req: Request,
next: Next, next: Next,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let user = req let user = current_user(&req)?;
.extensions() if user.role == "owner" {
.get::<User>()
.ok_or_else(|| AppError::Unauthorized("Context lost: Auth required".into()))?;
// Используем закешированный в памяти ID
if user.tg_id == Some(state.admin_tg_id) || user.role == "admin" {
Ok(next.run(req).await) Ok(next.run(req).await)
} else { } else {
warn!( Err(deny(user))
"SECURITY_ALERT: Unauthorized admin access attempt by @{:?}", }
user.tg_username }
);
Err(AppError::Unauthorized( /// Owner или Moderator (любой, независимо от `can_manage_nodes`).
"Insufficient clearance level".into(), pub async fn staff_guard(
)) State(_state): State<ApiState>,
req: Request,
next: Next,
) -> Result<Response, AppError> {
let user = current_user(&req)?;
if user.role == "owner" || user.role == "moderator" {
Ok(next.run(req).await)
} else {
Err(deny(user))
}
}
/// Owner ИЛИ Moderator с выданным правом `can_manage_nodes` — гейт для
/// управления нодами.
pub async fn node_manage_guard(
State(_state): State<ApiState>,
req: Request,
next: Next,
) -> Result<Response, AppError> {
let user = current_user(&req)?;
if user.role == "owner" || (user.role == "moderator" && user.can_manage_nodes) {
Ok(next.run(req).await)
} else {
Err(deny(user))
}
}
/// Только Partner.
pub async fn partner_guard(
State(_state): State<ApiState>,
req: Request,
next: Next,
) -> Result<Response, AppError> {
let user = current_user(&req)?;
if user.role == "partner" {
Ok(next.run(req).await)
} else {
Err(deny(user))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::repository::{AppRepository, PgRepository};
use crate::modules::auth::service::AuthService;
use crate::modules::billing::service::BillingService;
use crate::modules::nodes::service::NodeService;
use crate::modules::referrals::service::ReferralService;
use crate::modules::staff::service::StaffService;
use crate::modules::users::service::UserService;
use axum::{
body::Body,
http::{Request as HttpRequest, StatusCode},
middleware,
routing::get,
Extension, Router,
};
use sqlx::PgPool;
use std::sync::Arc;
use tower::ServiceExt;
fn build_state(pool: PgPool) -> ApiState {
// BillingService::new паникует без этой переменной окружения; для
// тестов ролевых гвардов её конкретное значение не имеет значения.
std::env::set_var("TON_MASTER_WALLET", "UQtest_wallet_for_guard_tests");
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
ApiState {
repo: repo.clone(),
auth_service: AuthService::new(repo.clone()),
billing_service: BillingService::new(repo.clone(), None),
node_service: NodeService::new(repo.clone()),
referral_service: ReferralService::new(repo.clone()),
user_service: UserService::new(repo.clone()),
staff_service: StaffService::new(repo.clone()),
jwt_secret: "test-secret".into(),
bot_token: "test-bot-token".into(),
bot_username: "test_bot".into(),
cookie_domain: "".into(),
cookie_secure: false,
csrf_allowed_origins: vec![],
proxy_internal_secret: "test-internal-secret".into(),
}
}
fn make_user(role: &str, can_manage_nodes: bool) -> User {
User {
id: uuid::Uuid::new_v4(),
tg_id: None,
tg_username: None,
role: role.into(),
balance: 0,
game_tickets: 0,
has_used_trial: false,
seed_hash: None,
last_hack_at: None,
data_limit_bytes: None,
data_used_bytes: 0,
data_period_reset_at: None,
username: None,
password_hash: None,
can_manage_nodes,
commission_percent: None,
partner_code: None,
referred_by_partner_id: None,
}
}
/// Строит одноразовый роутер с единственным guard'ом под тестом, инжектит
/// заданного пользователя напрямую в extensions (минуя настоящий auth_guard/JWT
/// — гварды читают только Extension<User>, см. `current_user`) и возвращает
/// итоговый HTTP-статус.
async fn status_for<G, Fut>(state: ApiState, user: User, guard: G) -> StatusCode
where
G: Fn(State<ApiState>, Request, Next) -> Fut + Clone + Send + Sync + 'static,
Fut: std::future::Future<Output = Result<Response, AppError>> + Send + 'static,
{
let app = Router::new()
.route("/", get(|| async { StatusCode::OK }))
.route_layer(middleware::from_fn_with_state(state.clone(), guard))
.layer(Extension(user))
.with_state(state);
let resp = app
.oneshot(HttpRequest::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
resp.status()
}
#[sqlx::test]
async fn test_owner_guard_matrix(pool: PgPool) {
let state = build_state(pool);
assert_eq!(
status_for(state.clone(), make_user("owner", false), owner_guard).await,
StatusCode::OK
);
assert_eq!(
status_for(state.clone(), make_user("moderator", true), owner_guard).await,
StatusCode::UNAUTHORIZED
);
assert_eq!(
status_for(state.clone(), make_user("partner", false), owner_guard).await,
StatusCode::UNAUTHORIZED
);
assert_eq!(
status_for(state.clone(), make_user("user", false), owner_guard).await,
StatusCode::UNAUTHORIZED
);
}
#[sqlx::test]
async fn test_staff_guard_matrix(pool: PgPool) {
let state = build_state(pool);
assert_eq!(
status_for(state.clone(), make_user("owner", false), staff_guard).await,
StatusCode::OK
);
assert_eq!(
status_for(state.clone(), make_user("moderator", false), staff_guard).await,
StatusCode::OK
);
assert_eq!(
status_for(state.clone(), make_user("partner", false), staff_guard).await,
StatusCode::UNAUTHORIZED
);
assert_eq!(
status_for(state.clone(), make_user("user", false), staff_guard).await,
StatusCode::UNAUTHORIZED
);
}
#[sqlx::test]
async fn test_node_manage_guard_matrix(pool: PgPool) {
let state = build_state(pool);
assert_eq!(
status_for(state.clone(), make_user("owner", false), node_manage_guard).await,
StatusCode::OK,
"owner управляет нодами всегда, независимо от can_manage_nodes"
);
assert_eq!(
status_for(
state.clone(),
make_user("moderator", true),
node_manage_guard
)
.await,
StatusCode::OK
);
assert_eq!(
status_for(
state.clone(),
make_user("moderator", false),
node_manage_guard
)
.await,
StatusCode::UNAUTHORIZED,
"модератор без выданного права can_manage_nodes не должен проходить"
);
assert_eq!(
status_for(
state.clone(),
make_user("partner", false),
node_manage_guard
)
.await,
StatusCode::UNAUTHORIZED
);
}
#[sqlx::test]
async fn test_partner_guard_matrix(pool: PgPool) {
let state = build_state(pool);
assert_eq!(
status_for(state.clone(), make_user("partner", false), partner_guard).await,
StatusCode::OK
);
assert_eq!(
status_for(state.clone(), make_user("owner", false), partner_guard).await,
StatusCode::UNAUTHORIZED
);
assert_eq!(
status_for(state.clone(), make_user("moderator", true), partner_guard).await,
StatusCode::UNAUTHORIZED
);
assert_eq!(
status_for(state.clone(), make_user("user", false), partner_guard).await,
StatusCode::UNAUTHORIZED
);
} }
} }
+4 -3
View File
@@ -1,9 +1,10 @@
//! Авторизация: Telegram Login Widget, анонимный "Ghost Protocol" (вход по seed-фразе) //! Авторизация: Telegram Login Widget, анонимный "Ghost Protocol" (вход по seed-фразе),
//! и JWT-гварды (`auth_guard`, `admin_guard`). См. README.md рядом с этим файлом. //! логин Owner/Moderator/Partner по паролю, и ролевые JWT-гварды (`auth_guard` +
//! `owner_guard`/`staff_guard`/`node_manage_guard`/`partner_guard`). См. README.md рядом.
pub mod controller; pub mod controller;
pub mod guard; pub mod guard;
pub mod service; pub mod service;
pub use guard::{admin_guard, auth_guard}; pub use guard::{auth_guard, node_manage_guard, owner_guard, partner_guard, staff_guard};
pub use service::{AuthService, Claims}; pub use service::{AuthService, Claims};
+472
View File
@@ -34,6 +34,7 @@ pub struct Claims {
/// Пара токенов, выдаваемая при входе: короткий access-JWT (кладётся в /// Пара токенов, выдаваемая при входе: короткий access-JWT (кладётся в
/// `Authorization`/cookie на каждый запрос) и непрозрачный refresh-токен /// `Authorization`/cookie на каждый запрос) и непрозрачный refresh-токен
/// (только в отдельной cookie, только на `/auth/refresh`). /// (только в отдельной cookie, только на `/auth/refresh`).
#[derive(Debug)]
pub struct Session { pub struct Session {
pub access_token: String, pub access_token: String,
pub refresh_token: String, pub refresh_token: String,
@@ -239,6 +240,176 @@ impl AuthService {
self.issue_session(user.id, user.tg_id, jwt_secret, user_agent) self.issue_session(user.id, user.tg_id, jwt_secret, user_agent)
.await .await
} }
// --- Логин Owner/Moderator/Partner по паролю (не Telegram/seed) ---
// Отдельная схема хеширования от `hash_seed_argon2`: там общая на все
// seed'ы фиксированная соль из ARGON_SALT — приемлемо для
// высокоэнтропийных сгенерированных seed'ов, но не для человеческих
// паролей (одна утечка ARGON_SALT — и все пароли колются разом одной
// radeoduga-таблицей). Здесь — самодостаточная PHC-строка со случайной
// солью на каждый пароль (`argon2::password_hash`).
/// Хеширует пароль со случайной солью. Используется и при создании
/// Owner/Moderator/Partner (`staff` controller), и в CLI-бутстрапе
/// (`--create-owner`) — важно, чтобы схема была идентичной везде.
pub fn hash_password(password: &str) -> Result<String, AppError> {
use argon2::password_hash::{rand_core::OsRng, PasswordHasher, SaltString};
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| AppError::Internal(format!("Ошибка хеширования пароля: {e}")))
}
/// Логин Owner/Moderator/Partner по логину+паролю. Одинаковая ошибка на
/// "нет юзера" и "неверный пароль" — без user enumeration.
#[instrument(skip(self, password, jwt_secret))]
pub async fn login_staff(
&self,
username: &str,
password: &str,
jwt_secret: &str,
user_agent: Option<&str>,
) -> Result<Session, AppError> {
use argon2::password_hash::{PasswordHash, PasswordVerifier};
let generic_err = || AppError::Unauthorized("Invalid credentials".into());
let user = self
.repo
.get_user_by_username(username)
.await?
.ok_or_else(generic_err)?;
let hash = user.password_hash.as_deref().ok_or_else(generic_err)?;
let parsed_hash = PasswordHash::new(hash).map_err(|_| generic_err())?;
Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.map_err(|_| generic_err())?;
self.issue_session(user.id, user.tg_id, jwt_secret, user_agent)
.await
}
/// Ищет партнёра по коду диплинка (`t.me/<bot>?start=p_XXXXXX`).
pub async fn find_partner_by_code(&self, code: &str) -> Result<Option<User>, AppError> {
self.repo
.find_partner_by_code(code)
.await
.map_err(AppError::Database)
}
/// Привязывает покупателя к партнёру (first-wins, no-op если уже привязан).
pub async fn attribute_partner_referral(
&self,
partner_id: Uuid,
buyer_id: Uuid,
) -> Result<bool, AppError> {
self.repo
.set_referred_by_partner(buyer_id, partner_id)
.await
.map_err(AppError::Database)
}
// --- Magic-link вход через Telegram-бота для десктоп/мобильного приложения ---
// Приложение не может встроить Telegram Login Widget (нет домена/WebView-моста
// как у веб-ЛК), поэтому вместо него: приложение создаёт auth_code и открывает
// `t.me/<bot>?start=<auth_code>` (см. `bot.rs::handle_commands`), а само поллит
// `GET /auth/telegram/session/{code}`, пока бот не подтвердит код.
/// Создаёт ожидающую подтверждения сессию, возвращает код для deep-link.
pub async fn create_telegram_login_code(&self) -> Result<String, AppError> {
let auth_code: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(8)
.map(char::from)
.collect();
self.repo
.create_auth_session(&auth_code)
.await
.map_err(AppError::Database)?;
Ok(auth_code)
}
/// Вызывается ботом на `/start <auth_code>`: если код существует, ещё не
/// подтверждён и не истёк — привязывает его к вошедшему в боте юзеру.
pub async fn confirm_telegram_login_code(
&self,
auth_code: &str,
user_id: Uuid,
) -> Result<bool, AppError> {
self.repo
.verify_auth_session(auth_code, user_id)
.await
.map_err(AppError::Database)
}
/// Код для входа, который генерирует **сам бот** по кнопке в меню — в
/// отличие от `create_telegram_login_code` (deep-link из приложения),
/// боту не нужен отдельный шаг подтверждения: сам факт диалога уже
/// удостоверяет tg_id, поэтому код создаётся сразу подтверждённым. Юзер
/// вручную вводит его в приложении. Deep-link-флоу ненадёжен для юзеров,
/// уже открывавших бота раньше — Telegram не пересылает `?start=` заново
/// для уже начатых чатов, только пустой `/start`.
pub async fn create_bot_login_code(
&self,
tg_id: i64,
username: Option<String>,
) -> Result<String, AppError> {
let user = self.process_login(tg_id, username, None).await?;
let auth_code: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(8)
.map(char::from)
.collect();
self.repo
.create_auth_session(&auth_code)
.await
.map_err(AppError::Database)?;
self.repo
.verify_auth_session(&auth_code, user.id)
.await
.map_err(AppError::Database)?;
Ok(auth_code)
}
/// Опрашивается приложением. `Ok(None)` — код существует, ждём подтверждения
/// в боте; `Err(Unauthorized)` — код не найден или истёк.
#[instrument(skip(self, jwt_secret))]
pub async fn poll_telegram_login(
&self,
auth_code: &str,
jwt_secret: &str,
user_agent: Option<&str>,
) -> Result<Option<Session>, AppError> {
let (user_id, is_verified, expires_at) = self
.repo
.get_auth_session(auth_code)
.await
.map_err(AppError::Database)?
.ok_or_else(|| AppError::Unauthorized("Invalid auth code".into()))?;
if expires_at < Utc::now() {
return Err(AppError::Unauthorized("Auth code expired".into()));
}
if !is_verified {
return Ok(None);
}
let user_id = user_id
.ok_or_else(|| AppError::Internal("Verified auth session missing user_id".into()))?;
let user = self
.repo
.get_user_by_id(user_id)
.await
.map_err(AppError::Database)?
.ok_or(AppError::UserNotFound)?;
self.issue_session(user.id, user.tg_id, jwt_secret, user_agent)
.await
.map(Some)
}
// --- Логика Telegram (без изменений, кроме вызова генерации токена) --- // --- Логика Telegram (без изменений, кроме вызова генерации токена) ---
// Сигнатура повторяет поля payload-а Telegram Login Widget — это намеренно. // Сигнатура повторяет поля payload-а Telegram Login Widget — это намеренно.
@@ -325,3 +496,304 @@ impl AuthService {
Ok(()) Ok(())
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::db::repository::PgRepository;
use sqlx::PgPool;
const SECRET: &str = "test-jwt-secret";
const SALT: &str = "test-seed-salt-min-16-chars";
// --- Ротация refresh-токена и обнаружение переиспользования ---
#[sqlx::test]
async fn test_refresh_session_rotates_and_revokes_old_token(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = AuthService::new(repo.clone());
let user = repo.upsert_user(1, None).await.unwrap();
let session = svc
.issue_session(user.id, Some(1), SECRET, None)
.await
.unwrap();
let rotated = svc
.refresh_session(&session.refresh_token, SECRET, None)
.await
.expect("первая ротация должна пройти");
assert_ne!(rotated.refresh_token, session.refresh_token);
// Старый refresh-токен теперь отозван — повторное предъявление отклоняется.
let err = svc
.refresh_session(&session.refresh_token, SECRET, None)
.await
.unwrap_err();
assert!(matches!(err, AppError::Unauthorized(_)));
}
#[sqlx::test]
async fn test_refresh_reuse_after_rotation_revokes_all_sessions(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = AuthService::new(repo.clone());
let user = repo.upsert_user(1, None).await.unwrap();
let session = svc
.issue_session(user.id, Some(1), SECRET, None)
.await
.unwrap();
let rotated = svc
.refresh_session(&session.refresh_token, SECRET, None)
.await
.unwrap();
// Переиспользование уже отозванного (старого) токена — сигнал компрометации.
let err = svc
.refresh_session(&session.refresh_token, SECRET, None)
.await
.unwrap_err();
assert!(matches!(err, AppError::Unauthorized(_)));
// Даже свежая пара, выданная легитимной ротацией ДО обнаружения реюза,
// должна была быть отозвана целиком (revoke_all_refresh_sessions).
let err2 = svc
.refresh_session(&rotated.refresh_token, SECRET, None)
.await
.unwrap_err();
assert!(matches!(err2, AppError::Unauthorized(_)));
}
#[sqlx::test]
async fn test_refresh_session_expired_rejected(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = AuthService::new(repo.clone());
let user = repo.upsert_user(1, None).await.unwrap();
let raw_token = "manually-crafted-refresh-token";
let token_hash = AuthService::hash_refresh_token(raw_token);
repo.create_refresh_session(
user.id,
&token_hash,
None,
Utc::now() - chrono::Duration::days(1),
)
.await
.unwrap();
let err = svc
.refresh_session(raw_token, SECRET, None)
.await
.unwrap_err();
assert!(matches!(err, AppError::Unauthorized(_)));
}
// --- login_staff: без user enumeration ---
#[sqlx::test]
async fn test_login_staff_success(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = AuthService::new(repo.clone());
let hash = AuthService::hash_password("correct horse battery staple").unwrap();
repo.create_staff_user("owner1", &hash, "owner", true, None, None)
.await
.unwrap();
svc.login_staff("owner1", "correct horse battery staple", SECRET, None)
.await
.expect("верный логин/пароль должны пройти");
}
#[sqlx::test]
async fn test_login_staff_generic_error_no_enumeration(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = AuthService::new(repo.clone());
let hash = AuthService::hash_password("correct horse battery staple").unwrap();
repo.create_staff_user("owner1", &hash, "owner", true, None, None)
.await
.unwrap();
// Обычный Telegram-юзер без username/password_hash вообще.
repo.upsert_user(42, None).await.unwrap();
let err_wrong_pass = svc
.login_staff("owner1", "wrong password", SECRET, None)
.await
.unwrap_err();
let err_unknown_user = svc
.login_staff("nobody", "whatever", SECRET, None)
.await
.unwrap_err();
let msg = |e: AppError| match e {
AppError::Unauthorized(m) => m,
other => panic!("ожидался Unauthorized, получено {other:?}"),
};
// Обе ошибки должны быть текстуально неотличимы — иначе можно перебором
// узнать, существует ли username.
assert_eq!(msg(err_wrong_pass), msg(err_unknown_user));
}
#[test]
fn test_hash_password_roundtrip() {
use argon2::password_hash::{PasswordHash, PasswordVerifier};
let hash = AuthService::hash_password("hunter2").unwrap();
let parsed = PasswordHash::new(&hash).unwrap();
assert!(Argon2::default()
.verify_password(b"hunter2", &parsed)
.is_ok());
assert!(Argon2::default()
.verify_password(b"wrong", &parsed)
.is_err());
}
// --- Ghost Protocol: seed-вход ---
#[sqlx::test]
async fn test_register_and_login_seed_roundtrip(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = AuthService::new(repo.clone());
let (seed, _session) = svc.register_seed(SALT, SECRET, None).await.unwrap();
svc.login_seed(&seed, SALT, SECRET, None)
.await
.expect("вход по только что сгенерированному seed должен пройти");
let err = svc
.login_seed("totally-wrong-seed-value", SALT, SECRET, None)
.await
.unwrap_err();
assert!(matches!(err, AppError::Unauthorized(_)));
}
// --- Telegram Login Widget: HMAC-подпись ---
fn compute_valid_tg_hash(
bot_token: &str,
id: i64,
username: Option<&str>,
auth_date: i64,
) -> String {
let mut data_check_arr = vec![format!("auth_date={}", auth_date), format!("id={}", id)];
if let Some(v) = username {
data_check_arr.push(format!("username={}", v));
}
data_check_arr.sort();
let data_check_string = data_check_arr.join("\n");
let mut hasher = Sha256::new();
hasher.update(bot_token.trim().as_bytes());
let secret_key = hasher.finalize();
let mut mac = Hmac::<Sha256>::new_from_slice(&secret_key).unwrap();
mac.update(data_check_string.as_bytes());
hex::encode(mac.finalize().into_bytes())
}
#[sqlx::test]
async fn test_verify_tg_widget_auth_accepts_valid_signature(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = AuthService::new(repo);
let bot_token = "123:ABC-fake-bot-token";
let auth_date = Utc::now().timestamp();
let hash = compute_valid_tg_hash(bot_token, 555, Some("neo"), auth_date);
svc.verify_tg_widget_auth(
bot_token,
555,
None,
None,
Some("neo"),
None,
auth_date,
&hash,
)
.expect("валидная подпись должна приниматься");
}
#[sqlx::test]
async fn test_verify_tg_widget_auth_rejects_tampered_signature(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = AuthService::new(repo);
let bot_token = "123:ABC-fake-bot-token";
let auth_date = Utc::now().timestamp();
let hash = compute_valid_tg_hash(bot_token, 555, Some("neo"), auth_date);
// Тот же хэш, но id подделан относительно того, что реально подписано.
let err = svc
.verify_tg_widget_auth(
bot_token,
666,
None,
None,
Some("neo"),
None,
auth_date,
&hash,
)
.unwrap_err();
assert!(matches!(err, AppError::Unauthorized(_)));
}
#[sqlx::test]
async fn test_verify_tg_widget_auth_rejects_expired_session(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = AuthService::new(repo);
let bot_token = "123:ABC-fake-bot-token";
let auth_date = Utc::now().timestamp() - 90_000; // > 86400 секунд назад
let hash = compute_valid_tg_hash(bot_token, 555, Some("neo"), auth_date);
let err = svc
.verify_tg_widget_auth(
bot_token,
555,
None,
None,
Some("neo"),
None,
auth_date,
&hash,
)
.unwrap_err();
assert!(matches!(err, AppError::Unauthorized(_)));
}
// --- process_login: привязка реферала только для новых юзеров, без self-ref ---
#[sqlx::test]
async fn test_process_login_applies_referral_for_new_user_only(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = AuthService::new(repo.clone());
let referrer = repo.upsert_user(100, None).await.unwrap();
// Новый юзер с валидным реферальным кодом (tg_id реферера) -> связь создаётся.
svc.process_login(200, None, Some("100".to_string()))
.await
.unwrap();
let (count, _) = repo.get_referral_stats(referrer.id).await.unwrap();
assert_eq!(count, 1);
// Самореферал игнорируется.
svc.process_login(300, None, Some("300".to_string()))
.await
.unwrap();
let (self_ref_count, _) = repo
.get_referral_stats(repo.get_user_by_tg_id(300).await.unwrap().unwrap().id)
.await
.unwrap();
assert_eq!(self_ref_count, 0);
// Повторный логин уже существующего юзера с ref_code — не создаёт новую связь
// (is_new уже false на момент проверки).
svc.process_login(200, None, Some("100".to_string()))
.await
.unwrap();
let (count_after, _) = repo.get_referral_stats(referrer.id).await.unwrap();
assert_eq!(
count_after, 1,
"повторный логин не должен задваивать реферальную связь"
);
}
}
+139
View File
@@ -0,0 +1,139 @@
//! Провайдер [@CryptoBot](https://t.me/CryptoBot) (Crypto Pay API) — оплата
//! криптой прямо в Telegram, без TonConnect-кошелька: юзер получает ссылку на
//! инвойс внутри CryptoBot, платит там, возвращается и жмёт "Я оплатил".
//!
//! Биллим в USDT 1:1 к USD-цене тарифа (см. `BillingService::initiate_payment` —
//! в отличие от TON, курс не нужен, `invoice.amount` уже приходит в USD-центах
//! из `plan_prices`). Своего вебхука пока нет — статус подтверждается либо
//! ручным нажатием (`verify_payment` бьёт по конкретному `invoice_id`), либо
//! никогда автоматически не подтягивается фоновой сверкой (в отличие от TON,
//! `get_recent_transactions` не переопределён — см. README рядом с `mod.rs`).
//!
//! Документация API: <https://help.crypt.bot/crypto-pay-api>.
use super::{InvoiceData, PaymentProvider};
use crate::error::AppError;
use async_trait::async_trait;
use serde_json::{json, Value};
/// Актив, в котором выставляется инвойс. USDT — самый ликвидный и стабильный
/// (1:1 к USD, без своей волатильности) из поддерживаемых Crypto Pay API.
const ASSET: &str = "USDT";
pub struct CryptoBotProvider {
pub api_token: String,
/// `https://pay.crypt.bot/api` (mainnet) или `https://testnet-pay.crypt.bot/api`.
pub base_url: String,
}
impl CryptoBotProvider {
fn client(&self) -> Result<reqwest::Client, AppError> {
use reqwest::header::{HeaderMap, HeaderValue};
let mut headers = HeaderMap::new();
headers.insert(
"Crypto-Pay-API-Token",
HeaderValue::from_str(&self.api_token).map_err(|e| {
AppError::Internal(format!("Некорректный CRYPTOBOT_API_TOKEN: {e}"))
})?,
);
reqwest::Client::builder()
.default_headers(headers)
.build()
.map_err(|e| AppError::Internal(format!("Ошибка создания HTTP-клиента: {e}")))
}
}
#[async_trait]
impl PaymentProvider for CryptoBotProvider {
fn provider_id(&self) -> &'static str {
"CRYPTOBOT"
}
async fn create_checkout_session(&self, invoice: &InvoiceData) -> Result<Value, AppError> {
// invoice.amount — USD-центы (см. docstring модуля), USDT биллится 1:1.
let amount_usdt = format!("{:.2}", invoice.amount as f64 / 100.0);
let res: Value = self
.client()?
.post(format!("{}/createInvoice", self.base_url))
.json(&json!({
"asset": ASSET,
"amount": amount_usdt,
"description": "Netrunner VPN — подписка",
"payload": invoice.invoice_id.to_string(),
}))
.send()
.await
.map_err(|e| AppError::Internal(format!("Ошибка сети CryptoBot API: {e}")))?
.json()
.await
.map_err(|e| AppError::Internal(format!("Ошибка парсинга ответа CryptoBot: {e}")))?;
if res["ok"].as_bool() != Some(true) {
return Err(AppError::Internal(format!(
"CryptoBot createInvoice отказал: {}",
res["error"]
)));
}
let result = &res["result"];
Ok(json!({
// Наш собственный ID (не CryptoBot'а) — чтобы вызывающий код мог
// позже дёрнуть `confirm_payment(invoice_id, ...)`.
"invoice_id": invoice.invoice_id,
"cryptobot_invoice_id": result["invoice_id"],
"pay_url": result["bot_invoice_url"].as_str().or_else(|| result["pay_url"].as_str()),
}))
}
/// `external_tx_id` здесь — не хэш транзакции, а собственный `invoice_id`
/// CryptoBot (полученный из `create_checkout_session`, см. `bot.rs::pay_check`
/// — он приходит в callback-данных, отдельно в БД не хранится).
async fn verify_payment(
&self,
external_tx_id: &str,
expected_amount: i64,
expected_invoice_id: &str,
) -> Result<bool, AppError> {
let res: Value = self
.client()?
.get(format!("{}/getInvoices", self.base_url))
.query(&[("invoice_ids", external_tx_id)])
.send()
.await
.map_err(|e| AppError::Internal(format!("Ошибка сети CryptoBot API: {e}")))?
.json()
.await
.map_err(|e| AppError::Internal(format!("Ошибка парсинга ответа CryptoBot: {e}")))?;
if res["ok"].as_bool() != Some(true) {
return Ok(false);
}
let Some(item) = res["result"]["items"].as_array().and_then(|a| a.first()) else {
return Ok(false);
};
let status = item["status"].as_str().unwrap_or("");
let payload = item["payload"].as_str().unwrap_or("");
let paid_amount: f64 = item["amount"]
.as_str()
.and_then(|s| s.parse().ok())
.unwrap_or(0.0);
let expected_usdt = expected_amount as f64 / 100.0;
if status == "paid" && payload == expected_invoice_id && paid_amount >= expected_usdt - 0.01
{
tracing::info!("CryptoBot invoice {} verified!", external_tx_id);
Ok(true)
} else {
tracing::warn!(
"CryptoBot mismatch. Expected invoice: {}, got payload: {}, status: {}",
expected_invoice_id,
payload,
status
);
Ok(false)
}
}
}
+1
View File
@@ -7,6 +7,7 @@ use async_trait::async_trait;
use serde_json::Value; use serde_json::Value;
use uuid::Uuid; use uuid::Uuid;
pub mod cryptobot;
pub mod ton; pub mod ton;
pub struct InvoiceData { pub struct InvoiceData {
+447 -7
View File
@@ -40,6 +40,23 @@ impl Clone for BillingService {
"TON", "TON",
Box::new(super::providers::ton::TonProvider { master_wallet }), Box::new(super::providers::ton::TonProvider { master_wallet }),
); );
// Опционален: без токена оплата через CryptoBot просто вернёт "provider
// not supported" (см. `initiate_payment`), а не уронит инстанс при
// старте — в отличие от обязательных секретов вроде TON_MASTER_WALLET.
if let Ok(api_token) = std::env::var("CRYPTOBOT_API_TOKEN") {
let base_url = if std::env::var("CRYPTOBOT_TESTNET").as_deref() == Ok("true") {
"https://testnet-pay.crypt.bot/api".to_string()
} else {
"https://pay.crypt.bot/api".to_string()
};
providers.insert(
"CRYPTOBOT",
Box::new(super::providers::cryptobot::CryptoBotProvider {
api_token,
base_url,
}),
);
}
Self { Self {
repo: self.repo.clone(), repo: self.repo.clone(),
@@ -60,6 +77,23 @@ impl BillingService {
"TON", "TON",
Box::new(super::providers::ton::TonProvider { master_wallet }), Box::new(super::providers::ton::TonProvider { master_wallet }),
); );
// Опционален: без токена оплата через CryptoBot просто вернёт "provider
// not supported" (см. `initiate_payment`), а не уронит инстанс при
// старте — в отличие от обязательных секретов вроде TON_MASTER_WALLET.
if let Ok(api_token) = std::env::var("CRYPTOBOT_API_TOKEN") {
let base_url = if std::env::var("CRYPTOBOT_TESTNET").as_deref() == Ok("true") {
"https://testnet-pay.crypt.bot/api".to_string()
} else {
"https://pay.crypt.bot/api".to_string()
};
providers.insert(
"CRYPTOBOT",
Box::new(super::providers::cryptobot::CryptoBotProvider {
api_token,
base_url,
}),
);
}
Self { Self {
repo, repo,
@@ -68,6 +102,14 @@ impl BillingService {
} }
} }
/// Активные тарифы с USD-ценой — для плиточного выбора тарифа в боте.
pub async fn list_plans_usd(&self) -> Result<Vec<(String, i64)>, AppError> {
self.repo
.list_active_plans_usd()
.await
.map_err(AppError::Database)
}
/// Курс TON к валюте из кеша; `None` при промахе/недоступности Redis — /// Курс TON к валюте из кеша; `None` при промахе/недоступности Redis —
/// вызывающий код должен сходить за свежим значением и заполнить кеш сам. /// вызывающий код должен сходить за свежим значением и заполнить кеш сам.
async fn get_cached_ton_rate(&self, currency_lower: &str) -> Option<rust_decimal::Decimal> { async fn get_cached_ton_rate(&self, currency_lower: &str) -> Option<rust_decimal::Decimal> {
@@ -115,6 +157,11 @@ impl BillingService {
AppError::BusinessLogic("Тариф недоступен для данного региона".into()) AppError::BusinessLogic("Тариф недоступен для данного региона".into())
})?; })?;
// Курс TON нужен только самому TON-провайдеру — блокчейн понимает
// только nanoTON, а не фиат. Остальные провайдеры (CryptoBot: USDT
// 1:1 к USD; в будущем карточные эквайринги) биллятся напрямую в
// валюте `plan_prices`, без пересчёта.
let invoice_amount = if provider_name == "TON" {
// --- КУРС TON: сначала кеш (Redis, TTL 60с), иначе синхронный запрос к TonAPI --- // --- КУРС TON: сначала кеш (Redis, TTL 60с), иначе синхронный запрос к TonAPI ---
let currency_lower = currency.to_lowercase(); let currency_lower = currency.to_lowercase();
let currency_upper = currency.to_uppercase(); let currency_upper = currency.to_uppercase();
@@ -136,7 +183,9 @@ impl BillingService {
.get(&url) .get(&url)
.send() .send()
.await .await
.map_err(|e| AppError::Internal(format!("Ошибка сети при запросе курса: {}", e)))? .map_err(|e| {
AppError::Internal(format!("Ошибка сети при запросе курса: {}", e))
})?
.json() .json()
.await .await
.map_err(|e| AppError::Internal(format!("Ошибка парсинга курса TON: {}", e)))?; .map_err(|e| AppError::Internal(format!("Ошибка парсинга курса TON: {}", e)))?;
@@ -155,17 +204,20 @@ impl BillingService {
}; };
// ---------------------------------------- // ----------------------------------------
let nanotons = Self::calculate_ton_invoice(fiat_amount_minimal, ton_rate) Self::calculate_ton_invoice(fiat_amount_minimal, ton_rate)
.map_err(AppError::BusinessLogic)?; .map_err(AppError::BusinessLogic)?
} else {
fiat_amount_minimal
};
let invoice_id = self let invoice_id = self
.repo .repo
.create_invoice(user.id, plan_name, provider_name, currency, nanotons) .create_invoice(user.id, plan_name, provider_name, currency, invoice_amount)
.await?; .await?;
let invoice_data = InvoiceData { let invoice_data = InvoiceData {
invoice_id, invoice_id,
amount: nanotons, amount: invoice_amount,
currency: currency.into(), currency: currency.into(),
}; };
@@ -199,8 +251,18 @@ impl BillingService {
return Ok(()); return Ok(());
} }
// 2. Получаем провайдера // 2. Получаем провайдера, которым был выставлен именно этот инвойс —
let provider = self.providers.get("TON").unwrap(); // раньше здесь был захардкожен "TON", что ломало подтверждение любого
// другого провайдера (см. `invoice.provider`, заполняется в `create_invoice`).
let provider = self
.providers
.get(invoice.provider.as_str())
.ok_or_else(|| {
AppError::Internal(format!(
"Провайдер {} не сконфигурирован на этом инстансе",
invoice.provider
))
})?;
// 3. Провайдер лезет в блокчейн и проверяет транзакцию // 3. Провайдер лезет в блокчейн и проверяет транзакцию
let is_valid = provider let is_valid = provider
@@ -233,6 +295,72 @@ impl BillingService {
.reward_referrer(invoice.user_id, revshare_bonus) .reward_referrer(invoice.user_id, revshare_bonus)
.await; .await;
// 7. Партнёрская комиссия — независимо от рефералки выше, отдельная
// система (см. modules::staff). Считаем от фиатной цены тарифа В
// ВАЛЮТЕ ИНВОЙСА через get_plan_price, а НЕ от invoice.amount:
// invoice.amount для TON — это nanoTON (+2% буфер на конвертацию),
// не фиат; для CryptoBot это фиат-центы 1:1, но полагаться на то,
// каким провайдером оплачен именно этот инвойс, неверно —
// get_plan_price(plan_name, currency) корректен одинаково для всех
// провайдеров. Ошибка здесь не должна ронять подтверждение платежа
// (та же resilience, что у reward_referrer выше).
if let Err(e) = self.record_partner_commission_if_referred(&invoice).await {
tracing::warn!(
"Не удалось начислить партнёрскую комиссию по инвойсу {}: {:?}",
invoice.id,
e
);
}
Ok(())
}
/// См. комментарий в шаге 7 `confirm_payment` — вынесено отдельным
/// методом ради `?`-раннего-выхода без загрязнения основной функции.
async fn record_partner_commission_if_referred(
&self,
invoice: &crate::db::repository::InvoiceRecord,
) -> Result<(), AppError> {
let Some(buyer) = self.repo.get_user_by_id(invoice.user_id).await? else {
return Ok(());
};
let Some(partner_id) = buyer.referred_by_partner_id else {
return Ok(());
};
let Some(partner) = self.repo.get_user_by_id(partner_id).await? else {
return Ok(());
};
let Some(percent) = partner.commission_percent else {
return Ok(()); // партнёр без выставленного процента — молча пропускаем
};
let Some(fiat_amount_minimal) = self
.repo
.get_plan_price(&invoice.plan_name, &invoice.currency)
.await?
else {
return Ok(()); // тариф/валюта более не активны — пропускаем
};
use rust_decimal::prelude::{FromPrimitive, ToPrimitive};
let commission = (rust_decimal::Decimal::from_i64(fiat_amount_minimal).unwrap_or_default()
* percent
/ rust_decimal::Decimal::from(100))
.round()
.to_i64()
.unwrap_or(0);
if commission > 0 {
self.repo
.record_partner_commission(
partner_id,
invoice.id,
invoice.user_id,
&invoice.currency,
commission,
percent,
)
.await?;
}
Ok(()) Ok(())
} }
@@ -356,3 +484,315 @@ impl BillingService {
Ok(nanotons) Ok(nanotons)
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::db::repository::PgRepository;
use crate::modules::billing::providers::{InvoiceData, PaymentProvider};
use async_trait::async_trait;
use rust_decimal::Decimal;
use sqlx::PgPool;
// --- calculate_ton_invoice: чистая арифметика, без БД ---
#[test]
fn test_calculate_ton_invoice_applies_2_percent_buffer() {
// $10.00 (1000 центов) по курсу 2 USD/TON -> 5 TON чистых -> +2% буфер -> 5.1 TON
let rate = Decimal::new(2, 0);
let nanotons = BillingService::calculate_ton_invoice(1000, rate).unwrap();
assert_eq!(nanotons, 5_100_000_000);
}
#[test]
fn test_calculate_ton_invoice_zero_rate_errors() {
let err = BillingService::calculate_ton_invoice(1000, Decimal::ZERO).unwrap_err();
assert!(err.contains("не может быть нулевым"));
}
// --- confirm_payment: владение, идемпотентность, маршрутизация провайдера ---
struct FakeProvider {
id: &'static str,
verify_result: bool,
}
#[async_trait]
impl PaymentProvider for FakeProvider {
fn provider_id(&self) -> &'static str {
self.id
}
async fn create_checkout_session(
&self,
_invoice: &InvoiceData,
) -> Result<serde_json::Value, AppError> {
Ok(serde_json::json!({}))
}
async fn verify_payment(
&self,
_external_tx_id: &str,
_expected_amount: i64,
_expected_invoice_id: &str,
) -> Result<bool, AppError> {
Ok(self.verify_result)
}
}
fn service_with_fake_provider(
repo: Arc<dyn AppRepository>,
verify_result: bool,
) -> BillingService {
let mut providers: HashMap<&'static str, Box<dyn PaymentProvider>> = HashMap::new();
providers.insert(
"TEST",
Box::new(FakeProvider {
id: "TEST",
verify_result,
}),
);
BillingService {
repo,
providers,
redis: None,
}
}
#[sqlx::test]
async fn test_confirm_payment_rejects_non_owner(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = service_with_fake_provider(repo.clone(), true);
let owner = repo.upsert_user(1, None).await.unwrap();
let stranger = repo.upsert_user(2, None).await.unwrap();
let invoice_id = repo
.create_invoice(owner.id, "GHOST_1M", "TEST", "USD", 500)
.await
.unwrap();
let err = svc
.confirm_payment(stranger.id, invoice_id, "tx1")
.await
.unwrap_err();
assert!(matches!(err, AppError::Unauthorized(_)));
// Инвойс не должен был измениться.
let invoice = repo.get_invoice(invoice_id).await.unwrap().unwrap();
assert_eq!(invoice.status, "pending");
}
#[sqlx::test]
async fn test_confirm_payment_idempotent_when_already_paid(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
// verify_result=false: если бы идемпотентность не сработала, второй вызов
// упал бы на верификации — но код обязан выйти раньше, на шаге "already paid".
let svc = service_with_fake_provider(repo.clone(), false);
let user = repo.upsert_user(1, None).await.unwrap();
let invoice_id = repo
.create_invoice(user.id, "GHOST_1M", "TEST", "USD", 500)
.await
.unwrap();
repo.update_invoice_status(invoice_id, "paid", Some("tx0"))
.await
.unwrap();
svc.confirm_payment(user.id, invoice_id, "tx1")
.await
.expect("повторное подтверждение уже оплаченного инвойса должно быть no-op");
}
#[sqlx::test]
async fn test_confirm_payment_unknown_provider_errors(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = service_with_fake_provider(repo.clone(), true);
let user = repo.upsert_user(1, None).await.unwrap();
// Инвойс выставлен провайдером, которого нет в HashMap этого инстанса —
// регрессионный тест на баг "confirm_payment жёстко ходил в TON независимо
// от invoice.provider".
let invoice_id = repo
.create_invoice(user.id, "GHOST_1M", "NOT_CONFIGURED", "USD", 500)
.await
.unwrap();
let err = svc
.confirm_payment(user.id, invoice_id, "tx1")
.await
.unwrap_err();
assert!(matches!(err, AppError::Internal(_)));
}
#[sqlx::test]
async fn test_confirm_payment_failed_verification_marks_invoice_failed(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = service_with_fake_provider(repo.clone(), false);
let user = repo.upsert_user(1, None).await.unwrap();
let invoice_id = repo
.create_invoice(user.id, "GHOST_1M", "TEST", "USD", 500)
.await
.unwrap();
let err = svc
.confirm_payment(user.id, invoice_id, "tx1")
.await
.unwrap_err();
assert!(matches!(err, AppError::BusinessLogic(_)));
let invoice = repo.get_invoice(invoice_id).await.unwrap().unwrap();
assert_eq!(invoice.status, "failed");
// Подписка не должна была активироваться.
assert!(repo.get_subscription(user.id).await.unwrap().is_none());
}
#[sqlx::test]
async fn test_confirm_payment_success_activates_subscription_and_rewards_referrer(
pool: PgPool,
) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = service_with_fake_provider(repo.clone(), true);
let referrer = repo.upsert_user(1, None).await.unwrap();
let buyer = repo.upsert_user(2, None).await.unwrap();
repo.create_referral(referrer.id, buyer.id).await.unwrap();
let invoice_id = repo
.create_invoice(buyer.id, "GHOST_1M", "TEST", "USD", 500) // $5.00
.await
.unwrap();
svc.confirm_payment(buyer.id, invoice_id, "tx1")
.await
.expect("успешная верификация должна пройти");
let invoice = repo.get_invoice(invoice_id).await.unwrap().unwrap();
assert_eq!(invoice.status, "paid");
let sub = repo
.get_subscription(buyer.id)
.await
.unwrap()
.expect("подписка должна активироваться");
assert_eq!(sub.plan_name, "GHOST_1M");
assert_eq!(sub.status, "active");
// 10% от суммы инвойса (500) = 50, зачисляется рефереру на баланс.
let referrer_updated = repo.get_user_by_id(referrer.id).await.unwrap().unwrap();
assert_eq!(referrer_updated.balance, 50);
}
// --- Партнёрская комиссия: считается от get_plan_price, не от invoice.amount ---
#[sqlx::test]
async fn test_partner_commission_uses_plan_price_not_invoice_amount(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = service_with_fake_provider(repo.clone(), true);
let partner = repo
.create_staff_user(
"partner1",
"hash",
"partner",
false,
Some(Decimal::new(10, 0)), // 10%
Some("p_abc123"),
)
.await
.unwrap();
let buyer = repo.upsert_user(2, None).await.unwrap();
repo.set_referred_by_partner(buyer.id, partner.id)
.await
.unwrap();
// invoice.amount = 999999 — намеренно НЕ совпадает с реальной ценой тарифа
// (симулирует TON-инвойс, где amount — nanoTON, а не фиат), чтобы доказать,
// что комиссия считается через get_plan_price("GHOST_1M", "USD") = 500,
// а не от этого числа.
let invoice_id = repo
.create_invoice(buyer.id, "GHOST_1M", "TEST", "USD", 999_999)
.await
.unwrap();
svc.confirm_payment(buyer.id, invoice_id, "tx1")
.await
.unwrap();
let earnings = repo.get_partner_earnings(partner.id).await.unwrap();
let usd_earnings = earnings.iter().find(|e| e.currency == "USD").unwrap();
// 10% от 500 (реальная цена GHOST_1M/USD) = 50, а НЕ 10% от 999999.
assert_eq!(usd_earnings.total_earned, 50);
}
#[sqlx::test]
async fn test_partner_commission_idempotent_on_duplicate_confirm(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = service_with_fake_provider(repo.clone(), true);
let partner = repo
.create_staff_user(
"partner1",
"hash",
"partner",
false,
Some(Decimal::new(10, 0)),
Some("p_abc123"),
)
.await
.unwrap();
let buyer = repo.upsert_user(2, None).await.unwrap();
repo.set_referred_by_partner(buyer.id, partner.id)
.await
.unwrap();
let invoice_id = repo
.create_invoice(buyer.id, "GHOST_1M", "TEST", "USD", 500)
.await
.unwrap();
svc.confirm_payment(buyer.id, invoice_id, "tx1")
.await
.unwrap();
// Второй вызов — инвойс уже "paid", подтверждение — no-op (см. тест выше),
// поэтому record_partner_commission_if_referred не должен вызваться снова.
svc.confirm_payment(buyer.id, invoice_id, "tx1")
.await
.unwrap();
let earnings = repo.get_partner_earnings(partner.id).await.unwrap();
let usd_earnings = earnings.iter().find(|e| e.currency == "USD").unwrap();
assert_eq!(
usd_earnings.total_earned, 50,
"комиссия не должна начислиться дважды"
);
}
#[sqlx::test]
async fn test_partner_commission_skipped_without_percent(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = service_with_fake_provider(repo.clone(), true);
// Партнёр без выставленного процента — комиссия не должна начисляться,
// и confirm_payment не должен упасть.
let partner = repo
.create_staff_user("partner1", "hash", "partner", false, None, Some("p_abc123"))
.await
.unwrap();
let buyer = repo.upsert_user(2, None).await.unwrap();
repo.set_referred_by_partner(buyer.id, partner.id)
.await
.unwrap();
let invoice_id = repo
.create_invoice(buyer.id, "GHOST_1M", "TEST", "USD", 500)
.await
.unwrap();
svc.confirm_payment(buyer.id, invoice_id, "tx1")
.await
.unwrap();
let earnings = repo.get_partner_earnings(partner.id).await.unwrap();
assert!(earnings.is_empty());
}
}
+2
View File
@@ -4,5 +4,7 @@
pub mod auth; pub mod auth;
pub mod billing; pub mod billing;
pub mod nodes; pub mod nodes;
pub mod proxy_internal;
pub mod referrals; pub mod referrals;
pub mod staff;
pub mod users; pub mod users;
+5 -3
View File
@@ -14,7 +14,7 @@ use crate::{
db::models::{CreateNodePayload, User, VpnNode}, db::models::{CreateNodePayload, User, VpnNode},
error::AppError, error::AppError,
modules::{ modules::{
auth::{admin_guard, auth_guard}, auth::{auth_guard, node_manage_guard},
nodes::service::NodeService, nodes::service::NodeService,
}, },
}; };
@@ -41,8 +41,10 @@ pub fn admin_router(state: ApiState) -> Router<ApiState> {
.route("/", get(list_all_nodes).post(add_node)) .route("/", get(list_all_nodes).post(add_node))
.route("/{id}", delete(remove_node)) .route("/{id}", delete(remove_node))
.route("/{id}/restart", post(restart_node)) .route("/{id}/restart", post(restart_node))
// Теперь admin_guard использует from_fn_with_state .route_layer(middleware::from_fn_with_state(
.route_layer(middleware::from_fn_with_state(state.clone(), admin_guard)) state.clone(),
node_manage_guard,
))
.route_layer(middleware::from_fn_with_state(state.clone(), auth_guard)) .route_layer(middleware::from_fn_with_state(state.clone(), auth_guard))
} }
+268
View File
@@ -0,0 +1,268 @@
//! Внутренний контракт для `netrunner-proxy`: проверка JWT-токена клиента и учёт
//! расхода трафика. Это не пользовательская сессия — прокси не логинится как юзер,
//! поэтому вместо `auth_guard`/cookie здесь общий секрет (`X-Internal-Secret`,
//! см. [`internal_secret_guard`]), которым делятся все прокси-ноды и бэкенд.
use crate::{api::ApiState, error::AppError};
use axum::{
extract::{Request, State},
middleware::Next,
response::Response,
routing::post,
Json, Router,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub async fn internal_secret_guard(
State(state): State<ApiState>,
req: Request,
next: Next,
) -> Result<Response, AppError> {
let provided = req
.headers()
.get("X-Internal-Secret")
.and_then(|v| v.to_str().ok());
if provided != Some(state.proxy_internal_secret.as_str()) {
return Err(AppError::Unauthorized("Invalid internal secret".into()));
}
Ok(next.run(req).await)
}
pub fn router() -> Router<ApiState> {
Router::new()
.route("/validate", post(validate_api))
.route("/usage", post(usage_api))
}
#[derive(Deserialize)]
struct ValidatePayload {
token: String,
}
#[derive(Serialize, Debug)]
struct ValidateResponse {
user_id: Uuid,
limit_bytes: Option<i64>,
used_bytes: i64,
}
/// Декодирует access-JWT тем же путём, что и `auth::guard::auth_guard`, но без
/// cookie/CSRF-логики (прокси всегда шлёт Bearer в теле, не в заголовках браузера).
async fn validate_api(
State(state): State<ApiState>,
Json(payload): Json<ValidatePayload>,
) -> Result<Json<ValidateResponse>, AppError> {
let token_data = jsonwebtoken::decode::<crate::modules::auth::service::Claims>(
&payload.token,
&jsonwebtoken::DecodingKey::from_secret(state.jwt_secret.as_bytes()),
&jsonwebtoken::Validation::default(),
)
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
let user_id = Uuid::parse_str(&token_data.claims.sub)
.map_err(|_| AppError::Unauthorized("Invalid token payload".into()))?;
let user = state
.repo
.get_user_by_id(user_id)
.await?
.ok_or(AppError::UserNotFound)?;
Ok(Json(ValidateResponse {
user_id: user.id,
limit_bytes: user.data_limit_bytes,
used_bytes: user.data_used_bytes,
}))
}
#[derive(Deserialize)]
struct UsagePayload {
user_id: Uuid,
delta_bytes: i64,
}
#[derive(Serialize, Debug)]
struct UsageResponse {
used_bytes: i64,
limit_bytes: Option<i64>,
over_limit: bool,
}
/// Прибавляет репортнутую прокси дельту трафика к счётчику юзера и сразу
/// отвечает, не превышен ли лимит — прокси разрывает сессию в тот же тик, если
/// `over_limit == true`, не делая отдельный round-trip.
async fn usage_api(
State(state): State<ApiState>,
Json(payload): Json<UsagePayload>,
) -> Result<Json<UsageResponse>, AppError> {
let (used_bytes, limit_bytes) = state
.repo
.apply_usage_delta(payload.user_id, payload.delta_bytes)
.await?
.ok_or(AppError::UserNotFound)?;
let over_limit = limit_bytes.is_some_and(|limit| used_bytes >= limit);
Ok(Json(UsageResponse {
used_bytes,
limit_bytes,
over_limit,
}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::repository::{AppRepository, PgRepository};
use crate::modules::auth::service::{AuthService, Claims};
use crate::modules::billing::service::BillingService;
use crate::modules::nodes::service::NodeService;
use crate::modules::referrals::service::ReferralService;
use crate::modules::staff::service::StaffService;
use crate::modules::users::service::UserService;
use jsonwebtoken::{encode, EncodingKey, Header};
use sqlx::PgPool;
use std::sync::Arc;
const SECRET: &str = "test-jwt-secret";
fn build_state(pool: PgPool) -> ApiState {
std::env::set_var(
"TON_MASTER_WALLET",
"UQtest_wallet_for_proxy_internal_tests",
);
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
ApiState {
repo: repo.clone(),
auth_service: AuthService::new(repo.clone()),
billing_service: BillingService::new(repo.clone(), None),
node_service: NodeService::new(repo.clone()),
referral_service: ReferralService::new(repo.clone()),
user_service: UserService::new(repo.clone()),
staff_service: StaffService::new(repo.clone()),
jwt_secret: SECRET.into(),
bot_token: "test-bot-token".into(),
bot_username: "test_bot".into(),
cookie_domain: "".into(),
cookie_secure: false,
csrf_allowed_origins: vec![],
proxy_internal_secret: "test-internal-secret".into(),
}
}
fn sign_token(user_id: uuid::Uuid) -> String {
let claims = Claims {
sub: user_id.to_string(),
tg_id: None,
exp: (chrono::Utc::now() + chrono::Duration::minutes(15)).timestamp() as usize,
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(SECRET.as_bytes()),
)
.unwrap()
}
#[sqlx::test]
async fn test_validate_api_rejects_garbage_token(pool: PgPool) {
let state = build_state(pool);
let err = validate_api(
State(state),
Json(ValidatePayload {
token: "not-a-real-jwt".into(),
}),
)
.await
.unwrap_err();
assert!(matches!(err, AppError::Unauthorized(_)));
}
#[sqlx::test]
async fn test_validate_api_accepts_valid_token_and_returns_limits(pool: PgPool) {
let state = build_state(pool.clone());
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let user = repo.upsert_user(1, None).await.unwrap();
repo.set_data_limit(user.id, Some(1_000_000)).await.unwrap();
let token = sign_token(user.id);
let resp = validate_api(State(state), Json(ValidatePayload { token }))
.await
.unwrap();
assert_eq!(resp.user_id, user.id);
assert_eq!(resp.limit_bytes, Some(1_000_000));
assert_eq!(resp.used_bytes, 0);
}
#[sqlx::test]
async fn test_usage_api_unknown_user_not_found(pool: PgPool) {
let state = build_state(pool);
let err = usage_api(
State(state),
Json(UsagePayload {
user_id: uuid::Uuid::new_v4(),
delta_bytes: 100,
}),
)
.await
.unwrap_err();
assert!(matches!(err, AppError::UserNotFound));
}
#[sqlx::test]
async fn test_usage_api_over_limit_boundary(pool: PgPool) {
let state = build_state(pool.clone());
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let user = repo.upsert_user(1, None).await.unwrap();
repo.set_data_limit(user.id, Some(1000)).await.unwrap();
// 999 из 1000 — ещё не превышен лимит.
let resp1 = usage_api(
State(state.clone()),
Json(UsagePayload {
user_id: user.id,
delta_bytes: 999,
}),
)
.await
.unwrap();
assert_eq!(resp1.used_bytes, 999);
assert!(!resp1.over_limit);
// Ровно 1000 из 1000 — лимит уже считается превышенным (>=, не >).
let resp2 = usage_api(
State(state),
Json(UsagePayload {
user_id: user.id,
delta_bytes: 1,
}),
)
.await
.unwrap();
assert_eq!(resp2.used_bytes, 1000);
assert!(resp2.over_limit);
}
#[sqlx::test]
async fn test_usage_api_unlimited_user_never_over_limit(pool: PgPool) {
let state = build_state(pool.clone());
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let user = repo.upsert_user(1, None).await.unwrap();
// data_limit_bytes остаётся NULL — безлимит.
let resp = usage_api(
State(state),
Json(UsagePayload {
user_id: user.id,
delta_bytes: 999_999_999,
}),
)
.await
.unwrap();
assert!(!resp.over_limit);
assert_eq!(resp.limit_bytes, None);
}
}
+215
View File
@@ -0,0 +1,215 @@
//! HTTP-роуты ролевой админки. Named-guard'ы (`owner_guard`/`staff_guard`/
//! `partner_guard`) уже проверяют роль до входа в хендлер — сами хендлеры
//! читают текущего юзера только через `Extension<User>`.
use axum::{
extract::{Extension, Path, State},
middleware,
routing::{get, patch, post},
Json, Router,
};
use serde::Deserialize;
use serde_json::{json, Value};
use uuid::Uuid;
use crate::{
api::ApiState,
db::models::User,
error::AppError,
modules::auth::{auth_guard, owner_guard, partner_guard, staff_guard},
};
/// Любой залогиненный видит свою роль/права — нужно фронту, чтобы решить,
/// что показать и не пора ли редиректить на `/admin/login`.
pub fn router(state: ApiState) -> Router<ApiState> {
Router::new()
.route("/me", get(get_my_role))
.route_layer(middleware::from_fn_with_state(state, auth_guard))
}
/// Только Owner: создание модераторов, выдача им права на управление нодами.
pub fn owner_router(state: ApiState) -> Router<ApiState> {
Router::new()
.route("/moderators", post(create_moderator))
.route("/users/{id}/permissions", patch(set_permissions))
.route_layer(middleware::from_fn_with_state(state.clone(), owner_guard))
.route_layer(middleware::from_fn_with_state(state, auth_guard))
}
/// Owner + Moderator: создание партнёров, список staff-юзеров, заявки на вывод.
pub fn staff_router(state: ApiState) -> Router<ApiState> {
Router::new()
.route("/partners", post(create_partner))
.route("/staff-users", get(list_staff))
.route("/withdrawals", get(list_withdrawals))
.route("/withdrawals/{id}", patch(update_withdrawal))
.route_layer(middleware::from_fn_with_state(state.clone(), staff_guard))
.route_layer(middleware::from_fn_with_state(state, auth_guard))
}
/// Только Partner: свой заработок и заявки на вывод.
pub fn partner_router(state: ApiState) -> Router<ApiState> {
Router::new()
.route("/withdrawals", post(request_withdrawal))
.route("/earnings", get(get_my_earnings))
.route_layer(middleware::from_fn_with_state(state.clone(), partner_guard))
.route_layer(middleware::from_fn_with_state(state, auth_guard))
}
async fn get_my_role(Extension(user): Extension<User>) -> Json<Value> {
Json(json!({
"role": user.role,
"can_manage_nodes": user.can_manage_nodes,
"username": user.username,
"partner_code": user.partner_code,
"commission_percent": user.commission_percent,
}))
}
#[derive(Deserialize)]
pub struct CreateModeratorPayload {
pub username: String,
pub password: String,
pub can_manage_nodes: bool,
}
async fn create_moderator(
State(state): State<ApiState>,
Json(p): Json<CreateModeratorPayload>,
) -> Result<Json<Value>, AppError> {
let user = state
.staff_service
.create_moderator(&p.username, &p.password, p.can_manage_nodes)
.await?;
Ok(Json(json!({
"id": user.id,
"username": user.username,
"role": user.role,
"can_manage_nodes": user.can_manage_nodes,
})))
}
#[derive(Deserialize)]
pub struct CreatePartnerPayload {
pub username: String,
pub password: String,
pub commission_percent: rust_decimal::Decimal,
}
async fn create_partner(
State(state): State<ApiState>,
Json(p): Json<CreatePartnerPayload>,
) -> Result<Json<Value>, AppError> {
let user = state
.staff_service
.create_partner(&p.username, &p.password, p.commission_percent)
.await?;
Ok(Json(json!({
"id": user.id,
"username": user.username,
"partner_code": user.partner_code,
"commission_percent": user.commission_percent,
})))
}
#[derive(Deserialize)]
pub struct SetPermissionsPayload {
pub can_manage_nodes: bool,
}
async fn set_permissions(
State(state): State<ApiState>,
Path(id): Path<Uuid>,
Json(p): Json<SetPermissionsPayload>,
) -> Result<Json<Value>, AppError> {
let user = state
.staff_service
.set_can_manage_nodes(id, p.can_manage_nodes)
.await?
.ok_or(AppError::UserNotFound)?;
Ok(Json(json!({
"id": user.id,
"can_manage_nodes": user.can_manage_nodes,
})))
}
async fn list_staff(State(state): State<ApiState>) -> Result<Json<Vec<Value>>, AppError> {
let users = state.staff_service.list_staff().await?;
Ok(Json(
users
.into_iter()
.map(|u| {
json!({
"id": u.id,
"username": u.username,
"role": u.role,
"can_manage_nodes": u.can_manage_nodes,
"commission_percent": u.commission_percent,
"partner_code": u.partner_code,
})
})
.collect(),
))
}
async fn list_withdrawals(State(state): State<ApiState>) -> Result<Json<Value>, AppError> {
let list = state.staff_service.list_all_withdrawals().await?;
Ok(Json(json!(list)))
}
#[derive(Deserialize)]
pub struct UpdateWithdrawalPayload {
pub status: String,
}
async fn update_withdrawal(
Extension(user): Extension<User>,
State(state): State<ApiState>,
Path(id): Path<Uuid>,
Json(p): Json<UpdateWithdrawalPayload>,
) -> Result<Json<Value>, AppError> {
if !["approved", "rejected", "paid"].contains(&p.status.as_str()) {
return Err(AppError::BusinessLogic("Недопустимый статус.".into()));
}
let req = state
.staff_service
.update_withdrawal(id, &p.status, user.id)
.await?
.ok_or_else(|| AppError::NotFound("Заявка не найдена".into()))?;
Ok(Json(json!(req)))
}
async fn get_my_earnings(
Extension(user): Extension<User>,
State(state): State<ApiState>,
) -> Result<Json<Value>, AppError> {
let earnings = state.staff_service.get_earnings(user.id).await?;
let withdrawals = state
.staff_service
.list_withdrawals_for_partner(user.id)
.await?;
Ok(Json(json!({
"earnings": earnings,
"withdrawals": withdrawals,
"partner_code": user.partner_code,
})))
}
#[derive(Deserialize)]
pub struct RequestWithdrawalPayload {
pub currency: String,
pub amount: i64,
pub note: Option<String>,
}
async fn request_withdrawal(
Extension(user): Extension<User>,
State(state): State<ApiState>,
Json(p): Json<RequestWithdrawalPayload>,
) -> Result<Json<Value>, AppError> {
let req = state
.staff_service
.request_withdrawal(user.id, &p.currency, p.amount, p.note.as_deref())
.await?;
Ok(Json(json!(req)))
}
+8
View File
@@ -0,0 +1,8 @@
//! Ролевая админка: Owner создаёт Moderator/Partner, Moderator тоже может
//! создавать Partner. Модератору отдельно выдаётся право на управление
//! нодами (`can_manage_nodes`). Партнёры видят свой заработок и подают
//! заявки на вывод; Owner/Moderator эти заявки рассматривают. См. README.md
//! рядом с этим файлом и Context в плане `idempotent-foraging-crane.md`.
pub mod controller;
pub mod service;
+318
View File
@@ -0,0 +1,318 @@
//! Бизнес-логика ролевой админки: создание Owner/Moderator/Partner,
//! переключение права на управление нодами, заработок и заявки на вывод
//! партнёров. Начисление самой комиссии живёт в
//! `billing::service::confirm_payment` (отдельно от рефералки) — здесь
//! только чтение/создание записей.
use crate::{
db::models::{PartnerEarningsByCurrency, User, WithdrawalRequest},
db::repository::AppRepository,
error::AppError,
modules::auth::service::AuthService,
};
use rand::Rng;
use std::sync::Arc;
use uuid::Uuid;
#[derive(Clone)]
pub struct StaffService {
repo: Arc<dyn AppRepository>,
}
/// Короткий код диплинка `t.me/<bot>?start=<partner_code>` — префикс `p_`
/// нужен боту, чтобы однозначно отличать партнёрский код от numeric
/// referrer-tg_id и от `auth_sessions.auth_code` (см. `bot.rs::Command::Start`).
fn generate_partner_code() -> String {
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
let mut rng = rand::thread_rng();
let code: String = (0..6)
.map(|_| CHARSET[rng.gen_range(0..CHARSET.len())] as char)
.collect();
format!("p_{code}")
}
impl StaffService {
pub fn new(repo: Arc<dyn AppRepository>) -> Self {
Self { repo }
}
pub async fn create_moderator(
&self,
username: &str,
password: &str,
can_manage_nodes: bool,
) -> Result<User, AppError> {
let hash = AuthService::hash_password(password)?;
self.repo
.create_staff_user(username, &hash, "moderator", can_manage_nodes, None, None)
.await
.map_err(|e| map_create_error(e, "модератора"))
}
pub async fn create_partner(
&self,
username: &str,
password: &str,
commission_percent: rust_decimal::Decimal,
) -> Result<User, AppError> {
let hash = AuthService::hash_password(password)?;
let code = generate_partner_code();
self.repo
.create_staff_user(
username,
&hash,
"partner",
false,
Some(commission_percent),
Some(&code),
)
.await
.map_err(|e| map_create_error(e, "партнёра"))
}
pub async fn set_can_manage_nodes(
&self,
user_id: Uuid,
enabled: bool,
) -> Result<Option<User>, AppError> {
self.repo
.set_can_manage_nodes(user_id, enabled)
.await
.map_err(AppError::Database)
}
pub async fn list_staff(&self) -> Result<Vec<User>, AppError> {
self.repo
.list_staff_users(None)
.await
.map_err(AppError::Database)
}
pub async fn list_all_withdrawals(&self) -> Result<Vec<WithdrawalRequest>, AppError> {
self.repo
.list_withdrawal_requests(None)
.await
.map_err(AppError::Database)
}
pub async fn update_withdrawal(
&self,
id: Uuid,
new_status: &str,
processed_by: Uuid,
) -> Result<Option<WithdrawalRequest>, AppError> {
self.repo
.update_withdrawal_status(id, new_status, processed_by)
.await
.map_err(AppError::Database)
}
pub async fn get_earnings(
&self,
partner_id: Uuid,
) -> Result<Vec<PartnerEarningsByCurrency>, AppError> {
self.repo
.get_partner_earnings(partner_id)
.await
.map_err(AppError::Database)
}
pub async fn list_withdrawals_for_partner(
&self,
partner_id: Uuid,
) -> Result<Vec<WithdrawalRequest>, AppError> {
self.repo
.list_withdrawal_requests_for_partner(partner_id)
.await
.map_err(AppError::Database)
}
/// Партнёр подаёт заявку на вывод — проверяем, что запрошенная сумма не
/// превышает доступный остаток именно в этой валюте (без конвертации).
/// Проверка баланса и вставка заявки — одна транзакция с блокировкой
/// строки партнёра (`request_partner_withdrawal`), иначе несколько
/// параллельных заявок на весь остаток могут пройти одновременно.
pub async fn request_withdrawal(
&self,
partner_id: Uuid,
currency: &str,
amount: i64,
note: Option<&str>,
) -> Result<WithdrawalRequest, AppError> {
if amount <= 0 {
return Err(AppError::BusinessLogic(
"Сумма вывода должна быть положительной.".into(),
));
}
self.repo
.request_partner_withdrawal(partner_id, currency, amount, note)
.await
.map_err(|e| match e.as_str() {
"INSUFFICIENT_BALANCE" => AppError::BusinessLogic(
"Запрошенная сумма превышает доступный остаток в этой валюте.".into(),
),
_ => AppError::Internal(format!("Ошибка обработки заявки на вывод: {e}")),
})
}
}
fn map_create_error(e: sqlx::Error, kind: &str) -> AppError {
if let sqlx::Error::Database(db_err) = &e {
if db_err.constraint() == Some("users_username_key") {
return AppError::BusinessLogic(format!(
"Логин уже занят, выберите другой для {kind}."
));
}
}
AppError::Database(e)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::repository::PgRepository;
use rust_decimal::Decimal;
use sqlx::PgPool;
#[sqlx::test]
async fn test_create_moderator_duplicate_username_is_friendly_error(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = StaffService::new(repo);
svc.create_moderator("mod1", "pass1", false).await.unwrap();
let err = svc
.create_moderator("mod1", "different-pass", true)
.await
.unwrap_err();
assert!(matches!(err, AppError::BusinessLogic(_)));
}
#[sqlx::test]
async fn test_create_partner_duplicate_username_is_friendly_error(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = StaffService::new(repo);
svc.create_partner("p1", "pass1", Decimal::new(10, 0))
.await
.unwrap();
let err = svc
.create_partner("p1", "different-pass", Decimal::new(20, 0))
.await
.unwrap_err();
assert!(matches!(err, AppError::BusinessLogic(_)));
}
async fn seed_partner_with_earnings(repo: &Arc<dyn AppRepository>, earned: i64) -> Uuid {
let partner = repo
.create_staff_user(
"partner1",
"hash",
"partner",
false,
Some(Decimal::new(10, 0)),
Some("p_abc123"),
)
.await
.unwrap();
let buyer = repo.upsert_user(999, None).await.unwrap();
let invoice_id = repo
.create_invoice(buyer.id, "GHOST_1M", "TEST", "USD", 500)
.await
.unwrap();
repo.record_partner_commission(
partner.id,
invoice_id,
buyer.id,
"USD",
earned,
Decimal::new(10, 0),
)
.await
.unwrap();
partner.id
}
#[sqlx::test]
async fn test_request_withdrawal_rejects_non_positive_amount(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = StaffService::new(repo.clone());
let partner_id = seed_partner_with_earnings(&repo, 1000).await;
let err = svc
.request_withdrawal(partner_id, "USD", 0, None)
.await
.unwrap_err();
assert!(matches!(err, AppError::BusinessLogic(_)));
let err2 = svc
.request_withdrawal(partner_id, "USD", -50, None)
.await
.unwrap_err();
assert!(matches!(err2, AppError::BusinessLogic(_)));
}
#[sqlx::test]
async fn test_request_withdrawal_rejects_over_balance(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = StaffService::new(repo.clone());
let partner_id = seed_partner_with_earnings(&repo, 1000).await;
let err = svc
.request_withdrawal(partner_id, "USD", 1001, None)
.await
.unwrap_err();
assert!(matches!(err, AppError::BusinessLogic(_)));
}
#[sqlx::test]
async fn test_request_withdrawal_succeeds_within_balance_and_updates_available(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = StaffService::new(repo.clone());
let partner_id = seed_partner_with_earnings(&repo, 1000).await;
svc.request_withdrawal(partner_id, "USD", 400, None)
.await
.expect("400 из доступных 1000 должно пройти");
let earnings = svc.get_earnings(partner_id).await.unwrap();
let usd = earnings.iter().find(|e| e.currency == "USD").unwrap();
assert_eq!(usd.total_earned, 1000);
assert_eq!(usd.pending_withdrawal, 400);
assert_eq!(usd.available, 600);
}
/// Регрессионный тест на гонку: `request_partner_withdrawal` обязан
/// блокировать строку партнёра (`FOR UPDATE`), иначе несколько параллельных
/// заявок на весь остаток проходят одновременно и суммарно превышают
/// доступный баланс партнёра (было воспроизведено до фикса — все 10 из 10
/// параллельных заявок проходили).
#[sqlx::test]
async fn test_concurrent_withdrawal_requests_do_not_overdraw_balance(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
let partner_id = seed_partner_with_earnings(&repo, 1000).await;
let num_requests = 10;
let mut handles = Vec::new();
for _ in 0..num_requests {
let repo_clone: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
let svc = StaffService::new(repo_clone);
handles.push(tokio::spawn(async move {
svc.request_withdrawal(partner_id, "USD", 1000, None).await
}));
}
let mut success_count = 0;
for h in handles {
if h.await.unwrap().is_ok() {
success_count += 1;
}
}
assert_eq!(
success_count, 1,
"без блокировки строки несколько параллельных заявок на весь остаток \
могли пройти одновременно и увести партнёра в минус — request_withdrawal \
нужен FOR UPDATE по аналогии с apply_promo_code"
);
}
}
+44 -3
View File
@@ -1,14 +1,20 @@
use axum::{ use axum::{
extract::{Extension, State}, extract::{Extension, Path, State},
middleware, middleware,
routing::{get, post}, routing::{get, patch, post},
Json, Router, Json, Router,
}; };
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use tracing::instrument; use tracing::instrument;
use uuid::Uuid;
use crate::{api::ApiState, db::models::User, error::AppError, modules::auth::guard::auth_guard}; use crate::{
api::ApiState,
db::models::User,
error::AppError,
modules::auth::{guard::auth_guard, owner_guard},
};
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct HackResultPayload { pub struct HackResultPayload {
@@ -26,6 +32,41 @@ pub fn router(state: ApiState) -> Router<ApiState> {
.route_layer(middleware::from_fn_with_state(state, auth_guard)) .route_layer(middleware::from_fn_with_state(state, auth_guard))
} }
#[derive(Deserialize)]
pub struct SetLimitPayload {
/// `None`/`null` — снять лимит (безлимит).
pub data_limit_bytes: Option<i64>,
}
/// Секретный роутер для Nexus (Admin) — динамический лимит трафика на юзера,
/// читается прокси-нодами на каждой проверке (`internal/validate`, `internal/usage`),
/// поэтому вступает в силу без перезапуска/передеплоя прокси.
pub fn admin_router(state: ApiState) -> Router<ApiState> {
Router::new()
.route("/{id}/limit", patch(set_user_limit))
.route_layer(middleware::from_fn_with_state(state.clone(), owner_guard))
.route_layer(middleware::from_fn_with_state(state, auth_guard))
}
#[instrument(skip(state, payload))]
async fn set_user_limit(
State(state): State<ApiState>,
Path(id): Path<Uuid>,
Json(payload): Json<SetLimitPayload>,
) -> Result<Json<Value>, AppError> {
let user = state
.repo
.set_data_limit(id, payload.data_limit_bytes)
.await?
.ok_or(AppError::UserNotFound)?;
Ok(Json(json!({
"status": "success",
"user_id": user.id,
"data_limit_bytes": user.data_limit_bytes,
})))
}
#[instrument(skip(state, user), fields(tg_id = user.tg_id))] #[instrument(skip(state, user), fields(tg_id = user.tg_id))]
async fn get_me( async fn get_me(
Extension(user): Extension<User>, Extension(user): Extension<User>,
+142
View File
@@ -9,6 +9,7 @@ use uuid::Uuid;
/// Ответ на `POST /users/hack/start`: доска, которую сервер только что /// Ответ на `POST /users/hack/start`: доска, которую сервер только что
/// сгенерировал и сохранил — клиент обязан играть строго на ней. /// сгенерировал и сохранил — клиент обязан играть строго на ней.
#[derive(Debug)]
pub struct HackSessionStart { pub struct HackSessionStart {
pub session_id: Uuid, pub session_id: Uuid,
pub matrix: Vec<Vec<String>>, pub matrix: Vec<Vec<String>>,
@@ -123,3 +124,144 @@ impl UserService {
Ok(reward) Ok(reward)
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::db::repository::PgRepository;
use sqlx::PgPool;
async fn grant_tickets(pool: &PgPool, user_id: Uuid, tickets: i32) {
sqlx::query("UPDATE users SET game_tickets = $1 WHERE id = $2")
.bind(tickets)
.bind(user_id)
.execute(pool)
.await
.unwrap();
}
#[sqlx::test]
async fn test_start_hack_session_no_tickets_errors(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = UserService::new(repo.clone());
let user = repo.upsert_user(1, None).await.unwrap();
let err = svc.start_hack_session(user.id).await.unwrap_err();
assert!(matches!(err, AppError::BusinessLogic(_)));
}
#[sqlx::test]
async fn test_start_hack_session_too_frequent_errors(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
let svc = UserService::new(repo.clone());
let user = repo.upsert_user(1, None).await.unwrap();
grant_tickets(&pool, user.id, 5).await;
svc.start_hack_session(user.id)
.await
.expect("первая игра должна пройти");
let err = svc.start_hack_session(user.id).await.unwrap_err();
assert!(
matches!(err, AppError::BusinessLogic(_)),
"повторный старт раньше 45с должен блокироваться rate-limit'ом"
);
}
#[sqlx::test]
async fn test_start_hack_session_decrements_ticket_and_creates_session(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
let svc = UserService::new(repo.clone());
let user = repo.upsert_user(1, None).await.unwrap();
grant_tickets(&pool, user.id, 3).await;
let session = svc.start_hack_session(user.id).await.unwrap();
assert_eq!(session.matrix.len(), cyberhack::GRID_SIZE);
assert_eq!(session.targets.len(), 3);
let updated = repo.get_user_by_id(user.id).await.unwrap().unwrap();
assert_eq!(updated.game_tickets, 2);
}
#[sqlx::test]
async fn test_submit_hack_session_double_submit_rejected(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
let svc = UserService::new(repo.clone());
let user = repo.upsert_user(1, None).await.unwrap();
grant_tickets(&pool, user.id, 3).await;
let session = svc.start_hack_session(user.id).await.unwrap();
svc.submit_hack_session(user.id, session.session_id, vec![])
.await
.expect("первая сдача (даже пустым путём) должна пройти");
let err = svc
.submit_hack_session(user.id, session.session_id, vec![])
.await
.unwrap_err();
assert!(
matches!(err, AppError::BusinessLogic(_)),
"повторная сдача уже использованной сессии должна отклоняться"
);
}
#[sqlx::test]
async fn test_submit_hack_session_wrong_user_rejected(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
let svc = UserService::new(repo.clone());
let owner = repo.upsert_user(1, None).await.unwrap();
let stranger = repo.upsert_user(2, None).await.unwrap();
grant_tickets(&pool, owner.id, 3).await;
let session = svc.start_hack_session(owner.id).await.unwrap();
let err = svc
.submit_hack_session(stranger.id, session.session_id, vec![])
.await
.unwrap_err();
assert!(matches!(err, AppError::BusinessLogic(_)));
}
#[sqlx::test]
async fn test_submit_hack_session_rejects_illegal_path_without_awarding_balance(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
let svc = UserService::new(repo.clone());
let user = repo.upsert_user(1, None).await.unwrap();
grant_tickets(&pool, user.id, 3).await;
let session = svc.start_hack_session(user.id).await.unwrap();
// Первый ход обязан быть в ряду 0 (active_index стартует в 0) — (1, 0) нелегален.
let illegal_path = vec![(1, 0)];
let err = svc
.submit_hack_session(user.id, session.session_id, illegal_path)
.await
.unwrap_err();
assert!(matches!(err, AppError::BusinessLogic(_)));
let unchanged = repo.get_user_by_id(user.id).await.unwrap().unwrap();
assert_eq!(
unchanged.balance, 0,
"нелегальный путь не должен начислять награду"
);
}
#[sqlx::test]
async fn test_submit_hack_session_end_to_end_credits_returned_reward(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
let svc = UserService::new(repo.clone());
let user = repo.upsert_user(1, None).await.unwrap();
grant_tickets(&pool, user.id, 3).await;
let session = svc.start_hack_session(user.id).await.unwrap();
// Первый ход всегда легален в ряду 0, независимо от содержимого доски.
let path = vec![(0, 0)];
let reward = svc
.submit_hack_session(user.id, session.session_id, path)
.await
.expect("легальный (пусть и не выигрышный) путь не должен отклоняться");
let updated = repo.get_user_by_id(user.id).await.unwrap().unwrap();
assert_eq!(
updated.balance, reward,
"начисленный баланс должен совпадать с возвращённой суммой награды"
);
}
}
+38
View File
@@ -128,3 +128,41 @@ async fn test_apply_promo_code_race_condition(pool: PgPool) {
let current_uses: i32 = promo_record.get("current_uses"); let current_uses: i32 = promo_record.get("current_uses");
assert_eq!(current_uses, 1); assert_eq!(current_uses, 1);
} }
// =====================================================================
// ТЕСТ 4: Реферальная награда — одноразовый бонус за первую покупку.
// `reward_referrer` фильтрует по `status = 'pending'`, поэтому повторная
// покупка того же приведённого юзера (см. BillingService::confirm_payment)
// не должна начислять рефереру ещё раз.
// =====================================================================
#[sqlx::test]
async fn test_reward_referrer_is_one_time_bonus_not_repeated(pool: PgPool) {
let repo = PgRepository::new(pool);
let referrer = repo.upsert_user(1, None).await.unwrap();
let referred = repo.upsert_user(2, None).await.unwrap();
repo.create_referral(referrer.id, referred.id)
.await
.unwrap();
let first = repo.reward_referrer(referred.id, 100).await.unwrap();
assert!(first, "первое начисление должно найти строку referrals");
let after_first = repo.get_user_by_id(referrer.id).await.unwrap().unwrap();
assert_eq!(after_first.balance, 100);
// Вторая "покупка" того же приведённого юзера — начисление НЕ должно
// сработать повторно, статус уже 'rewarded'.
let second = repo.reward_referrer(referred.id, 50).await.unwrap();
assert!(
!second,
"повторное начисление должно быть заблокировано статусом 'rewarded'"
);
let after_second = repo.get_user_by_id(referrer.id).await.unwrap().unwrap();
assert_eq!(
after_second.balance, 100,
"баланс не должен измениться повторно"
);
let (_, total_earned) = repo.get_referral_stats(referrer.id).await.unwrap();
assert_eq!(total_earned, 100);
}