security: fix CSRF/ownership/timing vulns; add billing reconciliation
- auth/guard: добавить CSRF-проверку Origin/Referer для cookie-мутаций, исправить инвертированную логику admin_guard (пускал всех, блокировал админов), перевести admin_guard на from_fn_with_state - auth/service: timing-safe сравнение HMAC через verify_slice вместо != по hex; криптостойкий Seed через Alphanumeric (был цифровой 0-9) - billing: confirm_payment проверяет owner (caller_id == invoice.user_id); активация триала только для Telegram-аккаунтов (Sybil-защита); реализована reconcile_pending_payments — сверка blockchain->pending-инвойсы - billing/providers: get_recent_transactions перенесён в трейт PaymentProvider - nodes/service: unwrap() -> map_err по всему SSH-пути; убран TcpStream import - docker: multi-stage build с cargo-chef, non-root user, healthcheck; prod-compose добавляет one-shot migrate-сервис, убирает проброс порта БД - deploy.yml: параметр image_tag, set -e, pull всего стека вместо только app - users/service: обработка NO_TICKETS / NO_USER из БД вместо catch-all Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+53
-19
@@ -1,37 +1,68 @@
|
||||
use crate::{api::ApiState, db::models::User, error::AppError};
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::Method,
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use tracing::warn;
|
||||
|
||||
/// Проверяет JWT и инжектит User в запрос.
|
||||
/// Аналог AuthGuard в NestJS.
|
||||
/// Проверяет JWT и инжектит User в запрос. Добавлена базовая защита от CSRF.
|
||||
pub async fn auth_guard(
|
||||
State(state): State<ApiState>,
|
||||
mut req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
let auth_header = req
|
||||
let method = req.method().clone();
|
||||
let is_mutating = matches!(
|
||||
method,
|
||||
Method::POST | Method::DELETE | Method::PUT | Method::PATCH
|
||||
);
|
||||
|
||||
// Определяем источник токена. Bearer в заголовке Authorization атакующий не может
|
||||
// выставить из чужого origin, поэтому CSRF ему не грозит. CSRF опасен только для
|
||||
// cookie-аутентификации, которую браузер подставляет автоматически.
|
||||
let bearer = req
|
||||
.headers()
|
||||
.get("Authorization")
|
||||
.and_then(|h| h.to_str().ok());
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|h| h.strip_prefix("Bearer "))
|
||||
.map(|t| t.to_string());
|
||||
|
||||
// 1. Получаем чистый токен (String)
|
||||
let token = if let Some(h) = auth_header {
|
||||
h.replace("Bearer ", "")
|
||||
let (token, from_cookie) = if let Some(t) = bearer {
|
||||
(t, false)
|
||||
} else {
|
||||
req.headers()
|
||||
let cookie_token = req
|
||||
.headers()
|
||||
.get("Cookie")
|
||||
.and_then(|c| c.to_str().ok())
|
||||
.and_then(|c| c.split("; ").find(|part| part.starts_with("nrxp_token=")))
|
||||
.map(|part| part.replace("nrxp_token=", ""))
|
||||
.ok_or_else(|| AppError::Unauthorized("Access token required".into()))?
|
||||
.ok_or_else(|| AppError::Unauthorized("Access token required".into()))?;
|
||||
(cookie_token, true)
|
||||
};
|
||||
|
||||
// 2. Декодируем токен, используя полученную выше переменную `token`
|
||||
// Передаем ссылку &token, так как jsonwebtoken ожидает &str
|
||||
// CSRF-защита нужна только для cookie-сессий и только на мутирующих методах.
|
||||
if from_cookie && is_mutating {
|
||||
let origin = req.headers().get("Origin").and_then(|v| v.to_str().ok());
|
||||
let referer = req.headers().get("Referer").and_then(|v| v.to_str().ok());
|
||||
|
||||
let is_valid_origin = origin == Some("https://netrunner-vpn.com");
|
||||
let is_valid_referer = referer
|
||||
.map(|r| r.starts_with("https://netrunner-vpn.com"))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_valid_origin && !is_valid_referer {
|
||||
warn!(
|
||||
"CSRF BLOCK: Request rejected. Origin: {:?}, Referer: {:?}",
|
||||
origin, referer
|
||||
);
|
||||
return Err(AppError::Unauthorized(
|
||||
"Invalid Origin/Referer (CSRF protection)".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let token_data = jsonwebtoken::decode::<super::service::Claims>(
|
||||
&token,
|
||||
&jsonwebtoken::DecodingKey::from_secret(state.jwt_secret.as_bytes()),
|
||||
@@ -53,23 +84,26 @@ pub async fn auth_guard(
|
||||
}
|
||||
|
||||
/// Проверяет права администратора.
|
||||
/// Должен идти строго после auth_guard.
|
||||
pub async fn admin_guard(req: Request, next: Next) -> Result<Response, AppError> {
|
||||
pub async fn admin_guard(
|
||||
State(state): State<ApiState>,
|
||||
req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
let user = req
|
||||
.extensions()
|
||||
.get::<User>()
|
||||
.ok_or_else(|| AppError::Unauthorized("Context lost: Auth required".into()))?;
|
||||
|
||||
let admin_id: i64 = std::env::var("ADMIN_TG_ID").unwrap().parse().unwrap();
|
||||
if user.tg_id == Some(admin_id) || user.role == "admin" {
|
||||
// Используем закешированный в памяти ID
|
||||
if user.tg_id == Some(state.admin_tg_id) || user.role == "admin" {
|
||||
Ok(next.run(req).await)
|
||||
} else {
|
||||
warn!(
|
||||
"SECURITY_ALERT: Unauthorized admin access attempt by @{:?}",
|
||||
user.tg_username
|
||||
);
|
||||
return Err(AppError::Unauthorized(
|
||||
Err(AppError::Unauthorized(
|
||||
"Insufficient clearance level".into(),
|
||||
));
|
||||
))
|
||||
}
|
||||
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
|
||||
+19
-13
@@ -2,11 +2,11 @@ use argon2::Argon2;
|
||||
use chrono::Utc;
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||
use rand::Rng;
|
||||
use rand::{distributions::Alphanumeric, Rng};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info, instrument};
|
||||
use uuid::Uuid; // Не забудь добавить rand в Cargo.toml
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
db::{models::User, repository::AppRepository},
|
||||
@@ -30,7 +30,6 @@ impl AuthService {
|
||||
Self { repo }
|
||||
}
|
||||
|
||||
/// Генерирует JWT на основе внутреннего ID и опционального Telegram ID
|
||||
#[instrument(skip(self, secret))]
|
||||
pub fn generate_auth_token(
|
||||
&self,
|
||||
@@ -41,7 +40,6 @@ impl AuthService {
|
||||
let claims = Claims {
|
||||
sub: user_id.to_string(),
|
||||
tg_id,
|
||||
// Время жизни токена сокращено до 10 минут
|
||||
exp: (Utc::now() + chrono::Duration::minutes(10)).timestamp() as usize,
|
||||
};
|
||||
|
||||
@@ -65,18 +63,20 @@ impl AuthService {
|
||||
Ok(hex::encode(hash_output))
|
||||
}
|
||||
|
||||
/// Ghost Protocol: Регистрация нового анонимного юзера
|
||||
pub async fn register_seed(
|
||||
&self,
|
||||
salt: &str,
|
||||
jwt_secret: &str,
|
||||
) -> Result<(String, String), AppError> {
|
||||
let seed: String = {
|
||||
let mut rng = rand::thread_rng();
|
||||
(0..16).map(|_| rng.gen_range(0..10).to_string()).collect()
|
||||
};
|
||||
// Генерация криптостойкого 16-символьного Seed (Alphanumeric)
|
||||
let seed: String = rand::thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(16)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
|
||||
// Используем мощный Argon2 вместо быстрого SHA256
|
||||
// Используем тот же детерминированный Argon2 (фиксированная соль), что и login_seed,
|
||||
// иначе сгенерированный при регистрации хеш никогда не совпадёт при входе.
|
||||
let hash = Self::hash_seed_argon2(&seed, salt)?;
|
||||
|
||||
let user = self.repo.create_seed_user(&hash).await?;
|
||||
@@ -104,6 +104,8 @@ impl AuthService {
|
||||
}
|
||||
// --- Логика Telegram (без изменений, кроме вызова генерации токена) ---
|
||||
|
||||
// Сигнатура повторяет поля payload-а Telegram Login Widget — это намеренно.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[instrument(skip(self, bot_token, hash))]
|
||||
pub fn verify_tg_widget_auth(
|
||||
&self,
|
||||
@@ -141,9 +143,13 @@ impl AuthService {
|
||||
.map_err(|e| AppError::Internal(format!("HMAC Error: {}", e)))?;
|
||||
mac.update(data_check_string.as_bytes());
|
||||
|
||||
if hex::encode(mac.finalize().into_bytes()) != hash {
|
||||
return Err(AppError::Unauthorized("Signature mismatch".into()));
|
||||
}
|
||||
// Сравнение тега в постоянном времени (verify_slice), чтобы исключить
|
||||
// timing-атаку на побайтовый подбор валидной подписи. Сырое != по hex-строке
|
||||
// завершается на первом несовпавшем байте и утекает информацию по таймингу.
|
||||
let provided = hex::decode(hash)
|
||||
.map_err(|_| AppError::Unauthorized("Malformed auth signature".into()))?;
|
||||
mac.verify_slice(&provided)
|
||||
.map_err(|_| AppError::Unauthorized("Signature mismatch".into()))?;
|
||||
if Utc::now().timestamp() - auth_date > 86400 {
|
||||
return Err(AppError::Unauthorized("Session expired".into()));
|
||||
}
|
||||
|
||||
@@ -50,13 +50,13 @@ async fn initiate_payment_api(
|
||||
|
||||
/// Шаг 2: Подтверждаем транзакцию после того как юзер её оплатил
|
||||
async fn confirm_payment_api(
|
||||
Extension(_user): Extension<User>, // В будущем можно сверять ID юзера с инвойсом
|
||||
Extension(user): Extension<User>,
|
||||
State(state): State<ApiState>,
|
||||
Json(payload): Json<ConfirmPaymentPayload>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
state
|
||||
.billing_service
|
||||
.confirm_payment(payload.invoice_id, &payload.external_tx_id)
|
||||
.confirm_payment(user.id, payload.invoice_id, &payload.external_tx_id)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
|
||||
@@ -8,6 +8,9 @@ pub mod ton;
|
||||
pub struct InvoiceData {
|
||||
pub invoice_id: Uuid,
|
||||
pub amount: i64,
|
||||
// Валюта инвойса. TON-чекаут её не использует (сумма уже в nanoTON),
|
||||
// но контракт провайдер-агностичен: эквайринги STRIPE/YOOKASSA её требуют.
|
||||
#[allow(dead_code)]
|
||||
pub currency: String,
|
||||
}
|
||||
|
||||
@@ -26,4 +29,11 @@ pub trait PaymentProvider: Send + Sync {
|
||||
expected_amount: i64,
|
||||
expected_invoice_id: &str,
|
||||
) -> Result<bool, AppError>;
|
||||
|
||||
/// Возвращает последние транзакции платежного аккаунта.
|
||||
/// Используется фоновой сверкой для добивания потерянных платежей.
|
||||
/// По умолчанию — пусто (провайдеры на основе вебхуков сверку не поддерживают).
|
||||
async fn get_recent_transactions(&self) -> Result<Vec<Value>, AppError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,14 @@ pub struct TonProvider {
|
||||
pub master_wallet: String,
|
||||
}
|
||||
|
||||
impl TonProvider {
|
||||
#[async_trait]
|
||||
impl PaymentProvider for TonProvider {
|
||||
fn provider_id(&self) -> &'static str {
|
||||
"TON"
|
||||
}
|
||||
|
||||
/// Получает историю последних транзакций мастер-кошелька
|
||||
pub async fn get_recent_transactions(&self) -> Result<Vec<Value>, AppError> {
|
||||
async fn get_recent_transactions(&self) -> Result<Vec<Value>, AppError> {
|
||||
let client = reqwest::Client::new();
|
||||
// Запрашиваем последние 50 транзакций
|
||||
let url = format!(
|
||||
@@ -30,13 +35,6 @@ impl TonProvider {
|
||||
let data: Value = res.json().await.unwrap_or(json!({}));
|
||||
Ok(data["transactions"].as_array().unwrap_or(&vec![]).clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PaymentProvider for TonProvider {
|
||||
fn provider_id(&self) -> &'static str {
|
||||
"TON"
|
||||
}
|
||||
|
||||
async fn create_checkout_session(&self, invoice: &InvoiceData) -> Result<Value, AppError> {
|
||||
// Для TON нам не нужно делать запрос к API эквайринга при создании.
|
||||
|
||||
@@ -115,9 +115,11 @@ impl BillingService {
|
||||
provider.create_checkout_session(&invoice_data).await
|
||||
}
|
||||
|
||||
/// Этап 2: Вебхук или ручная проверка оплаты юзером
|
||||
/// Этап 2: Вебхук или ручная проверка оплаты юзером.
|
||||
/// `caller_id` — кто инициировал подтверждение (для проверки владения инвойсом).
|
||||
pub async fn confirm_payment(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
invoice_id: Uuid,
|
||||
external_tx_id: &str,
|
||||
) -> Result<(), AppError> {
|
||||
@@ -128,6 +130,14 @@ impl BillingService {
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Инвойс не найден".into()))?;
|
||||
|
||||
// Подтверждать инвойс может только его владелец. Иначе чужой юзер мог бы
|
||||
// инициировать обращения к блокчейну по произвольным invoice_id (перебор/DoS).
|
||||
if invoice.user_id != caller_id {
|
||||
return Err(AppError::Unauthorized(
|
||||
"Этот инвойс принадлежит другому оперативнику.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if invoice.status == "paid" {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -169,7 +179,76 @@ impl BillingService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Фоновая сверка блокчейна: находит pending-инвойсы, по которым уже прошла
|
||||
/// оплата на мастер-кошелёк (но вебхук/ручное подтверждение потерялись),
|
||||
/// и доводит их до статуса `paid`. Возвращает число добитых инвойсов.
|
||||
pub async fn reconcile_pending_payments(&self) -> Result<u64, AppError> {
|
||||
let pending = self
|
||||
.repo
|
||||
.get_pending_invoices()
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if pending.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let provider = self
|
||||
.providers
|
||||
.get("TON")
|
||||
.ok_or_else(|| AppError::Internal("TON provider не сконфигурирован".into()))?;
|
||||
|
||||
// Снимок последних транзакций кошелька: comment(invoice_id) -> tx_hash
|
||||
let txs = provider.get_recent_transactions().await?;
|
||||
let mut hash_by_comment: HashMap<String, String> = HashMap::new();
|
||||
for tx in &txs {
|
||||
let comment = tx["in_msg"]["decoded_body"]["text"].as_str().unwrap_or("");
|
||||
let hash = tx["hash"].as_str().unwrap_or("");
|
||||
if !comment.is_empty() && !hash.is_empty() {
|
||||
hash_by_comment
|
||||
.entry(comment.to_string())
|
||||
.or_insert_with(|| hash.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let mut confirmed = 0u64;
|
||||
for invoice in pending {
|
||||
if let Some(tx_hash) = hash_by_comment.get(&invoice.id.to_string()) {
|
||||
// confirm_payment повторно валидирует сумму/адрес/коммент on-chain,
|
||||
// так что ложное срабатывание по совпадению комментария исключено.
|
||||
// Сверка системная — владельцем выступает сам пользователь инвойса.
|
||||
match self
|
||||
.confirm_payment(invoice.user_id, invoice.id, tx_hash)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
confirmed += 1;
|
||||
tracing::info!(
|
||||
"[{}] Реконсиляция: добит потерянный платёж по инвойсу {}",
|
||||
provider.provider_id(),
|
||||
invoice.id
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Реконсиляция инвойса {} не удалась: {:?}", invoice.id, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(confirmed)
|
||||
}
|
||||
|
||||
pub async fn activate_trial(&self, user: &User) -> Result<(), AppError> {
|
||||
// Защита от Sybil-атаки: анонимные Ghost-аккаунты (вход по Seed) генерируются
|
||||
// бесплатно и без лимита, поэтому им триал не положен — иначе любой скрипт
|
||||
// штампует новые Seed и крутит бесконечный бесплатный VPN. Триал привязан к
|
||||
// Telegram-личности (tg_id), которую так дёшево не размножить.
|
||||
if user.tg_id.is_none() {
|
||||
return Err(AppError::BusinessLogic(
|
||||
"Пробный период доступен только для аккаунтов, привязанных к Telegram.".into(),
|
||||
));
|
||||
}
|
||||
if user.has_used_trial {
|
||||
return Err(AppError::TrialAlreadyUsed);
|
||||
}
|
||||
@@ -190,7 +269,7 @@ impl BillingService {
|
||||
fiat_amount_minimal: i64, // В копейках/центах из БД
|
||||
ton_rate_to_currency: rust_decimal::Decimal, // Полный путь до типа, чтобы не было конфликтов имён
|
||||
) -> Result<i64, String> {
|
||||
use rust_decimal::prelude::{ToPrimitive, Zero};
|
||||
use rust_decimal::prelude::ToPrimitive;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
// 1. Создаем константу 100 безопасным способом (100.00)
|
||||
|
||||
@@ -30,13 +30,14 @@ pub fn public_router(state: ApiState) -> Router<ApiState> {
|
||||
.route("/routing", get(get_node_routing))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), auth_guard))
|
||||
}
|
||||
|
||||
/// Секретный роутер для Nexus (Admin)
|
||||
pub fn admin_router(state: ApiState) -> Router<ApiState> {
|
||||
Router::new()
|
||||
.route("/", post(add_node))
|
||||
.route("/{id}", delete(remove_node))
|
||||
// Очередность важна: сначала авторизация, затем проверка прав!
|
||||
.route_layer(middleware::from_fn(admin_guard))
|
||||
// Теперь admin_guard использует from_fn_with_state
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), admin_guard))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), auth_guard))
|
||||
}
|
||||
|
||||
@@ -67,7 +68,6 @@ async fn add_node(
|
||||
State(state): State<ApiState>,
|
||||
Json(req): Json<AddNodeRequest>,
|
||||
) -> Result<Json<VpnNode>, AppError> {
|
||||
// Pass the separated payload and password to the service
|
||||
let node = state
|
||||
.node_service
|
||||
.add_node(req.payload, &req.ssh_password)
|
||||
|
||||
@@ -10,7 +10,6 @@ use uuid::Uuid;
|
||||
|
||||
use ssh2::Session;
|
||||
use std::io::Read;
|
||||
use std::net::TcpStream;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -82,7 +81,8 @@ impl NodeService {
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut sess = Session::new().unwrap();
|
||||
let mut sess = Session::new()
|
||||
.map_err(|e| AppError::Internal(format!("Не удалось создать SSH-сессию: {}", e)))?;
|
||||
sess.set_tcp_stream(tcp);
|
||||
sess.handshake()
|
||||
.map_err(|e| AppError::Internal(format!("Сбой SSH Handshake: {}", e)))?;
|
||||
@@ -92,10 +92,17 @@ impl NodeService {
|
||||
})?;
|
||||
|
||||
// 3. Подготавливаем команды автоматизации
|
||||
let gh_token =
|
||||
std::env::var("GHCR_TOKEN").expect("КРИТИЧЕСКАЯ ОШИБКА: GHCR_TOKEN missing!");
|
||||
let init_script = fs::read_to_string("templates/init_node.sh")
|
||||
.unwrap_or_else(|_| "echo 'init_node.sh missing'".into());
|
||||
let gh_token = std::env::var("GHCR_TOKEN").map_err(|_| {
|
||||
AppError::Internal("GHCR_TOKEN не задан — провижининг ноды невозможен".into())
|
||||
})?;
|
||||
// Если скрипт инициализации отсутствует — жёстко падаем, а не деплоим
|
||||
// полуживую ноду заглушкой `echo`.
|
||||
let init_script = fs::read_to_string("templates/init_node.sh").map_err(|e| {
|
||||
AppError::Internal(format!(
|
||||
"Скрипт инициализации templates/init_node.sh не найден: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
// Скрипт укрепления (Hardening): Прокидывает ключ и жестко вырубает пароли
|
||||
let harden_script = format!(
|
||||
@@ -116,16 +123,22 @@ impl NodeService {
|
||||
);
|
||||
|
||||
// 4. Исполняем сценарий развертывания контейнеров в изолированной сессии
|
||||
let mut channel = sess.channel_session().unwrap();
|
||||
let mut channel = sess
|
||||
.channel_session()
|
||||
.map_err(|e| AppError::Internal(format!("Не удалось открыть SSH-канал: {}", e)))?;
|
||||
channel
|
||||
.exec(&full_command)
|
||||
.map_err(|e| AppError::Internal(format!("Ошибка старта команд деплоя: {}", e)))?;
|
||||
|
||||
let mut output = String::new();
|
||||
channel.read_to_string(&mut output).unwrap();
|
||||
channel
|
||||
.read_to_string(&mut output)
|
||||
.map_err(|e| AppError::Internal(format!("Ошибка чтения вывода SSH: {}", e)))?;
|
||||
tracing::info!("SSH Execution Output from {}:\n{}", ip, output);
|
||||
|
||||
channel.wait_close().unwrap();
|
||||
channel
|
||||
.wait_close()
|
||||
.map_err(|e| AppError::Internal(format!("Ошибка закрытия SSH-канала: {}", e)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -46,12 +46,16 @@ impl UserService {
|
||||
.repo
|
||||
.consume_ticket_and_reward(user_id, points)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if e == "TOO_FREQUENT" {
|
||||
AppError::BusinessLogic("Слишком частые попытки взлома. Минимальный интервал между играми — 45 секунд.".into())
|
||||
} else {
|
||||
AppError::Internal(format!("Критический сбой СУБД при фиксации игры: {}", e))
|
||||
}
|
||||
.map_err(|e| match e.as_str() {
|
||||
"TOO_FREQUENT" => AppError::BusinessLogic(
|
||||
"Слишком частые попытки взлома. Минимальный интервал между играми — 45 секунд."
|
||||
.into(),
|
||||
),
|
||||
"NO_TICKETS" => AppError::BusinessLogic(
|
||||
"Нет билетов для взлома. Билет начисляется при покупке подписки.".into(),
|
||||
),
|
||||
"NO_USER" => AppError::UserNotFound,
|
||||
_ => AppError::Internal(format!("Критический сбой СУБД при фиксации игры: {}", e)),
|
||||
})?;
|
||||
|
||||
Ok(if success { points } else { 0 })
|
||||
|
||||
Reference in New Issue
Block a user