3ea9e630ed
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>
101 lines
3.5 KiB
TypeScript
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);
|
|
};
|