Files
netrunner-backend/src/api.rs
T
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

246 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Сборка axum-роутера: монтирует контроллеры всех модулей под `/api/v1` и
//! `/api/v1/admin`, настраивает rate-limiting (tower_governor), CORS, Prometheus-метрики
//! и health-пробы. Раздача собранного фронтенда (SPA fallback на index.html) висит на
//! этом же роутере как `fallback_service`, поэтому бэкенд и фронт всегда деплоятся вместе.
use axum::{
extract::{MatchedPath, Request, State},
http::{
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
Method, StatusCode,
},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::get,
Json, Router,
};
use metrics_exporter_prometheus::{Matcher, PrometheusBuilder};
use serde_json::json;
use std::{sync::Arc, time::Instant};
use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer};
use tower_http::{
cors::CorsLayer,
services::{ServeDir, ServeFile},
trace::TraceLayer,
};
use crate::db::repository::AppRepository;
use crate::modules::{
auth::{controller as auth_ctrl, service::AuthService},
billing::{controller as bill_ctrl, service::BillingService},
nodes::{controller as node_ctrl, service::NodeService},
proxy_internal,
referrals::{controller as ref_ctrl, service::ReferralService},
staff::{controller as staff_ctrl, service::StaffService},
users::{controller as user_ctrl, service::UserService},
};
pub struct ApiState {
pub repo: Arc<dyn AppRepository>,
pub auth_service: AuthService,
pub billing_service: BillingService,
pub node_service: NodeService,
pub referral_service: ReferralService,
pub user_service: UserService,
pub staff_service: StaffService,
pub jwt_secret: String,
pub bot_token: String,
/// Юзернейм бота (без `@`) — отдаётся фронту через `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. Пусто (по умолчанию, локальная разработка) →
/// cookie host-only. В проде — `.netrunner-vpn.com`, чтобы одна сессия работала
/// и на лендинге, и на ЛК (общий родительский домен, разные сабдомены).
pub cookie_domain: String,
/// `Secure`-атрибут сессионных cookie. По умолчанию `true` — обязателен для
/// прода (HTTPS). На dev-стендах без TLS (голый HTTP на IP/домен, не
/// `localhost`) браузер и любой нормальный HTTP-клиент **молча отбрасывает**
/// `Secure`-cookie, полученную не по HTTPS, — сессия не переживает вообще ни
/// одного запроса. Для таких стендов явно выставляется `false` через
/// `COOKIE_SECURE=false`.
pub cookie_secure: bool,
/// Origin'ы, с которых разрешён cookie-based доступ к мутирующим ручкам
/// (CSRF-проверка в `auth::guard`). Отдельно от `CORS_ALLOWED_ORIGINS`, т.к. туда
/// же попадают Bearer-клиенты (Tauri), для которых CSRF неактуален.
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 {
fn clone(&self) -> Self {
Self {
repo: self.repo.clone(),
auth_service: self.auth_service.clone(),
billing_service: self.billing_service.clone(),
node_service: self.node_service.clone(),
referral_service: self.referral_service.clone(),
user_service: self.user_service.clone(),
staff_service: self.staff_service.clone(),
jwt_secret: self.jwt_secret.clone(),
bot_token: self.bot_token.clone(),
bot_username: self.bot_username.clone(),
cookie_domain: self.cookie_domain.clone(),
cookie_secure: self.cookie_secure,
csrf_allowed_origins: self.csrf_allowed_origins.clone(),
proxy_internal_secret: self.proxy_internal_secret.clone(),
}
}
}
pub fn create_router(state: ApiState, frontend_dir: &str) -> Router {
tracing::info!("Initializing Nexus Grid Modular Architecture...");
let static_path = std::path::PathBuf::from(frontend_dir);
let index_path = static_path.join("index.html");
let gov_conf = Arc::new(
GovernorConfigBuilder::default()
.per_millisecond(100)
.burst_size(10)
.finish()
.unwrap(),
);
let seed_limit_conf = Arc::new(
GovernorConfigBuilder::default()
.per_millisecond(5000)
.burst_size(3)
.finish()
.unwrap(),
);
let recorder_handle = PrometheusBuilder::new()
.set_buckets_for_metric(
Matcher::Full("http_request_duration_seconds".to_string()),
&[0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0],
)
.unwrap()
.install_recorder()
.expect("Failed to install Prometheus");
let api_routes_v1 = Router::new()
.nest(
"/auth",
auth_ctrl::router().layer(GovernorLayer::new(seed_limit_conf)),
)
.nest("/users", user_ctrl::router(state.clone()))
.nest("/billing", bill_ctrl::router(state.clone()))
.nest("/nodes", node_ctrl::public_router(state.clone()))
.nest("/referrals", ref_ctrl::router(state.clone()))
.route("/config", get(get_public_config))
.layer(GovernorLayer::new(gov_conf));
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 (через запятую),
// чтобы помимо веб-фронта пускать и десктоп/мобайл-приложение (Tauri webview).
let allowed_origins: Vec<axum::http::HeaderValue> = std::env::var("CORS_ALLOWED_ORIGINS")
.unwrap_or_else(|_| {
"https://netrunner-vpn.com,https://account.netrunner-vpn.com,\
tauri://localhost,http://tauri.localhost,\
http://localhost:1420,http://192.168.110.169:1420"
.to_string()
})
.split(',')
.filter_map(|s| s.trim().parse::<axum::http::HeaderValue>().ok())
.collect();
let cors = CorsLayer::new()
.allow_origin(allowed_origins)
.allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])
.allow_credentials(true)
.allow_headers([AUTHORIZATION, ACCEPT, CONTENT_TYPE]);
Router::new()
.nest("/api/v1", api_routes_v1)
.nest("/api/v1/admin", admin_routes_v1)
.nest("/api/v1", internal_routes)
// Liveness/readiness пробы для Docker/оркестратора и балансировщика.
.route("/health", get(health_live))
.route("/health/ready", get(health_ready))
.route(
"/metrics",
axum::routing::get(move || std::future::ready(recorder_handle.render())),
)
.route_layer(middleware::from_fn(track_metrics))
.fallback_service(
ServeDir::new(&static_path)
.append_index_html_on_directories(true)
.fallback(ServeFile::new(&index_path)),
)
.layer(cors)
.layer(TraceLayer::new_for_http())
.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: процесс жив и отвечает. Не трогает зависимости.
async fn health_live() -> impl IntoResponse {
(StatusCode::OK, Json(json!({ "status": "ok" })))
}
/// Readiness: готов обслуживать трафик (есть живое соединение с БД).
async fn health_ready(State(state): State<ApiState>) -> impl IntoResponse {
match state.repo.ping().await {
Ok(_) => (StatusCode::OK, Json(json!({ "status": "ready" }))),
Err(e) => {
tracing::warn!("Readiness check failed: {:?}", e);
(
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "status": "unavailable" })),
)
}
}
}
async fn track_metrics(req: Request, next: Next) -> Response {
let start = Instant::now();
let path = req
.extensions()
.get::<MatchedPath>()
.map(|m| m.as_str().to_owned())
.unwrap_or_else(|| req.uri().path().to_owned());
let method = req.method().to_string();
let response = next.run(req).await;
let latency = start.elapsed().as_secs_f64();
let status = response.status().as_u16().to_string();
let labels = [("method", method), ("path", path), ("status", status)];
metrics::counter!("http_requests_total", &labels).increment(1);
metrics::histogram!("http_request_duration_seconds", &labels).record(latency);
response
}