112 lines
2.7 KiB
JavaScript
112 lines
2.7 KiB
JavaScript
const crypto = require("crypto");
|
|
const { sendError } = require("../utils/http");
|
|
|
|
function normalizeIp(req) {
|
|
return String(req.socket?.remoteAddress || req.ip || "unknown").trim();
|
|
}
|
|
|
|
function findUserIdentifier(req) {
|
|
const candidates = [
|
|
req.params?.username,
|
|
req.params?.gamemode,
|
|
req.body?.username,
|
|
req.body?.ingame_name,
|
|
req.query?.q,
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
if (candidate == null) {
|
|
continue;
|
|
}
|
|
|
|
const normalized = String(candidate).trim().toLowerCase();
|
|
if (normalized) {
|
|
return normalized.slice(0, 64);
|
|
}
|
|
}
|
|
|
|
return "anonymous";
|
|
}
|
|
|
|
function buildBucketKey(raw) {
|
|
return crypto.createHash("sha256").update(String(raw)).digest("hex");
|
|
}
|
|
|
|
function createRateLimiter({ name, windowMs, max, keyFn }) {
|
|
const buckets = new Map();
|
|
|
|
function cleanup(now) {
|
|
for (const [key, bucket] of buckets.entries()) {
|
|
if (bucket.resetAt <= now) {
|
|
buckets.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
return function rateLimitMiddleware(req, res, next) {
|
|
const now = Date.now();
|
|
const rawKey = keyFn(req);
|
|
const key = buildBucketKey(`${name}:${rawKey}`);
|
|
const current = buckets.get(key);
|
|
|
|
if (!current || current.resetAt <= now) {
|
|
buckets.set(key, { count: 1, resetAt: now + windowMs });
|
|
|
|
if (buckets.size > 5000) {
|
|
cleanup(now);
|
|
}
|
|
|
|
return next();
|
|
}
|
|
|
|
if (current.count >= max) {
|
|
const retryAfterSeconds = Math.max(Math.ceil((current.resetAt - now) / 1000), 1);
|
|
res.setHeader("Retry-After", String(retryAfterSeconds));
|
|
res.setHeader("X-RateLimit-Limit", String(max));
|
|
res.setHeader("X-RateLimit-Remaining", "0");
|
|
res.setHeader("X-RateLimit-Reset", String(Math.ceil(current.resetAt / 1000)));
|
|
|
|
return sendError(
|
|
res,
|
|
429,
|
|
"RATE_LIMITED",
|
|
"Too many requests. Please retry later.",
|
|
{
|
|
limiter: name,
|
|
retryAfterSeconds,
|
|
}
|
|
);
|
|
}
|
|
|
|
current.count += 1;
|
|
const remaining = Math.max(max - current.count, 0);
|
|
res.setHeader("X-RateLimit-Limit", String(max));
|
|
res.setHeader("X-RateLimit-Remaining", String(remaining));
|
|
res.setHeader("X-RateLimit-Reset", String(Math.ceil(current.resetAt / 1000)));
|
|
|
|
return next();
|
|
};
|
|
}
|
|
|
|
function createApiRateLimiters({ ipMax = 60, userMax = 60, windowMs = 60 * 1000 }) {
|
|
const byIp = createRateLimiter({
|
|
name: "ip",
|
|
windowMs,
|
|
max: ipMax,
|
|
keyFn: (req) => normalizeIp(req),
|
|
});
|
|
|
|
const byUser = createRateLimiter({
|
|
name: "user",
|
|
windowMs,
|
|
max: userMax,
|
|
keyFn: (req) => `${normalizeIp(req)}:${findUserIdentifier(req)}`,
|
|
});
|
|
|
|
return [byIp, byUser];
|
|
}
|
|
|
|
module.exports = {
|
|
createApiRateLimiters,
|
|
};
|