Добавить проверку токена прокси, динамические лимиты трафика и 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>
This commit is contained in:
+126
-8
@@ -39,6 +39,30 @@ pub trait AppRepository: Send + Sync {
|
||||
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>;
|
||||
|
||||
// --- ЛИМИТЫ ТРАФИКА (используется прокси через 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-авторизация) ---
|
||||
async fn create_refresh_session(
|
||||
&self,
|
||||
@@ -175,7 +199,8 @@ impl AppRepository for PgRepository {
|
||||
"INSERT INTO users (id, tg_id, tg_username) \
|
||||
VALUES ($1, $2, $3) \
|
||||
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"
|
||||
)
|
||||
.bind(Uuid::new_v4()).bind(tg_id).bind(username).fetch_one(&self.pool).await
|
||||
}
|
||||
@@ -183,7 +208,8 @@ impl AppRepository for PgRepository {
|
||||
#[instrument(skip(self))]
|
||||
async fn get_user_by_id(&self, id: Uuid) -> 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 \
|
||||
"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 \
|
||||
FROM users WHERE id = $1"
|
||||
)
|
||||
.bind(id)
|
||||
@@ -194,7 +220,8 @@ impl AppRepository for PgRepository {
|
||||
#[instrument(skip(self))]
|
||||
async fn get_user_by_tg_id(&self, tg_id: i64) -> 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 \
|
||||
"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 \
|
||||
FROM users WHERE tg_id = $1"
|
||||
)
|
||||
.bind(tg_id)
|
||||
@@ -206,7 +233,8 @@ impl AppRepository for PgRepository {
|
||||
async fn create_seed_user(&self, seed_hash: &str) -> Result<User, sqlx::Error> {
|
||||
sqlx::query_as::<_, User>(
|
||||
"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"
|
||||
)
|
||||
.bind(Uuid::new_v4()).bind(seed_hash).fetch_one(&self.pool).await
|
||||
}
|
||||
@@ -214,7 +242,8 @@ impl AppRepository for PgRepository {
|
||||
#[instrument(skip(self))]
|
||||
async fn get_user_by_seed_hash(&self, hash: &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 \
|
||||
"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 \
|
||||
FROM users WHERE seed_hash = $1"
|
||||
)
|
||||
.bind(hash)
|
||||
@@ -222,6 +251,79 @@ impl AppRepository for PgRepository {
|
||||
.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",
|
||||
)
|
||||
.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))]
|
||||
async fn create_refresh_session(
|
||||
&self,
|
||||
@@ -295,10 +397,13 @@ impl AppRepository for PgRepository {
|
||||
game_tickets: i32,
|
||||
subscription: Option<Json<SubscriptionData>>,
|
||||
referrals: Json<Vec<ReferralData>>,
|
||||
data_limit_bytes: Option<i64>,
|
||||
data_used_bytes: i64,
|
||||
}
|
||||
|
||||
let query = r#"
|
||||
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)
|
||||
FROM public.subscriptions s WHERE s.user_id = u.id AND s.status != 'expired' LIMIT 1
|
||||
) AS "subscription",
|
||||
@@ -322,6 +427,8 @@ impl AppRepository for PgRepository {
|
||||
game_tickets: r.game_tickets,
|
||||
subscription: r.subscription.map(|json| json.0),
|
||||
referrals: r.referrals.0,
|
||||
data_limit_bytes: r.data_limit_bytes,
|
||||
data_used_bytes: r.data_used_bytes,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -561,12 +668,23 @@ impl AppRepository for PgRepository {
|
||||
.await?;
|
||||
|
||||
// Обновляем или создаем подписку
|
||||
sqlx::query("INSERT INTO subscriptions (user_id, plan_name, expires_at, status)
|
||||
VALUES ($1, $2, NOW() + INTERVAL '30 days', 'active')
|
||||
ON CONFLICT (user_id)
|
||||
sqlx::query("INSERT INTO subscriptions (user_id, plan_name, expires_at, status)
|
||||
VALUES ($1, $2, NOW() + INTERVAL '30 days', 'active')
|
||||
ON CONFLICT (user_id)
|
||||
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?;
|
||||
|
||||
// Проставляем дефолтный лимит трафика тарифа (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?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user