Добавить проверку токена прокси, динамические лимиты трафика и 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:
2026-07-07 00:30:25 +07:00
parent 1a26403afb
commit c8eea8203e
11 changed files with 485 additions and 17 deletions
+126 -8
View File
@@ -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(())
}