97 lines
3.5 KiB
JavaScript
97 lines
3.5 KiB
JavaScript
const DEFAULT_RATE_LIMITS = Object.freeze({
|
|
roles: {
|
|
admin: { requests: 30, window_seconds: 60 },
|
|
mod: { requests: 12, window_seconds: 60 },
|
|
user: { requests: 4, window_seconds: 60 }
|
|
},
|
|
platforms: {
|
|
webui: { requests: 60, window_seconds: 60 },
|
|
discord: { requests: 30, window_seconds: 60 },
|
|
twitch: { requests: 20, window_seconds: 60 },
|
|
youtube: { requests: 20, window_seconds: 60 },
|
|
kick: { requests: 20, window_seconds: 60 },
|
|
other: { requests: 20, window_seconds: 60 }
|
|
},
|
|
per_user: { requests: 2, window_seconds: 30 },
|
|
per_channel: { requests: 12, window_seconds: 60 },
|
|
queue_when_limited: false
|
|
});
|
|
|
|
class AiRateLimiter {
|
|
constructor(getConfig, recordAudit = () => {}) {
|
|
this.getConfig = getConfig;
|
|
this.recordAudit = recordAudit;
|
|
this.buckets = new Map();
|
|
this.denials = [];
|
|
}
|
|
|
|
check(context) {
|
|
const config = mergeLimits(this.getConfig()?.rate_limits);
|
|
const platform = context.platform || context.origin || "webui";
|
|
const checks = [
|
|
[`role:${context.role}`, config.roles[context.role] || config.roles.user],
|
|
[`platform:${platform}`, config.platforms[platform] || config.platforms.other],
|
|
[`user:${context.user_id}`, config.per_user]
|
|
];
|
|
const channelKey = context.server_id || context.channel_id;
|
|
if (channelKey) checks.push([`channel:${platform}:${channelKey}`, config.per_channel]);
|
|
const now = Date.now();
|
|
const prepared = [];
|
|
for (const [key, limit] of checks) {
|
|
const result = this.inspect(key, limit, now);
|
|
if (!result.allowed) {
|
|
const denial = {
|
|
at: new Date(now).toISOString(),
|
|
reason: "rate_limited",
|
|
bucket: key,
|
|
retry_after_seconds: result.retry_after_seconds,
|
|
user_id: context.user_id,
|
|
platform,
|
|
channel_id: context.channel_id || null
|
|
};
|
|
this.denials.unshift(denial);
|
|
this.denials = this.denials.slice(0, 100);
|
|
this.recordAudit({ kind: "access", status: "denied", denial_reason: "rate_limited", ...denial });
|
|
return { ...result, reason: "rate_limited", bucket: key };
|
|
}
|
|
prepared.push(result);
|
|
}
|
|
for (const result of prepared) this.commit(result, now);
|
|
return { allowed: true };
|
|
}
|
|
|
|
recentDenials(limit = 25) {
|
|
return this.denials.slice(0, limit);
|
|
}
|
|
|
|
inspect(key, limit, now) {
|
|
const requests = Math.max(0, Number(limit?.requests) || 0);
|
|
const windowMs = Math.max(1, Number(limit?.window_seconds) || 1) * 1000;
|
|
const current = (this.buckets.get(key) || []).filter((timestamp) => now - timestamp < windowMs);
|
|
if (!requests) return { allowed: true, key, current, disabled: true };
|
|
if (current.length >= requests) {
|
|
const retry = Math.max(1, Math.ceil((windowMs - (now - current[0])) / 1000));
|
|
return { allowed: false, retry_after_seconds: retry, key, current };
|
|
}
|
|
return { allowed: true, key, current };
|
|
}
|
|
|
|
commit(result, now) {
|
|
if (result.disabled) return;
|
|
this.buckets.set(result.key, [...result.current, now]);
|
|
}
|
|
}
|
|
|
|
function mergeLimits(value = {}) {
|
|
return {
|
|
...DEFAULT_RATE_LIMITS,
|
|
...value,
|
|
roles: { ...DEFAULT_RATE_LIMITS.roles, ...(value.roles || {}) },
|
|
platforms: { ...DEFAULT_RATE_LIMITS.platforms, ...(value.platforms || {}) },
|
|
per_user: { ...DEFAULT_RATE_LIMITS.per_user, ...(value.per_user || {}) },
|
|
per_channel: { ...DEFAULT_RATE_LIMITS.per_channel, ...(value.per_channel || {}) }
|
|
};
|
|
}
|
|
|
|
module.exports = { DEFAULT_RATE_LIMITS, AiRateLimiter, mergeLimits };
|