diff --git a/website/server/middlewares/ipBlocker.js b/website/server/middlewares/ipBlocker.js index c11f3e47d1..91bf2e6eee 100644 --- a/website/server/middlewares/ipBlocker.js +++ b/website/server/middlewares/ipBlocker.js @@ -3,6 +3,7 @@ import { Forbidden, } from '../libs/errors'; import { apiError } from '../libs/apiError'; +import { model as Blocker } from '../models/blocker'; // Middleware to block unwanted IP addresses @@ -22,10 +23,28 @@ const blockedIps = BLOCKED_IPS_RAW .filter(blockedIp => Boolean(blockedIp)) : []; +Blocker.watchBlockers({ + type: 'ipaddress', + area: 'full', +}, { + initial: true, +}).on('change', async change => { + const { operation, blocker } = change; + if (operation === 'add') { + if (blocker.value && !blockedIps.includes(blocker.value)) { + blockedIps.push(blocker.value); + } + } else if (operation === 'delete') { + const index = blockedIps.indexOf(blocker.value); + if (index !== -1) { + blockedIps.splice(index, 1); + } + } +}); + export default function ipBlocker (req, res, next) { // If there are no IPs to block, skip the middleware if (blockedIps.length === 0) return next(); - // Is the client IP, req.ip, blocked? const match = blockedIps.find(blockedIp => blockedIp === req.ip) !== undefined; diff --git a/website/server/models/blocker.js b/website/server/models/blocker.js new file mode 100644 index 0000000000..9d2e7c2f3a --- /dev/null +++ b/website/server/models/blocker.js @@ -0,0 +1,113 @@ +/* eslint-disable camelcase */ + +import mongoose from 'mongoose'; +import EventEmitter from 'events'; +import { v4 as uuid } from 'uuid'; +import validator from 'validator'; +import baseModel from '../libs/baseModel'; + +export const blockTypes = [ + 'ipaddress', + 'email', +]; + +export const blockArea = [ + 'full', + 'payment', +]; + +export const schema = new mongoose.Schema({ + id: { + $type: String, + default: uuid, + validate: [v => validator.isUUID(v), 'Invalid uuid for tag.'], + required: true, + }, + disabled: { + $type: Boolean, default: false, // If true, the block is disabled + }, + type: { + $type: String, enum: blockTypes, required: true, + }, + area: { + $type: String, enum: blockArea, default: 'full', // full or payment + }, + value: { + $type: String, required: true, // e.g. IP address + }, + blockSource: { + $type: String, enum: ['user', 'system', 'worker'], default: 'user', // who created the block + }, + reason: { + $type: String, required: false, // e.g. 'abusive behavior' + }, +}, { + strict: true, + minimize: false, // So empty objects are returned + _id: false, // use id instead of _id + typeKey: '$type', // So that we can use fields named `type` +}); + +schema.plugin(baseModel, { + timestamps: true, + noSet: ['_id'], + _id: false, // use id instead of _id +}); + +schema.statics.watchBlockers = function watchBlockers (query, options) { + const emitter = new EventEmitter(); + const matchQuery = { + $match: {}, + }; + if (query) { + if (query.type) { + matchQuery.$match['fullDocument.type'] = query.type; + } + if (query.area) { + matchQuery.$match['fullDocument.area'] = query.area; + } + } + process.nextTick(() => { + this.watch([matchQuery], { + fullDocument: 'updateLookup', + }) + .on('change', change => { + if (!change.fullDocument) { + return; // Ignore changes that don't have a fullDocument + } + if (change.operationType === 'insert' || !change.fullDocument.disabled) { + emitter.emit('change', { + operation: 'add', + blocker: change.fullDocument, + }); + } else if (change.operationType === 'update' && change.fullDocument.disabled) { + emitter.emit('change', { + operation: 'delete', + blocker: change.fullDocument, + }); + } + }) + .on('error', error => { + emitter.emit('error', error); + }); + if (options.initial) { + const initialQuery = { + disabled: false, + ...query, + }; + this.find(initialQuery).then(docs => { + for (const doc of docs) { + emitter.emit('change', { + operation: 'add', + blocker: doc, + }); + } + }).catch(error => { + emitter.emit('error', error); + }); + } + }); + return emitter; +}; + +export const model = mongoose.model('Blocker', schema);