import * as crypto from 'crypto'; import * as dotenv from 'dotenv'; import {NachklangFeedbackDB} from './Feedback.db.js'; dotenv.config(); const RATE_LIMIT_MAX = parseInt(process.env.FEEDBACK_RATE_LIMIT_MAX || '5', 10); const RATE_LIMIT_WINDOW_MIN = parseInt(process.env.FEEDBACK_RATE_LIMIT_WINDOW_MIN || '10', 10); const RATE_LIMIT_WINDOW_MS = RATE_LIMIT_WINDOW_MIN * 60 * 1000; if (!process.env.FEEDBACK_IP_SALT) { // A missing salt would silently degrade hashIp() to unsalted SHA-256, // which is reversible for the whole IPv4 space in minutes - fail loudly // instead of persisting deanonymizable data. throw new Error('FEEDBACK_IP_SALT is required (see .env / CLAUDE.md environment block)'); } const IP_SALT = process.env.FEEDBACK_IP_SALT; /** * Salted hash of the client IP. Never store or log the raw address. */ export const hashIp = (ip: string): string => { return crypto.createHash('sha256').update(IP_SALT + ip).digest('hex'); }; // In-memory sliding window, keyed by ip hash. Resets on process restart — // acceptable, the DB backstop below covers that gap. const recentSubmissions = new Map(); const pruneOld = (timestamps: number[], now: number): number[] => { return timestamps.filter(t => now - t < RATE_LIMIT_WINDOW_MS); }; // Without this, isRateLimited() would store a Map entry for every distinct // ip hash it has ever seen - including empty arrays for one-off visitors - // and nothing would ever remove it, growing unbounded for the process // lifetime. Sweep periodically so hashes that stop submitting eventually // drop out even if isRateLimited() is never called for them again. const sweepInterval = setInterval(() => { const now = Date.now(); for (const [ipHash, timestamps] of recentSubmissions) { if (pruneOld(timestamps, now).length === 0) { recentSubmissions.delete(ipHash); } } }, RATE_LIMIT_WINDOW_MS); sweepInterval.unref(); /** * DB backstop for the case where the in-memory counter was reset by a * process restart. Only queried when the in-memory counter is already * near the limit, so the common path stays DB-free. */ const checkDbBackstop = async (ipHash: string): Promise => { let conn = await NachklangFeedbackDB.getConnection(); try { const query = 'SELECT COUNT(*) as cnt FROM submissions WHERE ip_hash = ? AND submitted_at > NOW() - INTERVAL ? MINUTE'; const rows = await conn.query(query, [ipHash, RATE_LIMIT_WINDOW_MIN]); return Number(rows[0].cnt); } finally { await conn.end(); } }; /** * Returns true if the given ip hash is currently allowed to submit. * Does not itself record the submission — call recordSubmission after a * successful insert. */ export const isRateLimited = async (ipHash: string): Promise => { const now = Date.now(); const timestamps = pruneOld(recentSubmissions.get(ipHash) || [], now); if (timestamps.length > 0) { recentSubmissions.set(ipHash, timestamps); } else { recentSubmissions.delete(ipHash); } if (timestamps.length >= RATE_LIMIT_MAX) { return true; } // Close to the limit in memory — fall back to the DB in case the // process restarted and lost earlier counts. if (timestamps.length >= RATE_LIMIT_MAX - 1) { const dbCount = await checkDbBackstop(ipHash); if (dbCount >= RATE_LIMIT_MAX) { return true; } } return false; }; export const recordSubmission = (ipHash: string): void => { const now = Date.now(); const timestamps = pruneOld(recentSubmissions.get(ipHash) || [], now); timestamps.push(now); recentSubmissions.set(ipHash, timestamps); };