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 }