import rateLimit, { ipKeyGenerator } from 'express-rate-limit' import type { AuthedRequest } from './auth.js' const MIN_SUBMIT_MS = 2_000 const MAX_SUBMIT_MS = 60 * 60 * 1000 const DUPLICATE_WINDOW_MS = 10 * 60 * 1000 const MAX_URLS = 8 const MAX_REPEATED_CHAR = 40 const recentByUser = new Map() function normalizeMessage(message: string): string { return message.trim().toLowerCase().replace(/\s+/g, ' ') } function countUrls(message: string): number { const matches = message.match(/https?:\/\/|www\./gi) return matches?.length ?? 0 } function hasExcessiveRepeatedChars(message: string): boolean { return /(.)\1{39,}/.test(message) } function pruneRecentEntries(now: number): void { for (const [userId, entry] of recentByUser) { if (now - entry.at > DUPLICATE_WINDOW_MS) { recentByUser.delete(userId) } } } export type FeedbackSpamVerdict = 'ok' | 'silent_reject' | 'reject' export function analyzeFeedbackSpam( userId: string, payload: { message: string; website?: unknown; openedAt?: unknown } ): FeedbackSpamVerdict { if (typeof payload.website === 'string' && payload.website.trim()) { return 'silent_reject' } if (typeof payload.openedAt === 'number' && Number.isFinite(payload.openedAt)) { const elapsed = Date.now() - payload.openedAt if (elapsed < MIN_SUBMIT_MS || elapsed > MAX_SUBMIT_MS) { return 'silent_reject' } } const normalized = normalizeMessage(payload.message) const now = Date.now() pruneRecentEntries(now) const previous = recentByUser.get(userId) if (previous && previous.hash === normalized && now - previous.at < DUPLICATE_WINDOW_MS) { return 'reject' } if (countUrls(payload.message) > MAX_URLS || hasExcessiveRepeatedChars(payload.message)) { return 'reject' } recentByUser.set(userId, { hash: normalized, at: now }) return 'ok' } export const feedbackLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5, standardHeaders: true, legacyHeaders: false, keyGenerator: (req) => { const authed = req as AuthedRequest if (authed.userId) return authed.userId return ipKeyGenerator(req.ip ?? 'unknown') }, handler: (_req, res) => { res.status(429).json({ error: 'Too many feedback submissions. Please try again later.', code: 'RATE_LIMITED' }) } })