Files
API/src/models/feedback/feedback.ratelimit.ts
T
Paddy 3ea9e630ed Migrate the API to native ESM and vitest; pin Node 26
Prep PR for the admin auth module (docs/plan-admin-auth.md step 1).
better-auth 1.7 ships ESM only, so the API moves off CommonJS:

- "type": "module", module nodenext, target ES2024, .js suffixes on all
  relative imports, require('mariadb'|'cors') replaced by imports, and
  export= packages (winston, app-root-path, bcrypt) consumed via default
  imports. The logger now uses appRoot.path explicitly.
- TypeScript 5.9, @types/node 26, tslint removed. Node 26 pinned via
  engines and .nvmrc (Plesk runs 26).
- Jest 28 + ts-jest replaced by vitest 5. Eight test files depend on
  hoisted module mocks with static imports and resetModules + require,
  which Jest's ESM mode does not support; vitest keeps them nearly
  verbatim. Coverage via @vitest/coverage-v8 (lcov), Sonar generic report
  via vitest-sonar-reporter, so sonar-project.properties is unchanged.
  vitest.config.ts sets FEEDBACK_IP_SALT so the suite passes without a
  local .env.
- dotenv 8 -> 16 and axios 0.24 -> 1.x: their old typings are not
  resolvable under nodenext.
- autoCommit: false dropped from the pool configs; it is not a mariadb
  connector option and was silently ignored.

tsc clean, 96/96 tests green, compiled app boots and serves /, /docs and
CORS under Node ESM.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 16:02:26 +02:00

101 lines
3.5 KiB
TypeScript

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<string, number[]>();
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<number> => {
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<boolean> => {
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);
};