enerthya.dev-antiflood 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +145 -0
- package/package.json +12 -0
- package/src/constants/index.js +25 -0
- package/src/core/AntifloodManager.js +185 -0
- package/src/core/AntifloodRule.js +39 -0
- package/src/core/BucketStore.js +110 -0
- package/src/index.js +28 -0
- package/src/utils/index.js +28 -0
- package/test.js +371 -0
package/README.md
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# enerthya.dev-antiflood
|
|
2
|
+
|
|
3
|
+
Rate limiter avançado para o bot Enerthya.
|
|
4
|
+
|
|
5
|
+
Keyed por `commandName + userId + guildId`, com **burst window deslizante**, **penalidade progressiva** (aditiva ou exponencial) e **whitelist de cargos**.
|
|
6
|
+
|
|
7
|
+
Depende de `enerthya.dev-common` para formatação de tempo — não reimplementa o que já existe.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Instalação
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install enerthya.dev-antiflood
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Uso básico
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
const { AntifloodManager, isBlocked, formatRetryAfter } = require("enerthya.dev-antiflood");
|
|
23
|
+
|
|
24
|
+
const antiflood = new AntifloodManager({
|
|
25
|
+
globalRule: {
|
|
26
|
+
windowMs: 5000, // janela deslizante de 5s
|
|
27
|
+
maxHits: 3, // máximo de 3 usos nessa janela
|
|
28
|
+
penaltyMode: "ADDITIVE",
|
|
29
|
+
penaltyStep: 10000, // +10s de penalidade por hit excedente
|
|
30
|
+
maxPenalty: 60000, // teto de 60s de penalidade
|
|
31
|
+
},
|
|
32
|
+
whitelistRoleIds: ["ID_CARGO_ADMIN"],
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Dentro do handler de comando:
|
|
36
|
+
const result = antiflood.check({
|
|
37
|
+
userId: interaction.user.id,
|
|
38
|
+
guildId: interaction.guildId,
|
|
39
|
+
commandName: "ban",
|
|
40
|
+
memberRoleIds: interaction.member.roles.cache.map(r => r.id),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (isBlocked(result)) {
|
|
44
|
+
return interaction.reply({
|
|
45
|
+
content: `Você está usando este comando rápido demais. Aguarde ${formatRetryAfter(result.retryAfterMs)}.`,
|
|
46
|
+
ephemeral: true,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## API
|
|
54
|
+
|
|
55
|
+
### `new AntifloodManager(options?)`
|
|
56
|
+
|
|
57
|
+
| Opção | Tipo | Padrão | Descrição |
|
|
58
|
+
|---|---|---|---|
|
|
59
|
+
| `globalRule` | `object` | veja DEFAULT_RULE | Regra aplicada quando não há regra por comando |
|
|
60
|
+
| `whitelistRoleIds` | `string[]` | `[]` | Cargos que sempre passam sem checagem |
|
|
61
|
+
| `enabled` | `boolean` | `true` | Master switch |
|
|
62
|
+
|
|
63
|
+
#### `.setRule(commandName, ruleConfig)` → `this`
|
|
64
|
+
Registra uma regra específica para um comando. Tem prioridade sobre a global.
|
|
65
|
+
|
|
66
|
+
#### `.addWhitelist(...roleIds)` → `this`
|
|
67
|
+
Adiciona cargos à whitelist em runtime.
|
|
68
|
+
|
|
69
|
+
#### `.removeWhitelist(roleId)` → `this`
|
|
70
|
+
Remove um cargo da whitelist.
|
|
71
|
+
|
|
72
|
+
#### `.check({ userId, guildId?, commandName?, memberRoleIds? })`
|
|
73
|
+
Retorna:
|
|
74
|
+
```js
|
|
75
|
+
{
|
|
76
|
+
result: "ALLOWED" | "THROTTLED" | "PENALIZED" | "WHITELISTED",
|
|
77
|
+
retryAfterMs: number, // ms até poder tentar de novo (0 se liberado)
|
|
78
|
+
hitsInWindow: number, // hits acumulados na janela atual
|
|
79
|
+
penaltyUntil: number, // timestamp fim da penalidade (0 se não há)
|
|
80
|
+
rule: object, // regra que foi aplicada
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
#### `.reset({ userId, guildId?, commandName? })`
|
|
85
|
+
Desbloqueia manualmente um usuário para um comando+guild.
|
|
86
|
+
|
|
87
|
+
#### `.resetAll()`
|
|
88
|
+
Limpa todo o estado (ex: restart do bot).
|
|
89
|
+
|
|
90
|
+
#### `.disable()` / `.enable()`
|
|
91
|
+
Liga/desliga o rate limiter globalmente.
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
### `createRule(config?)`
|
|
96
|
+
Valida e retorna um objeto de regra frozen. Útil para criar regras de forma isolada.
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
const { createRule, PENALTY_MODE } = require("enerthya.dev-antiflood");
|
|
100
|
+
|
|
101
|
+
const strictRule = createRule({
|
|
102
|
+
windowMs: 3000,
|
|
103
|
+
maxHits: 1,
|
|
104
|
+
penaltyMode: PENALTY_MODE.EXPONENTIAL,
|
|
105
|
+
penaltyStep: 5000,
|
|
106
|
+
maxPenalty: 120000,
|
|
107
|
+
});
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
### `FLOOD_RESULT` (constantes)
|
|
113
|
+
| Valor | Descrição |
|
|
114
|
+
|---|---|
|
|
115
|
+
| `ALLOWED` | Passou normalmente |
|
|
116
|
+
| `THROTTLED` | Bloqueado até janela deslizar (sem penalidade progressiva) |
|
|
117
|
+
| `PENALIZED` | Bloqueado com penalidade progressiva ativa |
|
|
118
|
+
| `WHITELISTED` | Bypassed por cargo na whitelist |
|
|
119
|
+
|
|
120
|
+
### `PENALTY_MODE` (constantes)
|
|
121
|
+
| Valor | Comportamento |
|
|
122
|
+
|---|---|
|
|
123
|
+
| `NONE` | Bloqueia até a janela deslizar — sem penalidade extra |
|
|
124
|
+
| `ADDITIVE` | Cada hit excedente adiciona `penaltyStep` ms à trava |
|
|
125
|
+
| `EXPONENTIAL` | Cada hit excedente dobra a duração da trava (`2^n * penaltyStep`) |
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
### `formatRetryAfter(ms)` → `string`
|
|
130
|
+
Converte ms em texto PT-BR legível. Delega para `enerthya.dev-common`.
|
|
131
|
+
```
|
|
132
|
+
formatRetryAfter(1500) // "1 segundo"
|
|
133
|
+
formatRetryAfter(65000) // "1 minuto, 5 segundos"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### `isBlocked(checkResult)` → `boolean`
|
|
137
|
+
`true` se `result` é `THROTTLED` ou `PENALIZED`.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Dependências
|
|
142
|
+
|
|
143
|
+
| Pacote | Uso |
|
|
144
|
+
|---|---|
|
|
145
|
+
| `enerthya.dev-common` | `formatTime` para formatar retryAfterMs em PT-BR |
|
package/package.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "enerthya.dev-antiflood",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Advanced per-user+command+guild rate limiter with burst window, progressive penalty and role whitelist.",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": ["ratelimit", "antiflood", "discord", "bot", "cooldown"],
|
|
8
|
+
"engines": { "node": ">=16.0.0" },
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"enerthya.dev-common": "1.0.0"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Antiflood — constants
|
|
2
|
+
|
|
3
|
+
const FLOOD_RESULT = Object.freeze({
|
|
4
|
+
ALLOWED: "ALLOWED", // request passed
|
|
5
|
+
THROTTLED: "THROTTLED", // rate limit exceeded — caller should reject
|
|
6
|
+
PENALIZED: "PENALIZED", // rate limit exceeded + progressive penalty applied
|
|
7
|
+
WHITELISTED: "WHITELISTED", // bypassed due to whitelist
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const PENALTY_MODE = Object.freeze({
|
|
11
|
+
NONE: "NONE", // no progressive penalty — just block until window expires
|
|
12
|
+
ADDITIVE: "ADDITIVE", // each excess hit adds penaltyStep ms to the lock
|
|
13
|
+
EXPONENTIAL: "EXPONENTIAL", // each excess hit doubles the lock duration
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// Default rule applied when no custom config is provided
|
|
17
|
+
const DEFAULT_RULE = Object.freeze({
|
|
18
|
+
windowMs: 5000, // sliding window size in ms
|
|
19
|
+
maxHits: 3, // allowed hits per window
|
|
20
|
+
penaltyMode: PENALTY_MODE.ADDITIVE,
|
|
21
|
+
penaltyStep: 5000, // ms added per excess hit (ADDITIVE) or base for EXPONENTIAL
|
|
22
|
+
maxPenalty: 60000, // hard cap on penalty duration (ms)
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
module.exports = { FLOOD_RESULT, PENALTY_MODE, DEFAULT_RULE };
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// AntifloodManager — central rate limiter.
|
|
2
|
+
// Keyed by: commandName + userId + guildId (any field can be wildcarded with "*")
|
|
3
|
+
// Whitelisted roleIds bypass all checks.
|
|
4
|
+
|
|
5
|
+
const { BucketStore } = require("./BucketStore");
|
|
6
|
+
const { createRule } = require("./AntifloodRule");
|
|
7
|
+
const { FLOOD_RESULT, PENALTY_MODE, DEFAULT_RULE } = require("../constants");
|
|
8
|
+
|
|
9
|
+
class AntifloodManager {
|
|
10
|
+
/**
|
|
11
|
+
* @param {object} [options]
|
|
12
|
+
* @param {object} [options.globalRule] — default rule applied when no command-level rule matches
|
|
13
|
+
* @param {Set<string>|string[]} [options.whitelistRoleIds] — roleIds that always bypass flood checks
|
|
14
|
+
* @param {boolean} [options.enabled] — master switch (default true)
|
|
15
|
+
*/
|
|
16
|
+
constructor(options = {}) {
|
|
17
|
+
this._globalRule = createRule(options.globalRule || {});
|
|
18
|
+
this._commandRules = new Map(); // commandName → rule
|
|
19
|
+
this._store = new BucketStore();
|
|
20
|
+
this._whitelist = new Set(options.whitelistRoleIds || []);
|
|
21
|
+
this._enabled = options.enabled !== false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ── Configuration ─────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Register a per-command rule. Overrides the global rule for that command.
|
|
28
|
+
* @param {string} commandName
|
|
29
|
+
* @param {object} ruleConfig
|
|
30
|
+
*/
|
|
31
|
+
setRule(commandName, ruleConfig) {
|
|
32
|
+
this._commandRules.set(commandName, createRule(ruleConfig));
|
|
33
|
+
return this;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Add one or more roleIds to the whitelist.
|
|
38
|
+
* @param {...string} roleIds
|
|
39
|
+
*/
|
|
40
|
+
addWhitelist(...roleIds) {
|
|
41
|
+
for (const id of roleIds) this._whitelist.add(String(id));
|
|
42
|
+
return this;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Remove a roleId from the whitelist.
|
|
47
|
+
* @param {string} roleId
|
|
48
|
+
*/
|
|
49
|
+
removeWhitelist(roleId) {
|
|
50
|
+
this._whitelist.delete(String(roleId));
|
|
51
|
+
return this;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Disable all flood checks (e.g. during maintenance). */
|
|
55
|
+
disable() { this._enabled = false; return this; }
|
|
56
|
+
|
|
57
|
+
/** Re-enable flood checks. */
|
|
58
|
+
enable() { this._enabled = true; return this; }
|
|
59
|
+
|
|
60
|
+
// ── Check ─────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Check if the given request should be allowed or throttled.
|
|
64
|
+
*
|
|
65
|
+
* @param {object} params
|
|
66
|
+
* @param {string} params.userId
|
|
67
|
+
* @param {string} [params.guildId] — optional; included in key for guild isolation
|
|
68
|
+
* @param {string} [params.commandName] — optional; used to look up per-command rule
|
|
69
|
+
* @param {string[]} [params.memberRoleIds] — user's current roles, checked against whitelist
|
|
70
|
+
*
|
|
71
|
+
* @returns {{
|
|
72
|
+
* result: string, // FLOOD_RESULT value
|
|
73
|
+
* retryAfterMs: number, // ms until the user can try again (0 if ALLOWED/WHITELISTED)
|
|
74
|
+
* hitsInWindow: number, // how many hits recorded in current window
|
|
75
|
+
* penaltyUntil: number, // timestamp when penalty expires (0 if none)
|
|
76
|
+
* rule: object // the rule that was applied
|
|
77
|
+
* }}
|
|
78
|
+
*/
|
|
79
|
+
check({ userId, guildId = "*", commandName = "*", memberRoleIds = [] }) {
|
|
80
|
+
// Master switch
|
|
81
|
+
if (!this._enabled) {
|
|
82
|
+
return this._buildResult(FLOOD_RESULT.ALLOWED, 0, 0, 0, this._globalRule);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Whitelist check — any matching roleId bypasses everything
|
|
86
|
+
for (const rid of memberRoleIds) {
|
|
87
|
+
if (this._whitelist.has(String(rid))) {
|
|
88
|
+
return this._buildResult(FLOOD_RESULT.WHITELISTED, 0, 0, 0, this._globalRule);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const rule = this._commandRules.get(commandName) || this._globalRule;
|
|
93
|
+
const key = this._buildKey(commandName, userId, guildId);
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
|
|
96
|
+
// Try to reset expired penalty first
|
|
97
|
+
this._store.tryResetPenalty(key, now);
|
|
98
|
+
|
|
99
|
+
// Prune stale hits from the sliding window
|
|
100
|
+
this._store.prune(key, rule.windowMs, now);
|
|
101
|
+
|
|
102
|
+
const bucket = this._store.get(key);
|
|
103
|
+
|
|
104
|
+
// ── Active penalty lock ──────────────────────────────────────────────────
|
|
105
|
+
if (bucket.penaltyUntil > now) {
|
|
106
|
+
return this._buildResult(
|
|
107
|
+
FLOOD_RESULT.PENALIZED,
|
|
108
|
+
bucket.penaltyUntil - now,
|
|
109
|
+
bucket.hits.length,
|
|
110
|
+
bucket.penaltyUntil,
|
|
111
|
+
rule,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Hit is within quota ──────────────────────────────────────────────────
|
|
116
|
+
if (bucket.hits.length < rule.maxHits) {
|
|
117
|
+
this._store.addHit(key, now);
|
|
118
|
+
return this._buildResult(FLOOD_RESULT.ALLOWED, 0, bucket.hits.length, 0, rule);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Quota exceeded ───────────────────────────────────────────────────────
|
|
122
|
+
if (rule.penaltyMode === PENALTY_MODE.NONE) {
|
|
123
|
+
// No progressive penalty — just block until the window slides
|
|
124
|
+
const oldestHit = bucket.hits[0];
|
|
125
|
+
const retryAfterMs = rule.windowMs - (now - oldestHit);
|
|
126
|
+
return this._buildResult(
|
|
127
|
+
FLOOD_RESULT.THROTTLED,
|
|
128
|
+
Math.max(retryAfterMs, 0),
|
|
129
|
+
bucket.hits.length,
|
|
130
|
+
0,
|
|
131
|
+
rule,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Progressive penalty
|
|
136
|
+
this._store.addHit(key, now);
|
|
137
|
+
const penaltyUntil = this._store.applyPenalty(
|
|
138
|
+
key,
|
|
139
|
+
rule.penaltyMode,
|
|
140
|
+
rule.penaltyStep,
|
|
141
|
+
rule.maxPenalty,
|
|
142
|
+
now,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
return this._buildResult(
|
|
146
|
+
FLOOD_RESULT.PENALIZED,
|
|
147
|
+
penaltyUntil - now,
|
|
148
|
+
bucket.hits.length,
|
|
149
|
+
penaltyUntil,
|
|
150
|
+
rule,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Manual controls ───────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Manually reset a user's flood state for a given command + guild.
|
|
158
|
+
* Use this when a DEV/OWNER wants to unblock someone immediately.
|
|
159
|
+
*/
|
|
160
|
+
reset({ userId, guildId = "*", commandName = "*" }) {
|
|
161
|
+
this._store.reset(this._buildKey(commandName, userId, guildId));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Reset all flood state (e.g. on bot restart). */
|
|
165
|
+
resetAll() {
|
|
166
|
+
this._store.clear();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** @returns {number} number of active tracked buckets. */
|
|
170
|
+
get activeBuckets() {
|
|
171
|
+
return this._store.size;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Internals ─────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
_buildKey(commandName, userId, guildId) {
|
|
177
|
+
return `${commandName}|${userId}|${guildId}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
_buildResult(result, retryAfterMs, hitsInWindow, penaltyUntil, rule) {
|
|
181
|
+
return { result, retryAfterMs: Math.ceil(retryAfterMs), hitsInWindow, penaltyUntil, rule };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = { AntifloodManager };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// AntifloodRule — merges user-supplied config with defaults and validates it.
|
|
2
|
+
|
|
3
|
+
const { DEFAULT_RULE, PENALTY_MODE } = require("../constants");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Build a validated rule object from user-supplied partial config.
|
|
7
|
+
* All fields are optional — defaults are applied for missing ones.
|
|
8
|
+
*
|
|
9
|
+
* @param {object} [config]
|
|
10
|
+
* @param {number} [config.windowMs]
|
|
11
|
+
* @param {number} [config.maxHits]
|
|
12
|
+
* @param {string} [config.penaltyMode]
|
|
13
|
+
* @param {number} [config.penaltyStep]
|
|
14
|
+
* @param {number} [config.maxPenalty]
|
|
15
|
+
* @returns {{ windowMs: number, maxHits: number, penaltyMode: string, penaltyStep: number, maxPenalty: number }}
|
|
16
|
+
*/
|
|
17
|
+
function createRule(config = {}) {
|
|
18
|
+
const rule = Object.assign({}, DEFAULT_RULE, config);
|
|
19
|
+
|
|
20
|
+
if (typeof rule.windowMs !== "number" || rule.windowMs <= 0) {
|
|
21
|
+
throw new Error(`[antiflood] windowMs must be a positive number, got: ${rule.windowMs}`);
|
|
22
|
+
}
|
|
23
|
+
if (typeof rule.maxHits !== "number" || rule.maxHits < 1) {
|
|
24
|
+
throw new Error(`[antiflood] maxHits must be >= 1, got: ${rule.maxHits}`);
|
|
25
|
+
}
|
|
26
|
+
if (!Object.values(PENALTY_MODE).includes(rule.penaltyMode)) {
|
|
27
|
+
throw new Error(`[antiflood] penaltyMode must be one of ${Object.values(PENALTY_MODE).join(", ")}, got: ${rule.penaltyMode}`);
|
|
28
|
+
}
|
|
29
|
+
if (typeof rule.penaltyStep !== "number" || rule.penaltyStep < 0) {
|
|
30
|
+
throw new Error(`[antiflood] penaltyStep must be >= 0, got: ${rule.penaltyStep}`);
|
|
31
|
+
}
|
|
32
|
+
if (typeof rule.maxPenalty !== "number" || rule.maxPenalty < 0) {
|
|
33
|
+
throw new Error(`[antiflood] maxPenalty must be >= 0, got: ${rule.maxPenalty}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return Object.freeze(rule);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { createRule };
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// BucketStore — stores sliding-window hit timestamps + penalty state per key.
|
|
2
|
+
// Each bucket: { hits: number[], penaltyUntil: number, excessCount: number }
|
|
3
|
+
|
|
4
|
+
class BucketStore {
|
|
5
|
+
constructor() {
|
|
6
|
+
/** @type {Map<string, { hits: number[], penaltyUntil: number, excessCount: number }>} */
|
|
7
|
+
this._store = new Map();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Get or create a bucket for the given key.
|
|
12
|
+
* @param {string} key
|
|
13
|
+
*/
|
|
14
|
+
get(key) {
|
|
15
|
+
if (!this._store.has(key)) {
|
|
16
|
+
this._store.set(key, { hits: [], penaltyUntil: 0, excessCount: 0 });
|
|
17
|
+
}
|
|
18
|
+
return this._store.get(key);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Prune hits outside the sliding window and remove stale buckets.
|
|
23
|
+
* Should be called before every check to keep memory bounded.
|
|
24
|
+
* @param {string} key
|
|
25
|
+
* @param {number} windowMs
|
|
26
|
+
* @param {number} now
|
|
27
|
+
*/
|
|
28
|
+
prune(key, windowMs, now) {
|
|
29
|
+
const bucket = this._store.get(key);
|
|
30
|
+
if (!bucket) return;
|
|
31
|
+
|
|
32
|
+
bucket.hits = bucket.hits.filter(ts => now - ts < windowMs);
|
|
33
|
+
|
|
34
|
+
// Remove bucket entirely if it has no hits and no active penalty
|
|
35
|
+
if (bucket.hits.length === 0 && bucket.penaltyUntil <= now) {
|
|
36
|
+
this._store.delete(key);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Add a hit timestamp to the bucket.
|
|
42
|
+
* @param {string} key
|
|
43
|
+
* @param {number} now
|
|
44
|
+
*/
|
|
45
|
+
addHit(key, now) {
|
|
46
|
+
const bucket = this.get(key);
|
|
47
|
+
bucket.hits.push(now);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Apply progressive penalty based on excess hits.
|
|
52
|
+
* @param {string} key
|
|
53
|
+
* @param {"ADDITIVE"|"EXPONENTIAL"} mode
|
|
54
|
+
* @param {number} step — ms per excess hit (ADDITIVE) or base ms (EXPONENTIAL)
|
|
55
|
+
* @param {number} max — hard cap in ms
|
|
56
|
+
* @param {number} now
|
|
57
|
+
* @returns {number} penaltyUntil timestamp
|
|
58
|
+
*/
|
|
59
|
+
applyPenalty(key, mode, step, max, now) {
|
|
60
|
+
const bucket = this.get(key);
|
|
61
|
+
bucket.excessCount += 1;
|
|
62
|
+
|
|
63
|
+
let addedMs;
|
|
64
|
+
if (mode === "EXPONENTIAL") {
|
|
65
|
+
// 2^(excessCount-1) * step, capped at max
|
|
66
|
+
addedMs = Math.min(Math.pow(2, bucket.excessCount - 1) * step, max);
|
|
67
|
+
} else {
|
|
68
|
+
// ADDITIVE: excessCount * step, capped at max
|
|
69
|
+
addedMs = Math.min(bucket.excessCount * step, max);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Penalty stacks from current time, not from previous penaltyUntil
|
|
73
|
+
bucket.penaltyUntil = now + addedMs;
|
|
74
|
+
return bucket.penaltyUntil;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Reset excess counter after the penalty expires.
|
|
79
|
+
* @param {string} key
|
|
80
|
+
* @param {number} now
|
|
81
|
+
*/
|
|
82
|
+
tryResetPenalty(key, now) {
|
|
83
|
+
const bucket = this._store.get(key);
|
|
84
|
+
if (!bucket) return;
|
|
85
|
+
if (bucket.penaltyUntil > 0 && bucket.penaltyUntil <= now) {
|
|
86
|
+
bucket.excessCount = 0;
|
|
87
|
+
bucket.penaltyUntil = 0;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Fully reset a key (e.g. when a DEV clears a user's flood state).
|
|
93
|
+
* @param {string} key
|
|
94
|
+
*/
|
|
95
|
+
reset(key) {
|
|
96
|
+
this._store.delete(key);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** @returns {number} total tracked buckets */
|
|
100
|
+
get size() {
|
|
101
|
+
return this._store.size;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Clear all buckets (for tests / restart). */
|
|
105
|
+
clear() {
|
|
106
|
+
this._store.clear();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { BucketStore };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// enerthya.dev-antiflood
|
|
2
|
+
// Advanced per-user+command+guild rate limiter with burst window,
|
|
3
|
+
// progressive penalty (additive or exponential) and role whitelist.
|
|
4
|
+
// Zero dependencies. Node.js only.
|
|
5
|
+
|
|
6
|
+
const { AntifloodManager } = require("./core/AntifloodManager");
|
|
7
|
+
const { BucketStore } = require("./core/BucketStore");
|
|
8
|
+
const { createRule } = require("./core/AntifloodRule");
|
|
9
|
+
const { FLOOD_RESULT, PENALTY_MODE, DEFAULT_RULE } = require("./constants");
|
|
10
|
+
const { formatRetryAfter, isBlocked } = require("./utils");
|
|
11
|
+
|
|
12
|
+
module.exports = {
|
|
13
|
+
// Main class
|
|
14
|
+
AntifloodManager,
|
|
15
|
+
|
|
16
|
+
// Building blocks (for advanced usage / testing)
|
|
17
|
+
BucketStore,
|
|
18
|
+
createRule,
|
|
19
|
+
|
|
20
|
+
// Constants
|
|
21
|
+
FLOOD_RESULT,
|
|
22
|
+
PENALTY_MODE,
|
|
23
|
+
DEFAULT_RULE,
|
|
24
|
+
|
|
25
|
+
// Utilities
|
|
26
|
+
formatRetryAfter,
|
|
27
|
+
isBlocked,
|
|
28
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Utility helpers for antiflood consumers.
|
|
2
|
+
|
|
3
|
+
// formatTime is already implemented (and well-tested) in enerthya.dev-common.
|
|
4
|
+
// We import from there instead of reimplementing — per rule P16a.
|
|
5
|
+
const { formatTime } = require("enerthya.dev-common");
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Format retryAfterMs into a human-readable Portuguese string.
|
|
9
|
+
* Delegates to enerthya.dev-common's formatTime (seconds → PT-BR).
|
|
10
|
+
* e.g. 1500 → "1 segundo" | 65000 → "1 minuto, 5 segundos"
|
|
11
|
+
*
|
|
12
|
+
* @param {number} ms
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
function formatRetryAfter(ms) {
|
|
16
|
+
return formatTime(Math.ceil(ms / 1000));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Return true when the check result means the request should be blocked.
|
|
21
|
+
* @param {{ result: string }} checkResult
|
|
22
|
+
* @returns {boolean}
|
|
23
|
+
*/
|
|
24
|
+
function isBlocked(checkResult) {
|
|
25
|
+
return checkResult.result === "THROTTLED" || checkResult.result === "PENALIZED";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = { formatRetryAfter, isBlocked };
|
package/test.js
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
// enerthya.dev-antiflood — test suite
|
|
2
|
+
// Run: node test.js
|
|
3
|
+
// Uses only Node.js built-in assert. Zero external test runners.
|
|
4
|
+
|
|
5
|
+
const assert = require("assert");
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
AntifloodManager,
|
|
9
|
+
BucketStore,
|
|
10
|
+
createRule,
|
|
11
|
+
FLOOD_RESULT,
|
|
12
|
+
PENALTY_MODE,
|
|
13
|
+
DEFAULT_RULE,
|
|
14
|
+
formatRetryAfter,
|
|
15
|
+
isBlocked,
|
|
16
|
+
} = require("./src");
|
|
17
|
+
|
|
18
|
+
let passed = 0;
|
|
19
|
+
let failed = 0;
|
|
20
|
+
|
|
21
|
+
function test(name, fn) {
|
|
22
|
+
try {
|
|
23
|
+
fn();
|
|
24
|
+
console.log(` ✓ ${name}`);
|
|
25
|
+
passed++;
|
|
26
|
+
} catch (err) {
|
|
27
|
+
console.error(` ✗ ${name}`);
|
|
28
|
+
console.error(` ${err.message}`);
|
|
29
|
+
failed++;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ── Constants ──────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
console.log("\n[FLOOD_RESULT]");
|
|
36
|
+
test("has ALLOWED", () => assert.strictEqual(FLOOD_RESULT.ALLOWED, "ALLOWED"));
|
|
37
|
+
test("has THROTTLED", () => assert.strictEqual(FLOOD_RESULT.THROTTLED, "THROTTLED"));
|
|
38
|
+
test("has PENALIZED", () => assert.strictEqual(FLOOD_RESULT.PENALIZED, "PENALIZED"));
|
|
39
|
+
test("has WHITELISTED", () => assert.strictEqual(FLOOD_RESULT.WHITELISTED, "WHITELISTED"));
|
|
40
|
+
test("is frozen", () => assert.ok(Object.isFrozen(FLOOD_RESULT)));
|
|
41
|
+
|
|
42
|
+
console.log("\n[PENALTY_MODE]");
|
|
43
|
+
test("has NONE", () => assert.strictEqual(PENALTY_MODE.NONE, "NONE"));
|
|
44
|
+
test("has ADDITIVE", () => assert.strictEqual(PENALTY_MODE.ADDITIVE, "ADDITIVE"));
|
|
45
|
+
test("has EXPONENTIAL", () => assert.strictEqual(PENALTY_MODE.EXPONENTIAL, "EXPONENTIAL"));
|
|
46
|
+
|
|
47
|
+
// ── createRule ─────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
console.log("\n[createRule]");
|
|
50
|
+
test("returns defaults when called with no args", () => {
|
|
51
|
+
const r = createRule();
|
|
52
|
+
assert.strictEqual(r.windowMs, DEFAULT_RULE.windowMs);
|
|
53
|
+
assert.strictEqual(r.maxHits, DEFAULT_RULE.maxHits);
|
|
54
|
+
assert.strictEqual(r.penaltyMode, DEFAULT_RULE.penaltyMode);
|
|
55
|
+
});
|
|
56
|
+
test("overrides specific fields", () => {
|
|
57
|
+
const r = createRule({ maxHits: 10, windowMs: 2000 });
|
|
58
|
+
assert.strictEqual(r.maxHits, 10);
|
|
59
|
+
assert.strictEqual(r.windowMs, 2000);
|
|
60
|
+
assert.strictEqual(r.penaltyMode, DEFAULT_RULE.penaltyMode); // kept default
|
|
61
|
+
});
|
|
62
|
+
test("throws on invalid windowMs", () => {
|
|
63
|
+
assert.throws(() => createRule({ windowMs: -1 }), /windowMs/);
|
|
64
|
+
});
|
|
65
|
+
test("throws on invalid maxHits", () => {
|
|
66
|
+
assert.throws(() => createRule({ maxHits: 0 }), /maxHits/);
|
|
67
|
+
});
|
|
68
|
+
test("throws on unknown penaltyMode", () => {
|
|
69
|
+
assert.throws(() => createRule({ penaltyMode: "INVALID" }), /penaltyMode/);
|
|
70
|
+
});
|
|
71
|
+
test("frozen result", () => assert.ok(Object.isFrozen(createRule())));
|
|
72
|
+
|
|
73
|
+
// ── BucketStore ────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
console.log("\n[BucketStore]");
|
|
76
|
+
test("creates empty bucket on first get", () => {
|
|
77
|
+
const s = new BucketStore();
|
|
78
|
+
const b = s.get("k");
|
|
79
|
+
assert.deepStrictEqual(b.hits, []);
|
|
80
|
+
assert.strictEqual(b.penaltyUntil, 0);
|
|
81
|
+
assert.strictEqual(b.excessCount, 0);
|
|
82
|
+
});
|
|
83
|
+
test("addHit pushes timestamp", () => {
|
|
84
|
+
const s = new BucketStore();
|
|
85
|
+
s.addHit("k", 1000);
|
|
86
|
+
s.addHit("k", 2000);
|
|
87
|
+
assert.strictEqual(s.get("k").hits.length, 2);
|
|
88
|
+
});
|
|
89
|
+
test("prune removes hits outside window", () => {
|
|
90
|
+
const s = new BucketStore();
|
|
91
|
+
s.addHit("k", 1000);
|
|
92
|
+
s.addHit("k", 2000);
|
|
93
|
+
s.prune("k", 500, 2100); // window=500ms, now=2100 → only hit at 2000 survives
|
|
94
|
+
assert.strictEqual(s.get("k").hits.length, 1);
|
|
95
|
+
});
|
|
96
|
+
test("prune deletes stale bucket entirely", () => {
|
|
97
|
+
const s = new BucketStore();
|
|
98
|
+
s.addHit("k", 1000);
|
|
99
|
+
s.prune("k", 100, 5000); // all hits expired, no penalty
|
|
100
|
+
assert.strictEqual(s.size, 0);
|
|
101
|
+
});
|
|
102
|
+
test("applyPenalty ADDITIVE increases by step * excessCount", () => {
|
|
103
|
+
const s = new BucketStore();
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
s.applyPenalty("k", "ADDITIVE", 5000, 60000, now);
|
|
106
|
+
assert.ok(s.get("k").penaltyUntil > now);
|
|
107
|
+
assert.strictEqual(s.get("k").excessCount, 1);
|
|
108
|
+
});
|
|
109
|
+
test("applyPenalty EXPONENTIAL doubles each call", () => {
|
|
110
|
+
const s = new BucketStore();
|
|
111
|
+
const now = 10000;
|
|
112
|
+
s.applyPenalty("k", "EXPONENTIAL", 1000, 60000, now);
|
|
113
|
+
const p1 = s.get("k").penaltyUntil;
|
|
114
|
+
s.applyPenalty("k", "EXPONENTIAL", 1000, 60000, now);
|
|
115
|
+
const p2 = s.get("k").penaltyUntil;
|
|
116
|
+
// second call: 2^1 * 1000 = 2000ms added vs first 2^0 * 1000 = 1000ms
|
|
117
|
+
assert.ok(p2 - now > p1 - now);
|
|
118
|
+
});
|
|
119
|
+
test("applyPenalty respects maxPenalty cap", () => {
|
|
120
|
+
const s = new BucketStore();
|
|
121
|
+
const now = 0;
|
|
122
|
+
// 100 excess hits with step=100000, max=5000 — should be capped
|
|
123
|
+
for (let i = 0; i < 100; i++) s.applyPenalty("k", "ADDITIVE", 100000, 5000, now);
|
|
124
|
+
assert.ok(s.get("k").penaltyUntil <= now + 5000);
|
|
125
|
+
});
|
|
126
|
+
test("reset deletes key", () => {
|
|
127
|
+
const s = new BucketStore();
|
|
128
|
+
s.addHit("k", 1000);
|
|
129
|
+
s.reset("k");
|
|
130
|
+
assert.strictEqual(s.size, 0);
|
|
131
|
+
});
|
|
132
|
+
test("clear empties all buckets", () => {
|
|
133
|
+
const s = new BucketStore();
|
|
134
|
+
s.addHit("a", 1000);
|
|
135
|
+
s.addHit("b", 2000);
|
|
136
|
+
s.clear();
|
|
137
|
+
assert.strictEqual(s.size, 0);
|
|
138
|
+
});
|
|
139
|
+
test("tryResetPenalty clears excessCount when penalty expired", () => {
|
|
140
|
+
const s = new BucketStore();
|
|
141
|
+
const bucket = s.get("k");
|
|
142
|
+
bucket.penaltyUntil = 1000;
|
|
143
|
+
bucket.excessCount = 5;
|
|
144
|
+
s.tryResetPenalty("k", 2000); // now > penaltyUntil
|
|
145
|
+
assert.strictEqual(s.get("k").excessCount, 0);
|
|
146
|
+
});
|
|
147
|
+
test("tryResetPenalty does not clear if penalty still active", () => {
|
|
148
|
+
const s = new BucketStore();
|
|
149
|
+
const bucket = s.get("k");
|
|
150
|
+
bucket.penaltyUntil = 9999999999;
|
|
151
|
+
bucket.excessCount = 3;
|
|
152
|
+
s.tryResetPenalty("k", 1000);
|
|
153
|
+
assert.strictEqual(s.get("k").excessCount, 3);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ── AntifloodManager — basic flow ──────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
console.log("\n[AntifloodManager — basic flow]");
|
|
159
|
+
test("allows hits within maxHits", () => {
|
|
160
|
+
const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 3 } });
|
|
161
|
+
for (let i = 0; i < 3; i++) {
|
|
162
|
+
const r = af.check({ userId: "u1", commandName: "ping" });
|
|
163
|
+
assert.strictEqual(r.result, FLOOD_RESULT.ALLOWED, `hit ${i + 1} should be ALLOWED`);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
test("throttles on 4th hit with NONE penalty", () => {
|
|
167
|
+
const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 3, penaltyMode: "NONE" } });
|
|
168
|
+
for (let i = 0; i < 3; i++) af.check({ userId: "u1" });
|
|
169
|
+
const r = af.check({ userId: "u1" });
|
|
170
|
+
assert.strictEqual(r.result, FLOOD_RESULT.THROTTLED);
|
|
171
|
+
assert.ok(r.retryAfterMs > 0);
|
|
172
|
+
});
|
|
173
|
+
test("penalizes on 4th hit with ADDITIVE penalty", () => {
|
|
174
|
+
const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 3, penaltyMode: "ADDITIVE", penaltyStep: 5000 } });
|
|
175
|
+
for (let i = 0; i < 3; i++) af.check({ userId: "u1" });
|
|
176
|
+
const r = af.check({ userId: "u1" });
|
|
177
|
+
assert.strictEqual(r.result, FLOOD_RESULT.PENALIZED);
|
|
178
|
+
assert.ok(r.penaltyUntil > Date.now());
|
|
179
|
+
});
|
|
180
|
+
test("penalized user stays blocked on next check", () => {
|
|
181
|
+
const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 2, penaltyMode: "ADDITIVE", penaltyStep: 60000 } });
|
|
182
|
+
for (let i = 0; i < 2; i++) af.check({ userId: "u1" });
|
|
183
|
+
af.check({ userId: "u1" }); // triggers penalty
|
|
184
|
+
const r = af.check({ userId: "u1" });
|
|
185
|
+
assert.strictEqual(r.result, FLOOD_RESULT.PENALIZED);
|
|
186
|
+
});
|
|
187
|
+
test("independent users don't affect each other", () => {
|
|
188
|
+
const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 2 } });
|
|
189
|
+
af.check({ userId: "u1" });
|
|
190
|
+
af.check({ userId: "u1" });
|
|
191
|
+
af.check({ userId: "u1" }); // u1 gets throttled/penalized
|
|
192
|
+
const r = af.check({ userId: "u2" }); // u2 unaffected
|
|
193
|
+
assert.strictEqual(r.result, FLOOD_RESULT.ALLOWED);
|
|
194
|
+
});
|
|
195
|
+
test("different guilds are isolated", () => {
|
|
196
|
+
const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 2 } });
|
|
197
|
+
af.check({ userId: "u1", guildId: "g1" });
|
|
198
|
+
af.check({ userId: "u1", guildId: "g1" });
|
|
199
|
+
af.check({ userId: "u1", guildId: "g1" }); // g1 throttled
|
|
200
|
+
const r = af.check({ userId: "u1", guildId: "g2" });
|
|
201
|
+
assert.strictEqual(r.result, FLOOD_RESULT.ALLOWED);
|
|
202
|
+
});
|
|
203
|
+
test("different commands are isolated", () => {
|
|
204
|
+
const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 2 } });
|
|
205
|
+
af.check({ userId: "u1", commandName: "ban" });
|
|
206
|
+
af.check({ userId: "u1", commandName: "ban" });
|
|
207
|
+
af.check({ userId: "u1", commandName: "ban" }); // ban throttled
|
|
208
|
+
const r = af.check({ userId: "u1", commandName: "ping" });
|
|
209
|
+
assert.strictEqual(r.result, FLOOD_RESULT.ALLOWED);
|
|
210
|
+
});
|
|
211
|
+
test("returns correct hitsInWindow count", () => {
|
|
212
|
+
const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 5 } });
|
|
213
|
+
af.check({ userId: "u1" });
|
|
214
|
+
af.check({ userId: "u1" });
|
|
215
|
+
const r = af.check({ userId: "u1" });
|
|
216
|
+
assert.strictEqual(r.result, FLOOD_RESULT.ALLOWED);
|
|
217
|
+
assert.ok(r.hitsInWindow >= 2);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// ── AntifloodManager — per-command rules ──────────────────────────────────
|
|
221
|
+
|
|
222
|
+
console.log("\n[AntifloodManager — per-command rules]");
|
|
223
|
+
test("per-command rule overrides global rule", () => {
|
|
224
|
+
const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 10 } });
|
|
225
|
+
af.setRule("ban", { windowMs: 10000, maxHits: 1, penaltyMode: "NONE" });
|
|
226
|
+
af.check({ userId: "u1", commandName: "ban" });
|
|
227
|
+
const r = af.check({ userId: "u1", commandName: "ban" });
|
|
228
|
+
assert.strictEqual(r.result, FLOOD_RESULT.THROTTLED); // stricter rule applied
|
|
229
|
+
});
|
|
230
|
+
test("global rule unaffected by per-command rule", () => {
|
|
231
|
+
const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 10 } });
|
|
232
|
+
af.setRule("ban", { windowMs: 10000, maxHits: 1, penaltyMode: "NONE" });
|
|
233
|
+
for (let i = 0; i < 9; i++) af.check({ userId: "u1", commandName: "ping" });
|
|
234
|
+
const r = af.check({ userId: "u1", commandName: "ping" }); // 10th hit — within maxHits: 10
|
|
235
|
+
assert.strictEqual(r.result, FLOOD_RESULT.ALLOWED);
|
|
236
|
+
});
|
|
237
|
+
test("setRule returns manager (chainable)", () => {
|
|
238
|
+
const af = new AntifloodManager();
|
|
239
|
+
const ret = af.setRule("cmd", { maxHits: 5 });
|
|
240
|
+
assert.strictEqual(ret, af);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// ── AntifloodManager — whitelist ───────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
console.log("\n[AntifloodManager — whitelist]");
|
|
246
|
+
test("whitelisted role bypasses flood check", () => {
|
|
247
|
+
const af = new AntifloodManager({
|
|
248
|
+
globalRule: { windowMs: 10000, maxHits: 1 },
|
|
249
|
+
whitelistRoleIds: ["role_admin"],
|
|
250
|
+
});
|
|
251
|
+
for (let i = 0; i < 20; i++) {
|
|
252
|
+
const r = af.check({ userId: "u1", memberRoleIds: ["role_admin"] });
|
|
253
|
+
assert.strictEqual(r.result, FLOOD_RESULT.WHITELISTED);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
test("non-whitelisted role is not bypassed", () => {
|
|
257
|
+
const af = new AntifloodManager({
|
|
258
|
+
globalRule: { windowMs: 10000, maxHits: 1, penaltyMode: "NONE" },
|
|
259
|
+
whitelistRoleIds: ["role_admin"],
|
|
260
|
+
});
|
|
261
|
+
af.check({ userId: "u1", memberRoleIds: ["role_member"] });
|
|
262
|
+
const r = af.check({ userId: "u1", memberRoleIds: ["role_member"] });
|
|
263
|
+
assert.strictEqual(r.result, FLOOD_RESULT.THROTTLED);
|
|
264
|
+
});
|
|
265
|
+
test("addWhitelist adds role at runtime", () => {
|
|
266
|
+
const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 1 } });
|
|
267
|
+
af.addWhitelist("role_mod");
|
|
268
|
+
const r = af.check({ userId: "u1", memberRoleIds: ["role_mod"] });
|
|
269
|
+
assert.strictEqual(r.result, FLOOD_RESULT.WHITELISTED);
|
|
270
|
+
});
|
|
271
|
+
test("removeWhitelist removes role", () => {
|
|
272
|
+
const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 1 }, whitelistRoleIds: ["role_x"] });
|
|
273
|
+
af.removeWhitelist("role_x");
|
|
274
|
+
af.check({ userId: "u1", memberRoleIds: ["role_x"] });
|
|
275
|
+
const r = af.check({ userId: "u1", memberRoleIds: ["role_x"] });
|
|
276
|
+
assert.notStrictEqual(r.result, FLOOD_RESULT.WHITELISTED);
|
|
277
|
+
});
|
|
278
|
+
test("addWhitelist is chainable", () => {
|
|
279
|
+
const af = new AntifloodManager();
|
|
280
|
+
const ret = af.addWhitelist("r1");
|
|
281
|
+
assert.strictEqual(ret, af);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// ── AntifloodManager — master switch ──────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
console.log("\n[AntifloodManager — master switch]");
|
|
287
|
+
test("disabled manager always returns ALLOWED", () => {
|
|
288
|
+
const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 1 } });
|
|
289
|
+
af.disable();
|
|
290
|
+
for (let i = 0; i < 10; i++) {
|
|
291
|
+
const r = af.check({ userId: "u1" });
|
|
292
|
+
assert.strictEqual(r.result, FLOOD_RESULT.ALLOWED);
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
test("re-enabling resumes checks", () => {
|
|
296
|
+
const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 1, penaltyMode: "NONE" } });
|
|
297
|
+
af.disable();
|
|
298
|
+
af.check({ userId: "u1" }); // should not count
|
|
299
|
+
af.check({ userId: "u1" });
|
|
300
|
+
af.enable();
|
|
301
|
+
af.check({ userId: "u1" }); // hit 1
|
|
302
|
+
const r = af.check({ userId: "u1" }); // hit 2 → throttled
|
|
303
|
+
assert.strictEqual(r.result, FLOOD_RESULT.THROTTLED);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// ── AntifloodManager — manual reset ───────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
console.log("\n[AntifloodManager — manual reset]");
|
|
309
|
+
test("reset unblocks a throttled user", () => {
|
|
310
|
+
const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 1, penaltyMode: "NONE" } });
|
|
311
|
+
af.check({ userId: "u1", commandName: "ban" });
|
|
312
|
+
af.check({ userId: "u1", commandName: "ban" }); // throttled
|
|
313
|
+
af.reset({ userId: "u1", commandName: "ban" });
|
|
314
|
+
const r = af.check({ userId: "u1", commandName: "ban" });
|
|
315
|
+
assert.strictEqual(r.result, FLOOD_RESULT.ALLOWED);
|
|
316
|
+
});
|
|
317
|
+
test("resetAll clears all state", () => {
|
|
318
|
+
const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 2 } });
|
|
319
|
+
af.check({ userId: "u1" });
|
|
320
|
+
af.check({ userId: "u2" });
|
|
321
|
+
af.resetAll();
|
|
322
|
+
assert.strictEqual(af.activeBuckets, 0);
|
|
323
|
+
});
|
|
324
|
+
test("activeBuckets reflects current state", () => {
|
|
325
|
+
const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 5 } });
|
|
326
|
+
assert.strictEqual(af.activeBuckets, 0);
|
|
327
|
+
af.check({ userId: "u1" });
|
|
328
|
+
af.check({ userId: "u2" });
|
|
329
|
+
assert.ok(af.activeBuckets >= 2);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// ── EXPONENTIAL penalty ────────────────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
console.log("\n[AntifloodManager — EXPONENTIAL penalty]");
|
|
335
|
+
test("exponential penalty grows on repeated violations", () => {
|
|
336
|
+
const af = new AntifloodManager({
|
|
337
|
+
globalRule: { windowMs: 60000, maxHits: 2, penaltyMode: "EXPONENTIAL", penaltyStep: 1000, maxPenalty: 999999 },
|
|
338
|
+
});
|
|
339
|
+
af.check({ userId: "u1" });
|
|
340
|
+
af.check({ userId: "u1" });
|
|
341
|
+
const r1 = af.check({ userId: "u1" }); // 1st excess → penaltyUntil = now + 2^0*1000
|
|
342
|
+
const r2 = af.check({ userId: "u1" }); // 2nd excess → penaltyUntil = now + 2^1*1000
|
|
343
|
+
assert.strictEqual(r1.result, FLOOD_RESULT.PENALIZED);
|
|
344
|
+
assert.strictEqual(r2.result, FLOOD_RESULT.PENALIZED);
|
|
345
|
+
assert.ok(r2.penaltyUntil >= r1.penaltyUntil);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// ── Utilities ─────────────────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
console.log("\n[formatRetryAfter — delegates to enerthya.dev-common]");
|
|
351
|
+
test("formats 1000ms as 1 segundo", () => assert.ok(formatRetryAfter(1000).includes("segundo")));
|
|
352
|
+
test("formats 60000ms includes minuto",() => assert.ok(formatRetryAfter(60000).includes("minuto")));
|
|
353
|
+
test("formats 0ms as 0 segundos", () => assert.strictEqual(formatRetryAfter(0), "0 segundos"));
|
|
354
|
+
test("rounds up fractional seconds", () => assert.ok(formatRetryAfter(1500).includes("segundo")));
|
|
355
|
+
|
|
356
|
+
console.log("\n[isBlocked]");
|
|
357
|
+
test("THROTTLED → true", () => assert.strictEqual(isBlocked({ result: "THROTTLED" }), true));
|
|
358
|
+
test("PENALIZED → true", () => assert.strictEqual(isBlocked({ result: "PENALIZED" }), true));
|
|
359
|
+
test("ALLOWED → false", () => assert.strictEqual(isBlocked({ result: "ALLOWED" }), false));
|
|
360
|
+
test("WHITELISTED → false",() => assert.strictEqual(isBlocked({ result: "WHITELISTED" }), false));
|
|
361
|
+
|
|
362
|
+
// ── Summary ────────────────────────────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
console.log(`\n${"─".repeat(45)}`);
|
|
365
|
+
console.log(` ${passed + failed} tests: ${passed} passed, ${failed} failed`);
|
|
366
|
+
if (failed > 0) {
|
|
367
|
+
console.error(" ❌ SOME TESTS FAILED");
|
|
368
|
+
process.exit(1);
|
|
369
|
+
} else {
|
|
370
|
+
console.log(" ✅ ALL TESTS PASSED");
|
|
371
|
+
}
|