0edf4a789c
Extract Express app factory for testability, add Vitest/Supertest API smoke tests, root npm run check script, and deployment docs. Fix express-rate-limit IPv6 keyGenerator for feedback limiter. Co-authored-by: Cursor <cursoragent@cursor.com>
84 lines
2.3 KiB
TypeScript
84 lines
2.3 KiB
TypeScript
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<string, { hash: string; at: number }>()
|
|
|
|
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'
|
|
})
|
|
}
|
|
})
|