diff --git a/integration_test/channel/kickban.js b/integration_test/channel/kickban.js index f153bb93..b60d14ba 100644 --- a/integration_test/channel/kickban.js +++ b/integration_test/channel/kickban.js @@ -362,6 +362,7 @@ describe('KickbanModule', () => { ); }); + // TODO: for whatever reason, this test is flaky it('inserts a valid IPv6 ban', done => { const longIP = require('../../lib/utilities').expandIPv6('::abcd'); diff --git a/integration_test/controller/banned-channels.js b/integration_test/controller/banned-channels.js new file mode 100644 index 00000000..49bf9bc1 --- /dev/null +++ b/integration_test/controller/banned-channels.js @@ -0,0 +1,109 @@ +const assert = require('assert'); +const { BannedChannelsController } = require('../../lib/controller/banned-channels'); +const dbChannels = require('../../lib/database/channels'); +const testDB = require('../testutil/db').testDB; +const { EventEmitter } = require('events'); + +require('../../lib/database').init(testDB); + +const testBan = { + name: 'ban_test_1', + externalReason: 'because I said so', + internalReason: 'illegal content', + bannedBy: 'admin' +}; + +async function cleanupTestBan() { + return dbChannels.removeBannedChannel(testBan.name); +} + +describe('BannedChannelsController', () => { + let controller; + let messages; + + beforeEach(async () => { + await cleanupTestBan(); + messages = new EventEmitter(); + controller = new BannedChannelsController( + dbChannels, + messages + ); + }); + + afterEach(async () => { + await cleanupTestBan(); + }); + + it('bans a channel', async () => { + assert.strictEqual(await controller.getBannedChannel(testBan.name), null); + + let received = null; + messages.once('ChannelBanned', cb => { + received = cb; + }); + + await controller.banChannel(testBan); + let info = await controller.getBannedChannel(testBan.name); + for (let field of Object.keys(testBan)) { + // Consider renaming parameter to avoid this branch + if (field === 'name') { + assert.strictEqual(info.channelName, testBan.name); + } else { + assert.strictEqual(info[field], testBan[field]); + } + } + + assert.notEqual(received, null); + assert.strictEqual(received.channel, testBan.name); + assert.strictEqual(received.externalReason, testBan.externalReason); + }); + + it('updates an existing ban', async () => { + let received = []; + messages.on('ChannelBanned', cb => { + received.push(cb); + }); + + await controller.banChannel(testBan); + + let testBan2 = { ...testBan, externalReason: 'because of reasons' }; + await controller.banChannel(testBan2); + + let info = await controller.getBannedChannel(testBan2.name); + for (let field of Object.keys(testBan2)) { + // Consider renaming parameter to avoid this branch + if (field === 'name') { + assert.strictEqual(info.channelName, testBan2.name); + } else { + assert.strictEqual(info[field], testBan2[field]); + } + } + + assert.deepStrictEqual(received, [ + { + channel: testBan.name, + externalReason: testBan.externalReason + }, + { + channel: testBan2.name, + externalReason: testBan2.externalReason + }, + ]); + }); + + it('unbans a channel', async () => { + let received = null; + messages.once('ChannelUnbanned', cb => { + received = cb; + }); + + await controller.banChannel(testBan); + await controller.unbanChannel(testBan.name, testBan.bannedBy); + + let info = await controller.getBannedChannel(testBan.name); + assert.strictEqual(info, null); + + assert.notEqual(received, null); + assert.strictEqual(received.channel, testBan.name); + }); +}); diff --git a/src/controller/banned-channels.js b/src/controller/banned-channels.js index 197c07fc..68a57638 100644 --- a/src/controller/banned-channels.js +++ b/src/controller/banned-channels.js @@ -1,10 +1,15 @@ import { eventlog } from '../logger'; +import { SimpleCache } from '../util/simple-cache'; const LOGGER = require('@calzoneman/jsli')('BannedChannelsController'); export class BannedChannelsController { constructor(dbChannels, globalMessageBus) { this.dbChannels = dbChannels; this.globalMessageBus = globalMessageBus; + this.cache = new SimpleCache({ + maxElem: 1000, + maxAge: 5 * 60_000 + }); } /* @@ -20,6 +25,8 @@ export class BannedChannelsController { LOGGER.warn(`Channel ${name} is already banned, updating ban reason`); } + this.cache.delete(name); + await this.dbChannels.putBannedChannel({ name, externalReason, @@ -36,6 +43,7 @@ export class BannedChannelsController { async unbanChannel(name, unbannedBy) { LOGGER.info(`Unbanning channel ${name}`); eventlog.log(`[acp] ${unbannedBy} unbanned channel ${name}`); + this.cache.delete(name); this.globalMessageBus.emit( 'ChannelUnbanned', @@ -46,47 +54,14 @@ export class BannedChannelsController { } async getBannedChannel(name) { - // TODO: cache - return this.dbChannels.getBannedChannel(name); - } -} - -class Cache { - constructor({ maxElem, maxAge }) { - this.maxElem = maxElem; - this.maxAge = maxAge; - this.cache = new Map(); - } - - put(key, value) { - this.cache.set(key, { value: value, at: Date.now() }); - - if (this.cache.size > this.maxElem) { - this.cache.delete(this.cache.keys().next()); - } - } - - get(key) { - let val = this.cache.get(key); - - if (val != null) { - return val.value; - } else { - return null; - } - } - - delete(key) { - this.cache.delete(key); - } - - cleanup() { - let now = Date.now(); - - for (let [key, value] of this.cache) { - if (value.at < now - this.maxAge) { - this.cache.delete(key); - } - } + name = name.toLowerCase(); + + let info = this.cache.get(name); + if (info === null) { + info = await this.dbChannels.getBannedChannel(name); + this.cache.put(name, info); + } + + return info; } } diff --git a/src/main.js b/src/main.js index 04e06a5c..35553533 100644 --- a/src/main.js +++ b/src/main.js @@ -59,6 +59,7 @@ function handleLine(line, client) { client.write('{"status":"error","error":"internal error"}\n'); }); } catch (_error) { + // eslint no-empty: off } if (line === '/reload') { diff --git a/src/server.js b/src/server.js index 6921f504..8143e108 100644 --- a/src/server.js +++ b/src/server.js @@ -141,7 +141,8 @@ var Server = function () { Config.getEmailConfig(), emailController, Config.getCaptchaConfig(), - captchaController + captchaController, + self.bannedChannelsController ); // http/https/sio server init ----------------------------------------- diff --git a/src/util/simple-cache.js b/src/util/simple-cache.js new file mode 100644 index 00000000..606b9043 --- /dev/null +++ b/src/util/simple-cache.js @@ -0,0 +1,45 @@ +class SimpleCache { + constructor({ maxElem, maxAge }) { + this.maxElem = maxElem; + this.maxAge = maxAge; + this.cache = new Map(); + + setInterval(() => { + this.cleanup(); + }, maxAge).unref(); + } + + put(key, value) { + this.cache.set(key, { value: value, at: Date.now() }); + + if (this.cache.size > this.maxElem) { + this.cache.delete(this.cache.keys().next().value); + } + } + + get(key) { + let val = this.cache.get(key); + + if (val != null && Date.now() < val.at + this.maxAge) { + return val.value; + } else { + return null; + } + } + + delete(key) { + this.cache.delete(key); + } + + cleanup() { + let now = Date.now(); + + for (let [key, value] of this.cache) { + if (value.at < now - this.maxAge) { + this.cache.delete(key); + } + } + } +} + +export { SimpleCache }; diff --git a/src/web/routes/channel.js b/src/web/routes/channel.js index 8afdc7d1..6f55f0b2 100644 --- a/src/web/routes/channel.js +++ b/src/web/routes/channel.js @@ -11,7 +11,6 @@ export default function initialize(app, ioConfig, chanPath, getBannedChannel) { 'channel name.', { status: HTTPStatus.NOT_FOUND }); } - // TODO: add a cache let banInfo = await getBannedChannel(req.params.channel); if (banInfo !== null) { sendPug(res, 'banned_channel', { diff --git a/src/web/webserver.js b/src/web/webserver.js index aeb460b5..36a36cf2 100644 --- a/src/web/webserver.js +++ b/src/web/webserver.js @@ -143,7 +143,8 @@ module.exports = { emailConfig, emailController, captchaConfig, - captchaController + captchaController, + bannedChannelsController ) { patchExpressToHandleAsync(); const chanPath = Config.get('channel-path'); @@ -198,8 +199,7 @@ module.exports = { app, ioConfig, chanPath, - // TODO: banController - require('../database/channels').getBannedChannel + async name => bannedChannelsController.getBannedChannel(name) ); require('./routes/index')(app, channelIndex, webConfig.getMaxIndexEntries()); require('./routes/socketconfig')(app, clusterClient); diff --git a/test/util/simple-cache.js b/test/util/simple-cache.js new file mode 100644 index 00000000..dd7a42a5 --- /dev/null +++ b/test/util/simple-cache.js @@ -0,0 +1,52 @@ +const { SimpleCache } = require('../../lib/util/simple-cache'); +const assert = require('assert'); + +describe('SimpleCache', () => { + const CACHE_MAX_ELEM = 5; + const CACHE_MAX_AGE = 5; + let cache; + + beforeEach(() => { + cache = new SimpleCache({ + maxElem: CACHE_MAX_ELEM, + maxAge: CACHE_MAX_AGE + }); + }); + + it('sets, gets, and deletes a value', () => { + assert.strictEqual(cache.get('foo'), null); + + cache.put('foo', 'bar'); + assert.strictEqual(cache.get('foo'), 'bar'); + + cache.delete('foo'); + assert.strictEqual(cache.get('foo'), null); + }); + + it('does not return an expired value', done => { + cache.put('foo', 'bar'); + + setTimeout(() => { + assert.strictEqual(cache.get('foo'), null); + done(); + }, CACHE_MAX_AGE + 1); + }); + + it('cleans up old values', done => { + cache.put('foo', 'bar'); + + setTimeout(() => { + assert.strictEqual(cache.get('foo'), null); + done(); + }, CACHE_MAX_AGE * 2); + }); + + it('removes the oldest entry if max elem is reached', () => { + for (let i = 0; i < CACHE_MAX_ELEM + 1; i++) { + cache.put(`foo${i}`, 'bar'); + } + + assert.strictEqual(cache.get('foo0'), null); + assert.strictEqual(cache.get('foo1'), 'bar'); + }); +});