Add Feedback domain module: public submission flow, admin CRUD, reporting (#7)
Jenkins Production Deployment
Jenkins Production Deployment
Co-authored-by: Patrick Müller <mail@pmueller.me> Reviewed-on: #7 Co-authored-by: Patrick Mueller <patrick@mueller-patrick.tech> Co-committed-by: Patrick Mueller <patrick@mueller-patrick.tech>
This commit was merged in pull request #7.
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
import * as crypto from 'crypto';
|
||||
import * as dotenv from 'dotenv';
|
||||
import {NachklangFeedbackDB} from './Feedback.db';
|
||||
|
||||
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);
|
||||
};
|
||||
Reference in New Issue
Block a user