Merge pull request #11 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-01-18 05:37:02 +03:00
committed by GitHub
7 changed files with 180 additions and 87 deletions

View File

@@ -1,5 +1,5 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
import { tokenStorage, isTokenExpired, tokenRefreshManager } from '../utils/token'
import { tokenStorage, isTokenExpired, tokenRefreshManager, safeRedirectToLogin } from '../utils/token'
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'
@@ -38,7 +38,7 @@ apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) =>
} else {
// Refresh не удался - редирект на логин
tokenStorage.clearTokens()
window.location.href = '/login'
safeRedirectToLogin()
return config
}
}
@@ -73,7 +73,7 @@ apiClient.interceptors.response.use(
} else {
// Refresh не удался
tokenStorage.clearTokens()
window.location.href = '/login'
safeRedirectToLogin()
}
}

View File

@@ -1,6 +1,8 @@
// Currency exchange rate API
// Uses free exchangerate.host API
import axios from 'axios'
interface ExchangeRates {
USD: number
CNY: number
@@ -33,40 +35,39 @@ export const currencyApi = {
try {
// Try primary API (exchangerate.host) - get RUB based rates
const response = await fetch(
'https://api.exchangerate.host/latest?base=RUB&symbols=USD,CNY,IRR'
)
const response = await axios.get<{
success?: boolean
rates?: { USD?: number; CNY?: number; IRR?: number }
}>('https://api.exchangerate.host/latest', {
params: { base: 'RUB', symbols: 'USD,CNY,IRR' },
})
if (response.ok) {
const data = await response.json()
if (data.success && data.rates) {
if (response.data.success && response.data.rates) {
// API returns how much of each currency equals 1 RUB
// We need the inverse: how many RUB equals 1 of each currency
const rates: ExchangeRates = {
USD: data.rates.USD ? 1 / data.rates.USD : FALLBACK_RATES.USD,
CNY: data.rates.CNY ? 1 / data.rates.CNY : FALLBACK_RATES.CNY,
IRR: data.rates.IRR ? 1 / data.rates.IRR : FALLBACK_RATES.IRR,
USD: response.data.rates.USD ? 1 / response.data.rates.USD : FALLBACK_RATES.USD,
CNY: response.data.rates.CNY ? 1 / response.data.rates.CNY : FALLBACK_RATES.CNY,
IRR: response.data.rates.IRR ? 1 / response.data.rates.IRR : FALLBACK_RATES.IRR,
}
cachedRates = { rates, timestamp: Date.now() }
return rates
}
}
// Try backup API (open.er-api.com)
const backupResponse = await fetch('https://open.er-api.com/v6/latest/RUB')
const backupResponse = await axios.get<{
rates?: { USD?: number; CNY?: number; IRR?: number }
}>('https://open.er-api.com/v6/latest/RUB')
if (backupResponse.ok) {
const backupData = await backupResponse.json()
if (backupData.rates) {
if (backupResponse.data.rates) {
const rates: ExchangeRates = {
USD: backupData.rates.USD ? 1 / backupData.rates.USD : FALLBACK_RATES.USD,
CNY: backupData.rates.CNY ? 1 / backupData.rates.CNY : FALLBACK_RATES.CNY,
IRR: backupData.rates.IRR ? 1 / backupData.rates.IRR : FALLBACK_RATES.IRR,
USD: backupResponse.data.rates.USD ? 1 / backupResponse.data.rates.USD : FALLBACK_RATES.USD,
CNY: backupResponse.data.rates.CNY ? 1 / backupResponse.data.rates.CNY : FALLBACK_RATES.CNY,
IRR: backupResponse.data.rates.IRR ? 1 / backupResponse.data.rates.IRR : FALLBACK_RATES.IRR,
}
cachedRates = { rates, timestamp: Date.now() }
return rates
}
}
// Return fallback rates if both APIs fail
return FALLBACK_RATES

View File

@@ -1,4 +1,6 @@
export interface MiniappCreatePaymentPayload {
import axios, { AxiosError } from 'axios'
export interface MiniappCreatePaymentPayload {
initData: string
method: string
amountKopeks?: number | null
@@ -16,21 +18,26 @@ export const miniappApi = {
createPayment: async (
payload: MiniappCreatePaymentPayload
): Promise<MiniappCreatePaymentResponse> => {
const res = await fetch('/miniapp/payments/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
try {
const response = await axios.post<MiniappCreatePaymentResponse>(
'/miniapp/payments/create',
{
initData: payload.initData || '',
method: payload.method,
amountKopeks: payload.amountKopeks ?? null,
option: payload.option ?? null,
}),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
const message = (data && (data.detail || data.message)) || 'Failed to create payment'
throw new Error(String(message))
},
{
headers: { 'Content-Type': 'application/json' },
}
)
return response.data
} catch (error) {
const axiosError = error as AxiosError<{ detail?: string; message?: string }>
const message = axiosError.response?.data?.detail
|| axiosError.response?.data?.message
|| 'Failed to create payment'
throw new Error(message)
}
return data as MiniappCreatePaymentResponse
},
}

View File

@@ -3,8 +3,11 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { ticketsApi } from '../api/tickets'
import { infoApi } from '../api/info'
import { logger } from '../utils/logger'
import type { TicketDetail, TicketMessage } from '../types'
const log = logger.createLogger('Support')
const PlusIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
@@ -120,7 +123,7 @@ function MessageMedia({ message, t }: { message: TicketMessage; t: (key: string)
}
export default function Support() {
console.log('[Support] Component loaded - VERSION 2024-01-16-v3')
log.debug('Component loaded')
const { t } = useTranslation()
const queryClient = useQueryClient()
@@ -271,20 +274,20 @@ export default function Support() {
// If tickets are disabled, show redirect message
if (supportConfig && !supportConfig.tickets_enabled) {
console.log('[Support] Tickets disabled, config:', supportConfig)
log.debug('Tickets disabled, config:', supportConfig)
const getSupportMessage = () => {
console.log('[Support] Getting support message for type:', supportConfig.support_type)
log.debug('Getting support message for type:', supportConfig.support_type)
if (supportConfig.support_type === 'profile') {
const supportUsername = supportConfig.support_username || '@support'
console.log('[Support] Opening profile:', supportUsername)
log.debug('Opening profile:', supportUsername)
return {
title: t('support.ticketsDisabled'),
message: t('support.contactSupport', { username: supportUsername }),
buttonText: t('support.contactUs'),
buttonAction: () => {
console.log('[Support] Button clicked, opening:', supportUsername)
log.debug('Button clicked, opening:', supportUsername)
const webApp = window.Telegram?.WebApp
// Extract username without @
@@ -292,41 +295,36 @@ export default function Support() {
? supportUsername.slice(1)
: supportUsername
// Try different URL formats
const telegramUrl = `tg://resolve?domain=${username}`
const webUrl = `https://t.me/${username}`
console.log('[Support] Telegram URL:', telegramUrl)
console.log('[Support] Web URL:', webUrl)
console.log('[Support] WebApp methods:', {
log.debug('Web URL:', webUrl, 'WebApp methods:', {
openTelegramLink: !!webApp?.openTelegramLink,
openLink: !!webApp?.openLink,
})
// Try openTelegramLink first (for tg:// links)
if (webApp?.openTelegramLink) {
console.log('[Support] Using openTelegramLink with web URL')
log.debug('Using openTelegramLink with web URL')
try {
webApp.openTelegramLink(webUrl)
return
} catch (e) {
console.error('[Support] openTelegramLink failed:', e)
log.error('openTelegramLink failed:', e)
}
}
// Fallback to openLink
if (webApp?.openLink) {
console.log('[Support] Using openLink')
log.debug('Using openLink')
try {
webApp.openLink(webUrl)
return
} catch (e) {
console.error('[Support] openLink failed:', e)
log.error('openLink failed:', e)
}
}
// Last resort - window.open
console.log('[Support] Using window.open')
log.debug('Using window.open')
window.open(webUrl, '_blank')
},
}
@@ -350,13 +348,13 @@ export default function Support() {
// Fallback: contact support (should not normally happen if config is correct)
const supportUsername = supportConfig.support_username || '@support'
console.log('[Support] Fallback: Opening profile:', supportUsername)
log.debug('Fallback: Opening profile:', supportUsername)
return {
title: t('support.ticketsDisabled'),
message: t('support.contactSupport', { username: supportUsername }),
buttonText: t('support.contactUs'),
buttonAction: () => {
console.log('[Support] Fallback button clicked, opening:', supportUsername)
log.debug('Fallback button clicked, opening:', supportUsername)
const webApp = window.Telegram?.WebApp
// Extract username without @
@@ -365,16 +363,16 @@ export default function Support() {
: supportUsername
const webUrl = `https://t.me/${username}`
console.log('[Support] Fallback opening URL:', webUrl)
log.debug('Fallback opening URL:', webUrl)
if (webApp?.openTelegramLink) {
console.log('[Support] Fallback using openTelegramLink')
log.debug('Fallback using openTelegramLink')
webApp.openTelegramLink(webUrl)
} else if (webApp?.openLink) {
console.log('[Support] Fallback using openLink')
log.debug('Fallback using openLink')
webApp.openLink(webUrl)
} else {
console.log('[Support] Fallback using window.open')
log.debug('Fallback using window.open')
window.open(webUrl, '_blank')
}
},

View File

@@ -2,6 +2,7 @@ import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { User } from '../types'
import { authApi } from '../api/auth'
import { apiClient } from '../api/client'
import { tokenStorage, isTokenValid, tokenRefreshManager } from '../utils/token'
export interface TelegramWidgetData {
@@ -89,15 +90,9 @@ export const useAuthStore = create<AuthState>()(
set({ isAdmin: false })
return
}
const response = await fetch('/api/cabinet/auth/me/is-admin', {
headers: {
Authorization: `Bearer ${token}`,
},
})
if (response.ok) {
const data = await response.json()
set({ isAdmin: data.is_admin })
}
// Используем 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)
set({ isAdmin: false })

57
src/utils/logger.ts Normal file
View File

@@ -0,0 +1,57 @@
/**
* Утилита для логирования только в development режиме
* В production логи не выводятся
*/
const isDev = import.meta.env.DEV
export const logger = {
log: (...args: unknown[]): void => {
if (isDev) {
console.log(...args)
}
},
warn: (...args: unknown[]): void => {
if (isDev) {
console.warn(...args)
}
},
error: (...args: unknown[]): void => {
// Ошибки логируем всегда (важно для отладки в production)
console.error(...args)
},
debug: (...args: unknown[]): void => {
if (isDev) {
console.debug(...args)
}
},
/**
* Создаёт логгер с префиксом для конкретного модуля
*/
createLogger: (prefix: string) => ({
log: (...args: unknown[]): void => {
if (isDev) {
console.log(`[${prefix}]`, ...args)
}
},
warn: (...args: unknown[]): void => {
if (isDev) {
console.warn(`[${prefix}]`, ...args)
}
},
error: (...args: unknown[]): void => {
console.error(`[${prefix}]`, ...args)
},
debug: (...args: unknown[]): void => {
if (isDev) {
console.debug(`[${prefix}]`, ...args)
}
},
}),
}
export default logger

View File

@@ -2,6 +2,8 @@
* Утилиты для безопасной работы с JWT токенами
*/
import axios from 'axios'
const TOKEN_KEYS = {
ACCESS: 'access_token',
REFRESH: 'refresh_token',
@@ -194,18 +196,14 @@ class TokenRefreshManager {
private async doRefresh(refreshToken: string): Promise<string | null> {
try {
const response = await fetch(this.refreshEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
})
// Используем чистый axios (не apiClient) чтобы избежать циклической зависимости
const response = await axios.post<{ access_token?: string }>(
this.refreshEndpoint,
{ refresh_token: refreshToken },
{ headers: { 'Content-Type': 'application/json' } }
)
if (!response.ok) {
throw new Error(`Refresh failed: ${response.status}`)
}
const data = await response.json()
const newAccessToken = data.access_token
const newAccessToken = response.data.access_token
if (newAccessToken) {
tokenStorage.setAccessToken(newAccessToken)
@@ -253,3 +251,40 @@ class TokenRefreshManager {
}
export const tokenRefreshManager = new TokenRefreshManager()
/**
* Безопасный редирект на страницу логина
* Валидирует URL чтобы предотвратить open redirect уязвимость
*/
export function safeRedirectToLogin(): void {
// Разрешённые пути для редиректа (относительные пути нашего приложения)
const loginPath = '/login'
// Проверяем, что мы на том же origin
if (typeof window !== 'undefined') {
// Используем только относительный путь для безопасности
window.location.href = loginPath
}
}
/**
* Валидирует URL для редиректа
* Возвращает true только для безопасных URL (относительные пути или тот же origin)
*/
export function isValidRedirectUrl(url: string): boolean {
// Пустой URL - небезопасен
if (!url) return false
// Относительные пути всегда безопасны
if (url.startsWith('/') && !url.startsWith('//')) {
return true
}
try {
const parsed = new URL(url, window.location.origin)
// Разрешаем только тот же origin
return parsed.origin === window.location.origin
} catch {
return false
}
}