197 lines
5.4 KiB
Vue
197 lines
5.4 KiB
Vue
<script setup lang="ts">
|
|
const { $i18n } = useNuxtApp()
|
|
const t = $i18n.t
|
|
|
|
const message = ref('')
|
|
const sending = ref(false)
|
|
const sent = ref(false)
|
|
const error = ref('')
|
|
const cooldown = ref(0)
|
|
const honeypot = ref('')
|
|
const mountTime = Date.now()
|
|
let cooldownTimer: ReturnType<typeof setInterval> | null = null
|
|
|
|
const {
|
|
recaptchaSiteKey: siteKey,
|
|
feedbackMaxPerHour: MAX_PER_HOUR,
|
|
feedbackMaxPerSession: MAX_PER_SESSION,
|
|
feedbackCooldownSec: COOLDOWN_SEC,
|
|
feedbackMinSeconds: MIN_SECONDS
|
|
} = useRuntimeConfig().public
|
|
const RATE_LIMIT_KEY = 'feedback_ratelimit'
|
|
const SESSION_LIMIT_KEY = 'feedback_session'
|
|
|
|
function sanitize(text: string): string {
|
|
let clean = text
|
|
clean = clean.replace(/<[^>]*>/g, '')
|
|
clean = clean.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
|
clean = clean.replace(/https?:\/\/[^\s]+/gi, '')
|
|
clean = clean.replace(/www\.[^\s]+/gi, '')
|
|
clean = clean.replace(/javascript\s*:/gi, '')
|
|
clean = clean.replace(/\beval\s*\(/gi, '')
|
|
clean = clean.replace(/\bon\w+\s*=/gi, '')
|
|
clean = clean.replace(/[A-Za-z0-9+/]{50,}={0,2}/g, '')
|
|
clean = clean.replace(/'\s*OR\s+\d+\s*=\s*\d+/gi, '')
|
|
clean = clean.replace(/'\s*--/g, "'")
|
|
clean = clean.replace(/'\s*;\s*DROP\s+TABLE/gi, "'")
|
|
clean = clean.replace(/UNION\s+SELECT/gi, '')
|
|
clean = clean.replace(/xp_cmdshell/gi, '')
|
|
clean = clean.replace(/EXEC\s*\(/gi, '')
|
|
clean = clean.replace(/`[^`]*`/g, '')
|
|
clean = clean.replace(/\$\(.*?\)/g, '')
|
|
clean = clean.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '')
|
|
return clean.slice(0, 500)
|
|
}
|
|
|
|
function getRateLimitCount(): number {
|
|
try {
|
|
const raw = localStorage.getItem(RATE_LIMIT_KEY)
|
|
if (!raw) return 0
|
|
const data: { count: number; reset: number } = JSON.parse(raw)
|
|
if (Date.now() > data.reset) {
|
|
localStorage.removeItem(RATE_LIMIT_KEY)
|
|
return 0
|
|
}
|
|
return data.count
|
|
} catch {
|
|
return 0
|
|
}
|
|
}
|
|
|
|
function incrementRateLimit() {
|
|
const raw = localStorage.getItem(RATE_LIMIT_KEY)
|
|
const now = Date.now()
|
|
let data = { count: 0, reset: now + 3600000 }
|
|
if (raw) {
|
|
try {
|
|
const parsed = JSON.parse(raw)
|
|
if (now <= parsed.reset) {
|
|
data = parsed
|
|
}
|
|
} catch { /* usar default */ }
|
|
}
|
|
data.count++
|
|
localStorage.setItem(RATE_LIMIT_KEY, JSON.stringify(data))
|
|
}
|
|
|
|
function getSessionCount(): number {
|
|
try {
|
|
const raw = sessionStorage.getItem(SESSION_LIMIT_KEY)
|
|
return raw ? Number(raw) : 0
|
|
} catch {
|
|
return 0
|
|
}
|
|
}
|
|
|
|
function incrementSession() {
|
|
const count = getSessionCount() + 1
|
|
sessionStorage.setItem(SESSION_LIMIT_KEY, String(count))
|
|
}
|
|
|
|
function checkBlocked(): string | null {
|
|
const elapsed = (Date.now() - mountTime) / 1000
|
|
if (elapsed < MIN_SECONDS) return t('feedback.error_cooldown')
|
|
|
|
const rateCount = getRateLimitCount()
|
|
if (rateCount >= MAX_PER_HOUR) return t('feedback.error_rate_limit')
|
|
|
|
const sessionCount = getSessionCount()
|
|
if (sessionCount >= MAX_PER_SESSION) return t('feedback.error_session_limit')
|
|
|
|
return null
|
|
}
|
|
|
|
function startCooldown() {
|
|
cooldown.value = COOLDOWN_SEC
|
|
cooldownTimer = setInterval(() => {
|
|
cooldown.value--
|
|
if (cooldown.value <= 0) {
|
|
if (cooldownTimer) clearInterval(cooldownTimer)
|
|
cooldown.value = 0
|
|
}
|
|
}, 1000)
|
|
}
|
|
|
|
const { executeRecaptcha } = useRecaptcha(siteKey)
|
|
|
|
async function send() {
|
|
const body = sanitize(message.value)
|
|
if (!body.trim()) return
|
|
|
|
if (honeypot.value.trim()) return
|
|
|
|
const blocked = checkBlocked()
|
|
if (blocked !== null) {
|
|
error.value = blocked
|
|
return
|
|
}
|
|
|
|
sending.value = true
|
|
error.value = ''
|
|
|
|
try {
|
|
const token = siteKey ? await executeRecaptcha('submit_feedback') : null
|
|
|
|
await $fetch('/api/feedback', {
|
|
method: 'POST',
|
|
body: { message: body, recaptchaToken: token }
|
|
})
|
|
|
|
sent.value = true
|
|
message.value = ''
|
|
incrementRateLimit()
|
|
incrementSession()
|
|
startCooldown()
|
|
} catch (e) {
|
|
error.value = t('feedback.error_generic')
|
|
} finally {
|
|
sending.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<UDashboardPanel id="bugreport-panel" grow>
|
|
<UDashboardNavbar :title="t('feedback.title')">
|
|
<template #leading>
|
|
<UDashboardSidebarCollapse />
|
|
</template>
|
|
</UDashboardNavbar>
|
|
|
|
<div class="flex-1 flex items-center justify-center p-6">
|
|
<div v-if="sent" class="text-center space-y-3">
|
|
<UIcon name="i-lucide-circle-check" class="size-16 text-green-500" />
|
|
<p class="text-sm text-muted">{{ t('feedback.success_message') }}</p>
|
|
<UButton color="neutral" variant="outline" @click="sent = false">
|
|
{{ t('feedback.send_another') }}
|
|
</UButton>
|
|
</div>
|
|
|
|
<div v-else class="flex flex-col items-center gap-6 w-full max-w-xl">
|
|
<BugReportInput v-model="message" />
|
|
|
|
<input
|
|
v-model="honeypot"
|
|
type="text"
|
|
tabindex="-1"
|
|
autocomplete="off"
|
|
class="absolute -left-[9999px] -top-[9999px] opacity-0 pointer-events-none size-0"
|
|
aria-hidden="true"
|
|
/>
|
|
|
|
<UButton
|
|
:loading="sending"
|
|
:disabled="!message.trim() || cooldown > 0"
|
|
size="lg"
|
|
class="max-w-xl"
|
|
@click="send"
|
|
>
|
|
{{ sending ? t('feedback.sending') : cooldown > 0 ? t('feedback.wait_seconds', { seconds: cooldown }) : t('feedback.send') }}
|
|
</UButton>
|
|
|
|
<p v-if="error" class="text-sm text-red-500">{{ error }}</p>
|
|
</div>
|
|
</div>
|
|
</UDashboardPanel>
|
|
</template>
|