mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
Merge remote-tracking branch 'bedolaga/main' into PEDZ
This commit is contained in:
@@ -61,6 +61,48 @@ const platformIconComponents: Record<string, React.FC> = {
|
|||||||
// Platform order for display
|
// Platform order for display
|
||||||
const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV']
|
const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV']
|
||||||
|
|
||||||
|
// Allowed app schemes for deep links
|
||||||
|
const allowedAppSchemes = [
|
||||||
|
'happ://', 'flclash://', 'clash://', 'sing-box://', 'v2rayng://',
|
||||||
|
'sub://', 'shadowrocket://', 'hiddify://', 'streisand://',
|
||||||
|
'quantumult://', 'surge://', 'loon://', 'nekobox://', 'v2box://'
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate URL to prevent XSS via javascript: and other dangerous schemes
|
||||||
|
* Only allows http, https, and known app store URLs
|
||||||
|
*/
|
||||||
|
function isValidExternalUrl(url: string | undefined): 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow only http/https URLs
|
||||||
|
return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate deep link URL - only allows known VPN app schemes
|
||||||
|
*/
|
||||||
|
function isValidDeepLink(url: string | undefined): 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow known app schemes
|
||||||
|
return allowedAppSchemes.some(scheme => lowerUrl.startsWith(scheme))
|
||||||
|
}
|
||||||
|
|
||||||
// Detect user's platform from user agent
|
// Detect user's platform from user agent
|
||||||
function detectPlatform(): string | null {
|
function detectPlatform(): string | null {
|
||||||
if (typeof window === 'undefined' || !navigator?.userAgent) {
|
if (typeof window === 'undefined' || !navigator?.userAgent) {
|
||||||
@@ -170,7 +212,11 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
|||||||
|
|
||||||
// Handle deep link click - use miniapp redirect page like in miniapp index.html
|
// Handle deep link click - use miniapp redirect page like in miniapp index.html
|
||||||
const handleConnect = (app: AppInfo) => {
|
const handleConnect = (app: AppInfo) => {
|
||||||
if (!app.deepLink) return
|
// Validate deep link to prevent XSS
|
||||||
|
if (!app.deepLink || !isValidDeepLink(app.deepLink)) {
|
||||||
|
console.warn('Invalid or missing deep link:', app.deepLink)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const lang = i18n.language?.startsWith('ru') ? 'ru' : 'en'
|
const lang = i18n.language?.startsWith('ru') ? 'ru' : 'en'
|
||||||
const redirectUrl = `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(app.deepLink)}&lang=${lang}`
|
const redirectUrl = `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(app.deepLink)}&lang=${lang}`
|
||||||
@@ -442,7 +488,9 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
|||||||
</p>
|
</p>
|
||||||
{selectedApp.installationStep.buttons && selectedApp.installationStep.buttons.length > 0 && (
|
{selectedApp.installationStep.buttons && selectedApp.installationStep.buttons.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{selectedApp.installationStep.buttons.map((btn, idx) => (
|
{selectedApp.installationStep.buttons
|
||||||
|
.filter((btn) => isValidExternalUrl(btn.buttonLink))
|
||||||
|
.map((btn, idx) => (
|
||||||
<a
|
<a
|
||||||
key={idx}
|
key={idx}
|
||||||
href={btn.buttonLink}
|
href={btn.buttonLink}
|
||||||
|
|||||||
@@ -18,7 +18,14 @@ const ClockIcon = () => (
|
|||||||
|
|
||||||
const formatTimeLeft = (expiresAt: string, t: (key: string) => string): string => {
|
const formatTimeLeft = (expiresAt: string, t: (key: string) => string): string => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const expires = new Date(expiresAt)
|
// Ensure UTC parsing - if no timezone specified, assume UTC
|
||||||
|
let expires: Date
|
||||||
|
if (expiresAt.includes('Z') || expiresAt.includes('+') || expiresAt.includes('-', 10)) {
|
||||||
|
expires = new Date(expiresAt)
|
||||||
|
} else {
|
||||||
|
// No timezone - treat as UTC
|
||||||
|
expires = new Date(expiresAt + 'Z')
|
||||||
|
}
|
||||||
const diffMs = expires.getTime() - now.getTime()
|
const diffMs = expires.getTime() - now.getTime()
|
||||||
|
|
||||||
if (diffMs <= 0) return ''
|
if (diffMs <= 0) return ''
|
||||||
|
|||||||
@@ -36,7 +36,14 @@ const ServerIcon = () => (
|
|||||||
// Helper functions
|
// Helper functions
|
||||||
const formatTimeLeft = (expiresAt: string): string => {
|
const formatTimeLeft = (expiresAt: string): string => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const expires = new Date(expiresAt)
|
// Ensure UTC parsing - if no timezone specified, assume UTC
|
||||||
|
let expires: Date
|
||||||
|
if (expiresAt.includes('Z') || expiresAt.includes('+') || expiresAt.includes('-', 10)) {
|
||||||
|
expires = new Date(expiresAt)
|
||||||
|
} else {
|
||||||
|
// No timezone - treat as UTC
|
||||||
|
expires = new Date(expiresAt + 'Z')
|
||||||
|
}
|
||||||
const diffMs = expires.getTime() - now.getTime()
|
const diffMs = expires.getTime() - now.getTime()
|
||||||
|
|
||||||
if (diffMs <= 0) return 'Истекло'
|
if (diffMs <= 0) return 'Истекло'
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ export default function TelegramLoginButton({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current || !botUsername) return
|
if (!containerRef.current || !botUsername) return
|
||||||
|
|
||||||
// Clear previous widget
|
// Clear previous widget using safe DOM API
|
||||||
containerRef.current.innerHTML = ''
|
while (containerRef.current.firstChild) {
|
||||||
|
containerRef.current.removeChild(containerRef.current.firstChild)
|
||||||
|
}
|
||||||
|
|
||||||
// Get current URL for redirect
|
// Get current URL for redirect
|
||||||
const redirectUrl = `${window.location.origin}/auth/telegram/callback`
|
const redirectUrl = `${window.location.origin}/auth/telegram/callback`
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import { balanceApi } from '../api/balance'
|
import { balanceApi } from '../api/balance'
|
||||||
import { useCurrency } from '../hooks/useCurrency'
|
import { useCurrency } from '../hooks/useCurrency'
|
||||||
|
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit'
|
||||||
import type { PaymentMethod } from '../types'
|
import type { PaymentMethod } from '../types'
|
||||||
|
|
||||||
const TELEGRAM_LINK_REGEX = /^https?:\/\/t\.me\//i
|
const TELEGRAM_LINK_REGEX = /^https?:\/\/t\.me\//i
|
||||||
@@ -139,6 +140,14 @@ export default function TopUpModal({ method, onClose }: TopUpModalProps) {
|
|||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault(); setError(null)
|
e.preventDefault(); setError(null)
|
||||||
|
|
||||||
|
// Rate limit check: max 3 payment attempts per 30 seconds
|
||||||
|
if (!checkRateLimit(RATE_LIMIT_KEYS.PAYMENT, 3, 30000)) {
|
||||||
|
const resetTime = getRateLimitResetTime(RATE_LIMIT_KEYS.PAYMENT)
|
||||||
|
setError(t('balance.tooManyRequests', { seconds: resetTime }) || `Слишком много запросов. Подождите ${resetTime} сек.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (hasOptions && !selectedOption) { setError(t('balance.selectPaymentOption', 'Выберите способ оплаты')); return }
|
if (hasOptions && !selectedOption) { setError(t('balance.selectPaymentOption', 'Выберите способ оплаты')); return }
|
||||||
const amountCurrency = parseFloat(amount)
|
const amountCurrency = parseFloat(amount)
|
||||||
if (isNaN(amountCurrency) || amountCurrency <= 0) { setError(t('balance.invalidAmount', 'Invalid amount')); return }
|
if (isNaN(amountCurrency) || amountCurrency <= 0) { setError(t('balance.invalidAmount', 'Invalid amount')); return }
|
||||||
|
|||||||
@@ -126,6 +126,9 @@
|
|||||||
"selectServers": "Select Servers",
|
"selectServers": "Select Servers",
|
||||||
"selectDevices": "Devices",
|
"selectDevices": "Devices",
|
||||||
"perDevice": "/ device",
|
"perDevice": "/ device",
|
||||||
|
"devicesFree": "devices free",
|
||||||
|
"perExtraDevice": "/ extra device",
|
||||||
|
"perMonth": "/mo",
|
||||||
"summary": "Summary",
|
"summary": "Summary",
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
"purchase": "Purchase",
|
"purchase": "Purchase",
|
||||||
@@ -728,7 +731,8 @@
|
|||||||
"checkIntervalDesc": "How often to check for overdue tickets (30-600 seconds)",
|
"checkIntervalDesc": "How often to check for overdue tickets (30-600 seconds)",
|
||||||
"reminderCooldown": "Reminder interval (minutes)",
|
"reminderCooldown": "Reminder interval (minutes)",
|
||||||
"reminderCooldownDesc": "Minimum time between reminders (1-120 minutes)",
|
"reminderCooldownDesc": "Minimum time between reminders (1-120 minutes)",
|
||||||
"settingsUpdateError": "Error saving settings"
|
"settingsUpdateError": "Error saving settings",
|
||||||
|
"copyTelegramId": "Click to copy Telegram ID"
|
||||||
},
|
},
|
||||||
"tariffs": {
|
"tariffs": {
|
||||||
"title": "Tariff Management",
|
"title": "Tariff Management",
|
||||||
|
|||||||
@@ -126,6 +126,9 @@
|
|||||||
"selectServers": "Выберите серверы",
|
"selectServers": "Выберите серверы",
|
||||||
"selectDevices": "Устройства",
|
"selectDevices": "Устройства",
|
||||||
"perDevice": "/ устройство",
|
"perDevice": "/ устройство",
|
||||||
|
"devicesFree": "устройств бесплатно",
|
||||||
|
"perExtraDevice": "/ доп. устройство",
|
||||||
|
"perMonth": "/мес",
|
||||||
"summary": "Итого",
|
"summary": "Итого",
|
||||||
"total": "К оплате",
|
"total": "К оплате",
|
||||||
"purchase": "Купить",
|
"purchase": "Купить",
|
||||||
@@ -728,7 +731,8 @@
|
|||||||
"checkIntervalDesc": "Как часто проверять просроченные тикеты (30-600 секунд)",
|
"checkIntervalDesc": "Как часто проверять просроченные тикеты (30-600 секунд)",
|
||||||
"reminderCooldown": "Интервал напоминаний (минуты)",
|
"reminderCooldown": "Интервал напоминаний (минуты)",
|
||||||
"reminderCooldownDesc": "Минимальное время между напоминаниями (1-120 минут)",
|
"reminderCooldownDesc": "Минимальное время между напоминаниями (1-120 минут)",
|
||||||
"settingsUpdateError": "Ошибка сохранения настроек"
|
"settingsUpdateError": "Ошибка сохранения настроек",
|
||||||
|
"copyTelegramId": "Нажмите чтобы скопировать Telegram ID"
|
||||||
},
|
},
|
||||||
"tariffs": {
|
"tariffs": {
|
||||||
"title": "Управление тарифами",
|
"title": "Управление тарифами",
|
||||||
|
|||||||
@@ -155,10 +155,21 @@ export default function AdminTickets() {
|
|||||||
|
|
||||||
const formatUser = (ticket: AdminTicket | AdminTicketDetail) => {
|
const formatUser = (ticket: AdminTicket | AdminTicketDetail) => {
|
||||||
if (!ticket.user) return 'Unknown'
|
if (!ticket.user) return 'Unknown'
|
||||||
const { first_name, last_name, username, telegram_id } = ticket.user
|
const { first_name, last_name, username } = ticket.user
|
||||||
if (first_name || last_name) return `${first_name || ''} ${last_name || ''}`.trim()
|
if (first_name || last_name) return `${first_name || ''} ${last_name || ''}`.trim()
|
||||||
if (username) return `@${username}`
|
if (username) return `@${username}`
|
||||||
return `ID: ${telegram_id}`
|
return 'User'
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string) => {
|
||||||
|
navigator.clipboard.writeText(text).catch(() => {
|
||||||
|
const textarea = document.createElement('textarea')
|
||||||
|
textarea.value = text
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -248,7 +259,17 @@ export default function AdminTickets() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-dark-500">
|
<div className="text-xs text-dark-500">
|
||||||
{formatUser(ticket)} | {new Date(ticket.updated_at).toLocaleDateString()}
|
{formatUser(ticket)}
|
||||||
|
{ticket.user?.telegram_id && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); copyToClipboard(String(ticket.user!.telegram_id)) }}
|
||||||
|
className="ml-1 text-dark-600 hover:text-accent-400 transition-colors"
|
||||||
|
title={t('admin.tickets.copyTelegramId')}
|
||||||
|
>
|
||||||
|
(TG: {ticket.user!.telegram_id})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{' '}| {new Date(ticket.updated_at).toLocaleDateString()}
|
||||||
</div>
|
</div>
|
||||||
{ticket.last_message && (
|
{ticket.last_message && (
|
||||||
<div className="text-xs text-dark-600 mt-1 truncate">
|
<div className="text-xs text-dark-600 mt-1 truncate">
|
||||||
@@ -315,8 +336,17 @@ export default function AdminTickets() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-dark-500 mb-4">
|
<div className="text-sm text-dark-500 mb-4">
|
||||||
{t('admin.tickets.from')}: {formatUser(selectedTicket)} |{' '}
|
{t('admin.tickets.from')}: {formatUser(selectedTicket)}
|
||||||
{t('admin.tickets.created')}: {new Date(selectedTicket.created_at).toLocaleString()}
|
{selectedTicket.user?.telegram_id && (
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(String(selectedTicket.user!.telegram_id))}
|
||||||
|
className="ml-1 px-2 py-0.5 text-xs bg-dark-700 hover:bg-dark-600 rounded transition-colors"
|
||||||
|
title={t('admin.tickets.copyTelegramId')}
|
||||||
|
>
|
||||||
|
TG: {selectedTicket.user!.telegram_id}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{' '}| {t('admin.tickets.created')}: {new Date(selectedTicket.created_at).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{['open', 'pending', 'answered', 'closed'].map((s) => (
|
{['open', 'pending', 'answered', 'closed'].map((s) => (
|
||||||
|
|||||||
@@ -1095,6 +1095,13 @@ export default function Subscription() {
|
|||||||
{/* Tariff List - current tariff first */}
|
{/* Tariff List - current tariff first */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
{[...tariffs]
|
{[...tariffs]
|
||||||
|
// Hide Trial tariffs for users who already have trial subscription
|
||||||
|
.filter((tariff) => {
|
||||||
|
if (subscription?.is_trial && tariff.name.toLowerCase().includes('trial')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const aIsCurrent = a.is_current || a.id === subscription?.tariff_id
|
const aIsCurrent = a.is_current || a.id === subscription?.tariff_id
|
||||||
const bIsCurrent = b.is_current || b.id === subscription?.tariff_id
|
const bIsCurrent = b.is_current || b.id === subscription?.tariff_id
|
||||||
@@ -1787,7 +1794,15 @@ export default function Subscription() {
|
|||||||
{/* Step: Server Selection */}
|
{/* Step: Server Selection */}
|
||||||
{currentStep === 'servers' && selectedPeriod?.servers.options && (
|
{currentStep === 'servers' && selectedPeriod?.servers.options && (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||||
{selectedPeriod.servers.options.map((server) => {
|
{selectedPeriod.servers.options
|
||||||
|
// Hide Trial server for users who already have trial subscription
|
||||||
|
.filter((server) => {
|
||||||
|
if (subscription?.is_trial && server.name.toLowerCase().includes('trial')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.map((server) => {
|
||||||
const hasExistingDiscount = !!(server.discount_percent && server.discount_percent > 0)
|
const hasExistingDiscount = !!(server.discount_percent && server.discount_percent > 0)
|
||||||
const promoServer = applyPromoDiscount(server.price_kopeks, hasExistingDiscount)
|
const promoServer = applyPromoDiscount(server.price_kopeks, hasExistingDiscount)
|
||||||
|
|
||||||
@@ -1822,7 +1837,7 @@ export default function Subscription() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-dark-100">{server.name}</div>
|
<div className="font-medium text-dark-100">{server.name}</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-accent-400">{formatPrice(promoServer.price)}</span>
|
<span className="text-sm text-accent-400">{formatPrice(promoServer.price)}{t('subscription.perMonth')}</span>
|
||||||
{promoServer.original && (
|
{promoServer.original && (
|
||||||
<span className="text-dark-500 text-xs line-through">{formatPrice(promoServer.original)}</span>
|
<span className="text-dark-500 text-xs line-through">{formatPrice(promoServer.original)}</span>
|
||||||
)}
|
)}
|
||||||
@@ -1860,8 +1875,15 @@ export default function Subscription() {
|
|||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-dark-500 mt-4">
|
<div className="text-sm text-dark-500 mt-4 text-center space-y-1">
|
||||||
{formatPrice(selectedPeriod.devices.price_per_device_kopeks)} {t('subscription.perDevice')}
|
<div className="text-accent-400">
|
||||||
|
{selectedPeriod.devices.min} {t('subscription.devicesFree')}
|
||||||
|
</div>
|
||||||
|
{selectedPeriod.devices.max > selectedPeriod.devices.min && (
|
||||||
|
<div>
|
||||||
|
{formatPrice(selectedPeriod.devices.price_per_device_kopeks)} {t('subscription.perExtraDevice')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { ticketsApi } from '../api/tickets'
|
import { ticketsApi } from '../api/tickets'
|
||||||
import { infoApi } from '../api/info'
|
import { infoApi } from '../api/info'
|
||||||
import { logger } from '../utils/logger'
|
import { logger } from '../utils/logger'
|
||||||
|
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit'
|
||||||
import type { TicketDetail, TicketMessage } from '../types'
|
import type { TicketDetail, TicketMessage } from '../types'
|
||||||
|
|
||||||
const log = logger.createLogger('Support')
|
const log = logger.createLogger('Support')
|
||||||
@@ -132,6 +133,7 @@ export default function Support() {
|
|||||||
const [newTitle, setNewTitle] = useState('')
|
const [newTitle, setNewTitle] = useState('')
|
||||||
const [newMessage, setNewMessage] = useState('')
|
const [newMessage, setNewMessage] = useState('')
|
||||||
const [replyMessage, setReplyMessage] = useState('')
|
const [replyMessage, setReplyMessage] = useState('')
|
||||||
|
const [rateLimitError, setRateLimitError] = useState<string | null>(null)
|
||||||
|
|
||||||
// Media attachment states
|
// Media attachment states
|
||||||
const [createAttachment, setCreateAttachment] = useState<MediaAttachment | null>(null)
|
const [createAttachment, setCreateAttachment] = useState<MediaAttachment | null>(null)
|
||||||
@@ -507,6 +509,13 @@ export default function Support() {
|
|||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
setRateLimitError(null)
|
||||||
|
// Rate limit: max 3 tickets per 60 seconds
|
||||||
|
if (!checkRateLimit(RATE_LIMIT_KEYS.TICKET_CREATE, 3, 60000)) {
|
||||||
|
const resetTime = getRateLimitResetTime(RATE_LIMIT_KEYS.TICKET_CREATE)
|
||||||
|
setRateLimitError(t('support.tooManyRequests', { seconds: resetTime }) || `Слишком много запросов. Подождите ${resetTime} сек.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
createMutation.mutate()
|
createMutation.mutate()
|
||||||
}}
|
}}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
@@ -567,6 +576,12 @@ export default function Support() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{rateLimitError && (
|
||||||
|
<div className="bg-error-500/10 border border-error-500/30 text-error-400 p-3 rounded-xl text-sm">
|
||||||
|
{rateLimitError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -655,6 +670,13 @@ export default function Support() {
|
|||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
setRateLimitError(null)
|
||||||
|
// Rate limit: max 5 replies per 30 seconds
|
||||||
|
if (!checkRateLimit(RATE_LIMIT_KEYS.TICKET_REPLY, 5, 30000)) {
|
||||||
|
const resetTime = getRateLimitResetTime(RATE_LIMIT_KEYS.TICKET_REPLY)
|
||||||
|
setRateLimitError(t('support.tooManyRequests', { seconds: resetTime }) || `Слишком много запросов. Подождите ${resetTime} сек.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
replyMutation.mutate()
|
replyMutation.mutate()
|
||||||
}}
|
}}
|
||||||
className="pt-4 border-t border-dark-800/50"
|
className="pt-4 border-t border-dark-800/50"
|
||||||
@@ -715,6 +737,11 @@ export default function Support() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{rateLimitError && (
|
||||||
|
<div className="mt-2 bg-error-500/10 border border-error-500/30 text-error-400 p-2 rounded-lg text-sm">
|
||||||
|
{rateLimitError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
logout: () => {
|
logout: () => {
|
||||||
const { refreshToken } = get()
|
// Get refresh token from secure storage, not zustand state
|
||||||
|
const refreshToken = tokenStorage.getRefreshToken()
|
||||||
if (refreshToken) {
|
if (refreshToken) {
|
||||||
authApi.logout(refreshToken).catch(() => {
|
authApi.logout(refreshToken).catch(() => {
|
||||||
// Logout API call failed - ignore silently
|
// Logout API call failed - ignore silently
|
||||||
@@ -259,8 +260,9 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'cabinet-auth',
|
name: 'cabinet-auth',
|
||||||
|
// Only persist user info for UI caching
|
||||||
|
// Tokens are stored securely in sessionStorage via tokenStorage
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
refreshToken: state.refreshToken,
|
|
||||||
user: state.user,
|
user: state.user,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
117
src/utils/rateLimit.ts
Normal file
117
src/utils/rateLimit.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* Client-side rate limiting utilities to prevent spam requests
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface RateLimitEntry {
|
||||||
|
count: number
|
||||||
|
resetTime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateLimitStore = new Map<string, RateLimitEntry>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if action is rate limited
|
||||||
|
* @param key Unique key for the action (e.g., 'payment', 'ticket-create')
|
||||||
|
* @param maxRequests Maximum requests allowed in the time window
|
||||||
|
* @param windowMs Time window in milliseconds
|
||||||
|
* @returns true if action is allowed, false if rate limited
|
||||||
|
*/
|
||||||
|
export function checkRateLimit(
|
||||||
|
key: string,
|
||||||
|
maxRequests: number = 3,
|
||||||
|
windowMs: number = 10000
|
||||||
|
): boolean {
|
||||||
|
const now = Date.now()
|
||||||
|
const entry = rateLimitStore.get(key)
|
||||||
|
|
||||||
|
// Clean up expired entry
|
||||||
|
if (entry && now >= entry.resetTime) {
|
||||||
|
rateLimitStore.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentEntry = rateLimitStore.get(key)
|
||||||
|
|
||||||
|
if (!currentEntry) {
|
||||||
|
// First request - allow and start tracking
|
||||||
|
rateLimitStore.set(key, {
|
||||||
|
count: 1,
|
||||||
|
resetTime: now + windowMs,
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentEntry.count >= maxRequests) {
|
||||||
|
// Rate limit exceeded
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment counter and allow
|
||||||
|
currentEntry.count++
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get remaining time until rate limit resets (in seconds)
|
||||||
|
*/
|
||||||
|
export function getRateLimitResetTime(key: string): number {
|
||||||
|
const entry = rateLimitStore.get(key)
|
||||||
|
if (!entry) return 0
|
||||||
|
const remaining = entry.resetTime - Date.now()
|
||||||
|
return remaining > 0 ? Math.ceil(remaining / 1000) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset rate limit for a key (e.g., after successful action)
|
||||||
|
*/
|
||||||
|
export function resetRateLimit(key: string): void {
|
||||||
|
rateLimitStore.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounce function - delays execution until after wait ms have elapsed
|
||||||
|
*/
|
||||||
|
export function debounce<T extends (...args: unknown[]) => unknown>(
|
||||||
|
func: T,
|
||||||
|
wait: number
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
return (...args: Parameters<T>) => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
func(...args)
|
||||||
|
timeoutId = null
|
||||||
|
}, wait)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throttle function - ensures function is called at most once per limit ms
|
||||||
|
*/
|
||||||
|
export function throttle<T extends (...args: unknown[]) => unknown>(
|
||||||
|
func: T,
|
||||||
|
limit: number
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let inThrottle = false
|
||||||
|
|
||||||
|
return (...args: Parameters<T>) => {
|
||||||
|
if (!inThrottle) {
|
||||||
|
func(...args)
|
||||||
|
inThrottle = true
|
||||||
|
setTimeout(() => {
|
||||||
|
inThrottle = false
|
||||||
|
}, limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit keys for different actions
|
||||||
|
export const RATE_LIMIT_KEYS = {
|
||||||
|
PAYMENT: 'payment',
|
||||||
|
TICKET_CREATE: 'ticket-create',
|
||||||
|
TICKET_REPLY: 'ticket-reply',
|
||||||
|
PROMO_ACTIVATE: 'promo-activate',
|
||||||
|
WHEEL_SPIN: 'wheel-spin',
|
||||||
|
} as const
|
||||||
Reference in New Issue
Block a user