Files
kapteins-daagbok/server/src/routes/feedback.ts
T
elpatron d98e2e8dc0 feat(feedback): Rate-Limit und Spam-Erkennung für Feedback-Formular
Schützt den Feedback-Endpunkt vor Missbrauch durch pro-Nutzer-Limits, Honeypot, Zeitprüfung und einfache Inhaltsheuristiken.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 14:09:43 +02:00

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