plugins: automatic unload of api.events listeners
Massimo Melina committed
Feb 8, 2025 at 12:25 UTC
903d53bb506e6b34f8d8b8e01a59f4c5373c4d77
4 files changed
+51
-33
plugins/antibrute/plugin.js
+24
-26
@@ -16,32 +16,30 @@ const byIp = {}
16
17
exports.init = api => {
18
const { getOrSet, isLocalHost, HOUR } = api.misc
19
- return {
20
- unload: api.events.multi({
21
- async attemptingLogin({ ctx }) {
22
- const { ip } = ctx
23
- const now = new Date
24
- const rec = getOrSet(byIp, ip, () => ({ attempts: 0, next: now }))
25
- const max = api.getConfig('max') * 1000
26
- const delay = Math.min(max, 1000 * api.getConfig('increment') * ++rec.attempts)
27
- const wait = rec.next - now
28
- rec.next = new Date(+rec.next + delay)
29
- if (rec.attempts > api.getConfig('blockAfter') && !isLocalHost(ctx)) {
30
- const hours = api.getConfig('blockForHours')
31
- api.addBlock({ ip, comment: "From antibrute plugin", expire: hours ? new Date(now.getTime() + hours * HOUR) : undefined })
32
- }
33
- clearTimeout(rec.timer)
34
- if (wait > 0) {
35
- api.log('delaying', ip, 'for', Math.round(wait / 1000))
36
- ctx.set('x-anti-brute-force', wait)
37
- await new Promise(resolve => setTimeout(resolve, wait))
38
- }
39
- rec.timer = setTimeout(() => delete byIp[ip], 24 * HOUR) // no memory leak
40
- },
41
- login: ctx => {
42
- if (ctx.state.account)
43
- delete byIp[ctx.ip] // reset if login was successful
19
+ api.events.multi({
20
+ async attemptingLogin({ ctx }) {
21
+ const { ip } = ctx
22
+ const now = new Date
23
+ const rec = getOrSet(byIp, ip, () => ({ attempts: 0, next: now }))
24
+ const max = api.getConfig('max') * 1000
25
+ const delay = Math.min(max, 1000 * api.getConfig('increment') * ++rec.attempts)
26
+ const wait = rec.next - now
27
+ rec.next = new Date(+rec.next + delay)
28
+ if (rec.attempts > api.getConfig('blockAfter') && !isLocalHost(ctx)) {
29
+ const hours = api.getConfig('blockForHours')
30
+ api.addBlock({ ip, comment: "From antibrute plugin", expire: hours ? new Date(now.getTime() + hours * HOUR) : undefined })
31
}
32
+ clearTimeout(rec.timer)
33
+ if (wait > 0) {
34
+ api.log('delaying', ip, 'for', Math.round(wait / 1000))
35
+ ctx.set('x-anti-brute-force', wait)
36
+ await new Promise(resolve => setTimeout(resolve, wait))
37
+ }
38
+ rec.timer = setTimeout(() => delete byIp[ip], 24 * HOUR) // no memory leak
39
+ },
40
+ login(ctx) {
41
+ if (ctx.state.account)
42
+ delete byIp[ctx.ip] // reset if login was successful
43
+ }
44
})
46
- }
45
}
src/cross.ts
+5
@@ -509,6 +509,11 @@ export function popKey(o: any, k: string) {
509
return x
510
}
511
512
+export function patchKey(o: any, k: string, replacer: (was: unknown) => unknown) {
513
+ o[k] = replacer(o[k])
514
+ return o
515
+}
516
+
517
export function shortenAgent(agent: string) {
518
return _.findKey(BROWSERS, re => re.test(agent))
519
|| /^[^/(]+ ?/.exec(agent)?.[0]
src/events.ts
+7
-5
@@ -18,7 +18,7 @@ export class BetterEventEmitter {
18
cbs.add(listener)
19
if (cbs.size > warnAfter)
20
console.warn("Warning: many event listeners for ", e)
21
- this.emit(e + LISTENERS_SUFFIX, cbs)
21
+ this.emit(e + LISTENERS_SUFFIX, cbs, listener)
22
}
23
if (callNow)
24
try { listener() }
@@ -37,13 +37,15 @@ export class BetterEventEmitter {
37
return this.on(event + LISTENERS_SUFFIX, listener)
38
}
39
once(event: string, listener?: Listener) {
40
- return new Promise<any[]>(resolve => {
41
- const off = this.on(event, function(...args){
40
+ let off: () => unknown
41
+ const pro = new Promise<any[]>(resolve => {
42
+ off = this.on(event, function(...args){
43
off()
43
- resolve(args)
44
- return listener?.(...arguments)
44
+ resolve(args.slice(0, -1)) // remove the extra argument at the end of our emit()
45
+ return listener?.(...args)
46
})
47
})
48
+ return Object.assign(off!, { then: pro.then.bind(pro) } satisfies PromiseLike<any> as Promise<any>)
49
}
50
multi(map: { [eventName: string]: Listener }) {
51
const cbs = Object.entries(map).map(([name, cb]) => this.on(name.split(' '), cb))
src/plugins.ts
+15
-2
@@ -10,7 +10,7 @@ import * as Const from './const'
10
import Koa from 'koa'
11
import {
12
adjustStaticPathForGlob, callable, Callback, CFG, debounceAsync, Dict, getOrSet, objSameKeys, onlyTruthy,
13
- PendingPromise, pendingPromise, Promisable, same, tryJson, wait, waitFor, wantArray, watchDir, objFromKeys
13
+ PendingPromise, pendingPromise, Promisable, same, tryJson, wait, waitFor, wantArray, watchDir, objFromKeys, patchKey
14
} from './misc'
15
import * as misc from './misc'
16
import { defineConfig, getConfig } from './config'
@@ -114,11 +114,19 @@ export function getPluginConfigFields(id: string) {
114
}
115
116
async function initPlugin(pl: any, morePassedToInit?: { id: string } & Dict<any>) {
117
+ const undoEvents: any[] = []
118
const res = await pl.init?.({
119
Const,
120
require,
121
getConnections,
121
- events,
122
+ // intercept all subscriptions, so to be able to undo them on unload
123
+ events: Object.create(events, objFromKeys(['on', 'once', 'multi'], k => ({
124
+ value() {
125
+ const ret = (events[k] as any)(...arguments)
126
+ undoEvents.push(ret)
127
+ return ret
128
+ }
129
+ }))),
130
log: console.log,
131
setError(msg: string) { setError(morePassedToInit?.id || 'server_code', msg) },
132
getHfsConfig: getConfig,
@@ -133,6 +141,11 @@ async function initPlugin(pl: any, morePassedToInit?: { id: string } & Dict<any>
141
...morePassedToInit
142
})
143
Object.assign(pl, typeof res === 'function' ? { unload: res } : res)
144
+ patchKey(pl, 'unload', was => () => {
145
+ for (const cb of undoEvents) cb()
146
+ if (typeof was === 'function')
147
+ return was(...arguments)
148
+ })
149
events.emit('pluginInitialized', pl)
150
return pl
151
}