d98e2e8dc0
Schützt den Feedback-Endpunkt vor Missbrauch durch pro-Nutzer-Limits, Honeypot, Zeitprüfung und einfache Inhaltsheuristiken. Co-authored-by: Cursor <cursoragent@cursor.com>
104 lines
3.3 KiB
TypeScript
104 lines
3.3 KiB
TypeScript
import { Router } from 'express'
|
|
import { isNtfyConfigured, sendFeedbackViaNtfy } from '../services/ntfyNotify.js'
|
|
import { requireUser } from '../middleware/auth.js'
|
|
import { analyzeFeedbackSpam, feedbackLimiter } from '../middleware/feedbackProtection.js'
|
|
|
|
const router = Router()
|
|
|
|
const VALID_CATEGORIES = new Set(['bug', 'feature', 'general'])
|
|
const MAX_MESSAGE_LENGTH = 2000
|
|
const MAX_EMAIL_LENGTH = 254
|
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
|
|
function parseOptionalEmail(value: unknown): string | undefined {
|
|
if (value === undefined || value === null || value === '') return undefined
|
|
if (typeof value !== 'string') return undefined
|
|
|
|
const trimmed = value.trim()
|
|
if (!trimmed) return undefined
|
|
if (trimmed.length > MAX_EMAIL_LENGTH) return undefined
|
|
if (!EMAIL_PATTERN.test(trimmed)) return undefined
|
|
|
|
return trimmed
|
|
}
|
|
|
|
router.get('/status', requireUser, (_req, res) => {
|
|
res.json({ enabled: isNtfyConfigured() })
|
|
})
|
|
|
|
router.post('/', requireUser, feedbackLimiter, async (req: any, res) => {
|
|
try {
|
|
if (!isNtfyConfigured()) {
|
|
return res.status(503).json({ error: 'Feedback is not configured on this server' })
|
|
}
|
|
|
|
const {
|
|
category,
|
|
message,
|
|
username,
|
|
contactEmail,
|
|
logbookId,
|
|
logbookTitle,
|
|
appVersion,
|
|
pageUrl,
|
|
website,
|
|
openedAt
|
|
} = req.body ?? {}
|
|
|
|
if (typeof category !== 'string' || !VALID_CATEGORIES.has(category)) {
|
|
return res.status(400).json({ error: 'Invalid category' })
|
|
}
|
|
|
|
if (typeof message !== 'string' || !message.trim()) {
|
|
return res.status(400).json({ error: 'Message is required' })
|
|
}
|
|
|
|
const trimmedMessage = message.trim()
|
|
if (trimmedMessage.length > MAX_MESSAGE_LENGTH) {
|
|
return res.status(400).json({ error: `Message must be at most ${MAX_MESSAGE_LENGTH} characters` })
|
|
}
|
|
|
|
let parsedContactEmail: string | undefined
|
|
if (contactEmail !== undefined && contactEmail !== null && String(contactEmail).trim()) {
|
|
parsedContactEmail = parseOptionalEmail(contactEmail)
|
|
if (!parsedContactEmail) {
|
|
return res.status(400).json({ error: 'Invalid email address' })
|
|
}
|
|
}
|
|
|
|
const spamVerdict = analyzeFeedbackSpam(req.userId, {
|
|
message: trimmedMessage,
|
|
website,
|
|
openedAt
|
|
})
|
|
if (spamVerdict === 'silent_reject') {
|
|
return res.json({ ok: true })
|
|
}
|
|
if (spamVerdict === 'reject') {
|
|
return res.status(400).json({
|
|
error: 'This feedback could not be sent. Please change your message and try again.',
|
|
code: 'SPAM_DETECTED'
|
|
})
|
|
}
|
|
|
|
await sendFeedbackViaNtfy({
|
|
category,
|
|
message: trimmedMessage,
|
|
username: typeof username === 'string' ? username.trim() : undefined,
|
|
contactEmail: parsedContactEmail,
|
|
userId: req.userId,
|
|
logbookId: typeof logbookId === 'string' ? logbookId.trim() : undefined,
|
|
logbookTitle: typeof logbookTitle === 'string' ? logbookTitle.trim() : undefined,
|
|
appVersion: typeof appVersion === 'string' ? appVersion.trim() : undefined,
|
|
pageUrl: typeof pageUrl === 'string' ? pageUrl.trim() : undefined
|
|
})
|
|
|
|
return res.json({ ok: true })
|
|
} catch (error: any) {
|
|
console.error('Error sending feedback via Ntfy:', error)
|
|
return res.status(502).json({ error: error.message || 'Failed to send feedback' })
|
|
}
|
|
})
|
|
|
|
export default router
|