Merge pull request #14 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-01-18 06:30:18 +03:00
committed by GitHub
9 changed files with 153 additions and 45 deletions

View File

@@ -6,6 +6,32 @@ const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'
// Настраиваем endpoint для refresh
tokenRefreshManager.setRefreshEndpoint(`${API_BASE_URL}/cabinet/auth/refresh`)
// CSRF token management
const CSRF_COOKIE_NAME = 'csrf_token'
const CSRF_HEADER_NAME = 'X-CSRF-Token'
function getCsrfToken(): string | null {
if (typeof document === 'undefined') return null
const match = document.cookie.match(new RegExp(`(^| )${CSRF_COOKIE_NAME}=([^;]+)`))
return match ? match[2] : null
}
function generateCsrfToken(): string {
const array = new Uint8Array(32)
crypto.getRandomValues(array)
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('')
}
function ensureCsrfToken(): string {
let token = getCsrfToken()
if (!token) {
token = generateCsrfToken()
// Set cookie with SameSite=Strict for CSRF protection
document.cookie = `${CSRF_COOKIE_NAME}=${token}; path=/; SameSite=Strict; Secure`
}
return token
}
const getTelegramInitData = (): string | null => {
if (typeof window === 'undefined') return null
@@ -51,6 +77,13 @@ apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) =>
if (telegramInitData && config.headers) {
config.headers['X-Telegram-Init-Data'] = telegramInitData
}
// Add CSRF token for state-changing methods
const method = config.method?.toUpperCase()
if (method && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method) && config.headers) {
config.headers[CSRF_HEADER_NAME] = ensureCsrfToken()
}
return config
})

View File

@@ -75,28 +75,20 @@ export default function TopUpModal({ method, onClose }: TopUpModalProps) {
const starsPaymentMutation = useMutation({
mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks),
onSuccess: (data) => {
console.log('[Stars] API response:', data)
console.log('[Stars] invoice_url:', data.invoice_url)
const webApp = window.Telegram?.WebApp
console.log('[Stars] webApp:', webApp)
console.log('[Stars] openInvoice available:', !!webApp?.openInvoice)
if (!data.invoice_url) {
console.error('[Stars] No invoice_url in response!')
setError('Сервер не вернул ссылку на оплату')
return
}
if (!webApp?.openInvoice) {
console.error('[Stars] openInvoice not available - not in Telegram Mini App?')
setError('Оплата Stars доступна только в Telegram Mini App')
return
}
console.log('[Stars] Calling openInvoice with:', data.invoice_url)
try {
webApp.openInvoice(data.invoice_url, (status) => {
console.log('[Stars] Invoice callback status:', status)
if (status === 'paid') {
setError(null)
onClose()
@@ -107,12 +99,10 @@ export default function TopUpModal({ method, onClose }: TopUpModalProps) {
}
})
} catch (e) {
console.error('[Stars] openInvoice error:', e)
setError('Ошибка открытия окна оплаты: ' + String(e))
}
},
onError: (error: unknown) => {
console.error('[Stars] API error:', error)
const axiosError = error as { response?: { data?: { detail?: string }, status?: number } }
const detail = axiosError?.response?.data?.detail
const status = axiosError?.response?.status

View File

@@ -26,6 +26,19 @@ const appSchemes = [
const COUNTDOWN_SECONDS = 5
// Validate deep link to prevent javascript: and other dangerous schemes
const isValidDeepLink = (url: string): boolean => {
if (!url) return false
const lowerUrl = url.toLowerCase().trim()
// Block dangerous schemes
const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:']
if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) {
return false
}
// Only allow known app schemes
return appSchemes.some(app => lowerUrl.startsWith(app.scheme))
}
export default function DeepLinkRedirect() {
const { i18n } = useTranslation()
const navigate = useNavigate()
@@ -104,13 +117,13 @@ export default function DeepLinkRedirect() {
// Open deep link - same as miniapp, just window.location.href
const openDeepLink = useCallback(() => {
if (!deepLink) return
if (!deepLink || !isValidDeepLink(deepLink)) return
window.location.href = deepLink
}, [deepLink])
// Countdown timer effect
useEffect(() => {
if (!deepLink) {
if (!deepLink || !isValidDeepLink(deepLink)) {
setStatus('error')
return
}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import DOMPurify from 'dompurify'
import { infoApi, FaqPage } from '../api/info'
const InfoIcon = () => (
@@ -41,6 +42,15 @@ const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
type TabType = 'faq' | 'rules' | 'privacy' | 'offer'
// Sanitize HTML content to prevent XSS
const sanitizeHtml = (html: string): string => {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p', 'br', 'b', 'i', 'u', 'strong', 'em', 'a', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'code', 'pre', 'span', 'div'],
ALLOWED_ATTR: ['href', 'target', 'rel', 'class'],
ALLOW_DATA_ATTR: false,
})
}
// Convert plain text to HTML with proper formatting
const formatContent = (content: string): string => {
if (!content) return ''
@@ -49,11 +59,11 @@ const formatContent = (content: string): string => {
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(content)
if (hasHtmlTags) {
return content
return sanitizeHtml(content)
}
// Convert plain text to formatted HTML
return content
const result = content
.split(/\n\n+/) // Split by double newlines (paragraphs)
.map(paragraph => {
// Check if it's a header (starts with # or numeric like "1.")
@@ -83,6 +93,8 @@ const formatContent = (content: string): string => {
return `<p>${formattedParagraph}</p>`
})
.join('')
return sanitizeHtml(result)
}
export default function Info() {

View File

@@ -301,18 +301,20 @@ export default function Subscription() {
// Auto-scroll to switch tariff modal when it appears
useEffect(() => {
if (switchTariffId && switchModalRef.current) {
setTimeout(() => {
const timer = setTimeout(() => {
switchModalRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 100)
return () => clearTimeout(timer)
}
}, [switchTariffId])
// Auto-scroll to tariff purchase form when it appears
useEffect(() => {
if (showTariffPurchase && tariffPurchaseRef.current) {
setTimeout(() => {
const timer = setTimeout(() => {
tariffPurchaseRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 100)
return () => clearTimeout(timer)
}
}, [showTariffPurchase])
@@ -320,11 +322,12 @@ export default function Subscription() {
useEffect(() => {
const state = location.state as { scrollToExtend?: boolean } | null
if (state?.scrollToExtend && tariffsCardRef.current) {
setTimeout(() => {
const timer = setTimeout(() => {
tariffsCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 300)
// Clear the state to prevent re-scrolling on subsequent renders
window.history.replaceState({}, document.title)
return () => clearTimeout(timer)
}
}, [location.state])

View File

@@ -32,19 +32,27 @@ export default function TelegramCallback() {
return
}
// Parse and validate numeric fields
const parsedId = parseInt(id, 10)
const parsedAuthDate = parseInt(authDate, 10)
if (Number.isNaN(parsedId) || Number.isNaN(parsedAuthDate)) {
setError(t('auth.telegramRequired'))
return
}
try {
await loginWithTelegramWidget({
id: parseInt(id, 10),
id: parsedId,
first_name: firstName,
last_name: lastName || undefined,
username: username || undefined,
photo_url: photoUrl || undefined,
auth_date: parseInt(authDate, 10),
auth_date: parsedAuthDate,
hash: hash,
})
navigate('/')
} catch (err: unknown) {
console.error('Telegram auth error:', err)
const error = err as { response?: { data?: { detail?: string } } }
setError(error.response?.data?.detail || t('common.error'))
}

View File

@@ -5,6 +5,29 @@ import { useQuery } from '@tanstack/react-query'
import { useAuthStore } from '../store/auth'
import { brandingApi } from '../api/branding'
// Validate redirect URL to prevent open redirect attacks
const getSafeRedirectUrl = (url: string | null): string => {
if (!url) return '/'
// Only allow relative paths starting with /
// Block protocol-relative URLs (//evil.com) and absolute URLs
if (!url.startsWith('/') || url.startsWith('//')) {
return '/'
}
// Additional check for encoded characters that could bypass validation
try {
const decoded = decodeURIComponent(url)
if (!decoded.startsWith('/') || decoded.startsWith('//') || decoded.includes('://')) {
return '/'
}
} catch {
return '/'
}
return url
}
const MAX_RETRY_ATTEMPTS = 3
const RETRY_COUNT_KEY = 'telegram_redirect_retry_count'
export default function TelegramRedirect() {
const { t } = useTranslation()
const navigate = useNavigate()
@@ -12,6 +35,10 @@ export default function TelegramRedirect() {
const { loginWithTelegram, isAuthenticated, isLoading: authLoading } = useAuthStore()
const [status, setStatus] = useState<'loading' | 'success' | 'error' | 'not-telegram'>('loading')
const [errorMessage, setErrorMessage] = useState('')
const [retryCount, setRetryCount] = useState(() => {
const stored = sessionStorage.getItem(RETRY_COUNT_KEY)
return stored ? parseInt(stored, 10) : 0
})
// Get branding for nice display
const { data: branding } = useQuery({
@@ -24,8 +51,8 @@ export default function TelegramRedirect() {
const logoLetter = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V'
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null
// Get redirect target from URL params
const redirectTo = searchParams.get('redirect') || '/'
// Get redirect target from URL params (validated)
const redirectTo = getSafeRedirectUrl(searchParams.get('redirect'))
useEffect(() => {
// If already authenticated, redirect immediately
@@ -76,13 +103,28 @@ export default function TelegramRedirect() {
setTimeout(initTelegram, 300)
}, [loginWithTelegram, navigate, isAuthenticated, authLoading, redirectTo, t])
// Handle retry
// Handle retry with limit to prevent infinite loops
const handleRetry = () => {
if (retryCount >= MAX_RETRY_ATTEMPTS) {
setErrorMessage('Превышено количество попыток. Попробуйте позже.')
sessionStorage.removeItem(RETRY_COUNT_KEY)
return
}
const newCount = retryCount + 1
setRetryCount(newCount)
sessionStorage.setItem(RETRY_COUNT_KEY, String(newCount))
setStatus('loading')
setErrorMessage('')
window.location.reload()
}
// Clear retry count on successful auth
useEffect(() => {
if (status === 'success') {
sessionStorage.removeItem(RETRY_COUNT_KEY)
}
}, [status])
return (
<div className="min-h-screen flex items-center justify-center p-4">
{/* Background */}

View File

@@ -36,8 +36,12 @@ interface AuthState {
}
// Блокировка для предотвращения race condition при инициализации
let initializePromise: Promise<void> | null = null
let isInitializing = false
// Используем объект для атомарности операций
const initState = {
promise: null as Promise<void> | null,
isInitializing: false,
isInitialized: false,
}
export const useAuthStore = create<AuthState>()(
persist(
@@ -69,8 +73,8 @@ export const useAuthStore = create<AuthState>()(
logout: () => {
const { refreshToken } = get()
if (refreshToken) {
authApi.logout(refreshToken).catch((error) => {
console.error('[Auth] Logout API call failed:', error)
authApi.logout(refreshToken).catch(() => {
// Logout API call failed - ignore silently
})
}
tokenStorage.clearTokens()
@@ -93,8 +97,7 @@ export const useAuthStore = create<AuthState>()(
// Используем apiClient для единообразной обработки ошибок
const response = await apiClient.get<{ is_admin: boolean }>('/cabinet/auth/me/is-admin')
set({ isAdmin: response.data.is_admin })
} catch (error) {
console.error('[Auth] Failed to check admin status:', error)
} catch {
set({ isAdmin: false })
}
},
@@ -103,19 +106,24 @@ export const useAuthStore = create<AuthState>()(
try {
const user = await authApi.getMe()
set({ user })
} catch (error) {
console.error('[Auth] Failed to refresh user:', error)
} catch {
// Failed to refresh user - ignore silently
}
},
initialize: async () => {
// Защита от race condition - если уже идёт инициализация, ждём её завершения
if (isInitializing && initializePromise) {
return initializePromise
// Защита от race condition - если уже инициализировано, выходим
if (initState.isInitialized) {
return
}
isInitializing = true
initializePromise = (async () => {
// Если уже идёт инициализация, ждём её завершения
if (initState.isInitializing && initState.promise) {
return initState.promise
}
initState.isInitializing = true
initState.promise = (async () => {
try {
set({ isLoading: true })
@@ -167,8 +175,7 @@ export const useAuthStore = create<AuthState>()(
isLoading: false,
})
get().checkAdminStatus()
} catch (error) {
console.error('[Auth] getMe failed, trying refresh:', error)
} catch {
// Token might be invalid on server, try to refresh
const newToken = await tokenRefreshManager.refreshAccessToken()
if (newToken) {
@@ -182,8 +189,7 @@ export const useAuthStore = create<AuthState>()(
isLoading: false,
})
get().checkAdminStatus()
} catch (userError) {
console.error('[Auth] getMe failed after refresh:', userError)
} catch {
tokenStorage.clearTokens()
set({
accessToken: null,
@@ -206,12 +212,13 @@ export const useAuthStore = create<AuthState>()(
}
}
} finally {
isInitializing = false
initializePromise = null
initState.isInitializing = false
initState.isInitialized = true
initState.promise = null
}
})()
return initializePromise
return initState.promise
},
loginWithTelegram: async (initData) => {

View File

@@ -211,8 +211,8 @@ class TokenRefreshManager {
}
return null
} catch (error) {
console.error('[TokenRefreshManager] Refresh failed:', error)
} catch {
// Token refresh failed - don't log sensitive error details
return null
}
}