diff --git a/src/api/client.ts b/src/api/client.ts index 7788bb7..782a924 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -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() } } diff --git a/src/api/currency.ts b/src/api/currency.ts index 3288dcf..e5aa9f3 100644 --- a/src/api/currency.ts +++ b/src/api/currency.ts @@ -1,6 +1,8 @@ // Currency exchange rate API // Uses free exchangerate.host API +import axios from 'axios' + interface ExchangeRates { USD: number CNY: number @@ -33,39 +35,38 @@ 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) { - // 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, - } - cachedRates = { rates, timestamp: Date.now() } - return 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: 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) { - 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, - } - cachedRates = { rates, timestamp: Date.now() } - return rates + if (backupResponse.data.rates) { + const rates: ExchangeRates = { + 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 diff --git a/src/api/miniapp.ts b/src/api/miniapp.ts index 3fc2157..0801c30 100644 --- a/src/api/miniapp.ts +++ b/src/api/miniapp.ts @@ -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 => { - const res = await fetch('/miniapp/payments/create', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - 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)) + try { + const response = await axios.post( + '/miniapp/payments/create', + { + initData: payload.initData || '', + method: payload.method, + amountKopeks: payload.amountKopeks ?? null, + option: payload.option ?? null, + }, + { + 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 }, } diff --git a/src/pages/Support.tsx b/src/pages/Support.tsx index 2bfb6bf..80226cd 100644 --- a/src/pages/Support.tsx +++ b/src/pages/Support.tsx @@ -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 = () => ( @@ -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') } }, diff --git a/src/store/auth.ts b/src/store/auth.ts index e2d4ab4..c53747e 100644 --- a/src/store/auth.ts +++ b/src/store/auth.ts @@ -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()( 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 }) diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..95eee2b --- /dev/null +++ b/src/utils/logger.ts @@ -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 diff --git a/src/utils/token.ts b/src/utils/token.ts index 7018db9..0c7522f 100644 --- a/src/utils/token.ts +++ b/src/utils/token.ts @@ -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 { 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 + } +}