- Adds UI links for joining with PIN in Login and Register pages. - Normalizes event slugs to lowercase (preserving hyphens) and event PINs to strip all hyphens/spaces to handle raw inputs. - Implements a pre-check rate limit pattern (`isRateLimited`) to safely enforce a max of 5 failed attempts per IP window (60s) without rate-limiting successful authentications. - Achieves NAT-safe idempotency in `POST /api/join` by extracting and reusing active event guest sessions instead of blindly incrementing claimed seats on every request. - Integrates complete test suite coverage for these new constraints. Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
109 lines
3.2 KiB
TypeScript
109 lines
3.2 KiB
TypeScript
import { valkey } from "./valkey.ts";
|
|
|
|
/**
|
|
* Implements a sliding window rate limiter backed by Valkey using Sorted Sets.
|
|
*
|
|
* @param key - The unique identifier for the limit (e.g. "rate:public:ip:192.168.1.1")
|
|
* @param limit - Maximum requests allowed in the window.
|
|
* @param windowMs - Size of the window in milliseconds.
|
|
* @returns boolean - true if allowed, false if limit exceeded.
|
|
*/
|
|
export let checkRateLimit = async function checkRateLimit(
|
|
key: string,
|
|
limit: number,
|
|
windowMs: number,
|
|
): Promise<boolean> {
|
|
const now = Date.now();
|
|
const windowStart = now - windowMs;
|
|
|
|
// Use a multi block to ensure atomicity
|
|
const multi = valkey.multi();
|
|
|
|
// 1. Remove all elements outside the current window
|
|
multi.zremrangebyscore(key, 0, windowStart);
|
|
|
|
// 2. Add the current request timestamp
|
|
// Using the timestamp itself as the member and score.
|
|
// To avoid collisions if multiple requests happen in the exact same millisecond,
|
|
// we could append a random string, but for simple sliding window,
|
|
// just the timestamp with a random suffix is safer.
|
|
const member = `${now}-${crypto.randomUUID()}`;
|
|
multi.zadd(key, now, member);
|
|
|
|
// 3. Count elements in the current window
|
|
multi.zcount(key, "-inf", "+inf");
|
|
|
|
// 4. Update the key's TTL to automatically clean up
|
|
multi.pexpire(key, windowMs);
|
|
|
|
try {
|
|
const results = await multi.exec();
|
|
if (!results) {
|
|
return false; // Fail closed
|
|
}
|
|
|
|
// The third command in multi is zcount
|
|
const countResult = results[2];
|
|
if (countResult[0]) {
|
|
// If there was an error executing zcount
|
|
throw countResult[0];
|
|
}
|
|
|
|
const currentCount = countResult[1] as number;
|
|
return currentCount <= limit;
|
|
} catch (error) {
|
|
console.error("[RateLimit] Error executing multi block:", error);
|
|
return false; // Fail closed if Valkey throws an error
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Checks if a sliding window rate limit is exceeded without incrementing.
|
|
*
|
|
* @param key - The unique identifier for the limit.
|
|
* @param limit - Maximum requests allowed in the window.
|
|
* @param windowMs - Size of the window in milliseconds.
|
|
* @returns boolean - true if limit is exceeded (rate limited), false if allowed.
|
|
*/
|
|
export let isRateLimited = async function isRateLimited(
|
|
key: string,
|
|
limit: number,
|
|
windowMs: number,
|
|
): Promise<boolean> {
|
|
const now = Date.now();
|
|
const windowStart = now - windowMs;
|
|
|
|
const multi = valkey.multi();
|
|
multi.zremrangebyscore(key, 0, windowStart);
|
|
multi.zcount(key, "-inf", "+inf");
|
|
|
|
try {
|
|
const results = await multi.exec();
|
|
if (!results) return false; // fail open or closed depending on preference, returning false here allows it
|
|
|
|
const countResult = results[1];
|
|
if (countResult[0]) throw countResult[0];
|
|
|
|
const currentCount = countResult[1] as number;
|
|
return currentCount >= limit;
|
|
} catch (error) {
|
|
console.error("[RateLimit] Error checking isRateLimited:", error);
|
|
return false; // fail open or closed, returning false here
|
|
}
|
|
};
|
|
|
|
export const rateLimitWrapper = {
|
|
get checkRateLimit() {
|
|
return checkRateLimit;
|
|
},
|
|
set checkRateLimit(val: any) {
|
|
checkRateLimit = val;
|
|
},
|
|
get isRateLimited() {
|
|
return isRateLimited;
|
|
},
|
|
set isRateLimited(val: any) {
|
|
isRateLimited = val;
|
|
},
|
|
};
|