UNPKG

nodebb-plugin-spam-be-gone-fix

Version:

support both Google Recaptcha, Akismet.com, StopForumSpam.com & ProjectHoneyPot.com in Nodebb V3.x

573 lines (497 loc) 17.8 kB
'use strict'; const util = require('util'); const Honeypot = require('project-honeypot'); //const simpleRecaptcha = require('simple-recaptcha-new'); const hCaptcha = require('hcaptcha'); const stopforumspam = require('stopforumspam'); const winston = require.main.require('winston'); const nconf = require.main.require('nconf'); const Meta = require.main.require('./src/meta'); const User = require.main.require('./src/user'); const Topics = require.main.require('./src/topics'); const db = require.main.require('./src/database'); const pluginData = require('./plugin.json'); const https = require('https'); let akismetClient; let akismetCheckSpam; let akismetSubmitSpam; let akismetSubmitHam; let honeypot; let recaptchaArgs; let pluginSettings; const Plugin = module.exports; pluginData.nbbId = pluginData.id.replace(/nodebb-plugin-/, ''); Plugin.nbbId = pluginData.nbbId; Plugin.middleware = {}; Plugin.middleware.isAdminOrGlobalMod = function (req, res, next) { User.isAdminOrGlobalMod(req.uid, (err, isAdminOrGlobalMod) => { if (err) { return next(err); } if (isAdminOrGlobalMod) { return next(); } res.status(401).json({ message: '[[spam-be-gone-fix:not-allowed]]' }); }); }; Plugin.middleware.checkStopForumSpam = function (req, res, next) { if (!pluginSettings.stopforumspamEnabled) { return res.status(400).send({ message: '[[spam-be-gone-fix:sfs-not-enabled]]' }); } if (!pluginSettings.stopforumspamApiKey) { return res.status(400).send({ message: '[[spam-be-gone-fix:sfs-api-key-not-set]]' }); } next(); }; Plugin.load = async function (params) { const settings = await Meta.settings.get(pluginData.nbbId); if (!settings) { winston.warn(`[plugins/${pluginData.nbbId}] Settings not set or could not be retrieved!`); return; } if (settings.akismetEnabled === 'on') { if (settings.akismetApiKey) { akismetClient = require('akismet').client({ blog: nconf.get('url'), apiKey: settings.akismetApiKey, }); akismetClient.verifyKey((err, verified) => { if (err || !verified) { winston.error(`[plugins/${pluginData.nbbId}] Unable to verify Akismet API key.`); akismetClient = null; } else { akismetCheckSpam = util.promisify(akismetClient.checkSpam).bind(akismetClient); akismetSubmitSpam = util.promisify(akismetClient.submitSpam).bind(akismetClient); akismetSubmitHam = util.promisify(akismetClient.submitHam).bind(akismetClient); } }); } else { winston.error(`[plugins/${pluginData.nbbId}] Akismet API Key not set!`); } } if (settings.honeypotEnabled === 'on') { if (settings.honeypotApiKey) { honeypot = Honeypot(settings.honeypotApiKey); } else { winston.error(`[plugins/${pluginData.nbbId}] Honeypot API Key not set!`); } } if (settings.recaptchaEnabled === 'on') { if (settings.recaptchaPublicKey && settings.recaptchaPrivateKey) { recaptchaArgs = { addLoginRecaptcha: settings.loginRecaptchaEnabled === 'on', publicKey: settings.recaptchaPublicKey, targetId: `${pluginData.nbbId}-recaptcha-target`, options: { // theme: settings.recaptchaTheme || 'clean', // todo: switch to custom theme, issue#9 theme: 'clean', hl: (Meta.config.defaultLang || 'en').toLowerCase(), tabindex: settings.recaptchaTabindex || 0, }, }; } } if (!settings.akismetMinReputationHam) { settings.akismetMinReputationHam = 10; } if (settings.stopforumspamApiKey) { stopforumspam.Key(settings.stopforumspamApiKey); } pluginSettings = settings; const routeHelpers = require.main.require('./src/routes/helpers'); routeHelpers.setupAdminPageRoute(params.router, `/admin/plugins/${pluginData.nbbId}`, renderAdmin); params.router.post( `/api/user/:userslug/${pluginData.nbbId}/report`, Plugin.middleware.isAdminOrGlobalMod, Plugin.middleware.checkStopForumSpam, Plugin.report ); params.router.post( `/api/user/:username/${pluginData.nbbId}/report/queue`, Plugin.middleware.isAdminOrGlobalMod, Plugin.middleware.checkStopForumSpam, Plugin.reportFromQueue ); }; async function renderAdmin(req, res) { let akismet = await db.getObject(`${pluginData.nbbId}:akismet`); akismet = { ...{ checks: 0, spam: 0 }, ...akismet }; res.render(`admin/plugins/${pluginData.nbbId}`, { nbbId: pluginData.nbbId, akismet, title: 'Spam Be Gone Fix', }); } // report an existing user account Plugin.report = async function (req, res, next) { try { const uid = await User.getUidByUserslug(req.params.userslug); if (!uid) { return next(new Error('[[error:no-user]]')); } const [isAdmin, fields, ips] = await Promise.all([ User.isAdministrator(uid), User.getUserFields(uid, ['username', 'email', 'uid']), User.getIPs(uid, 0), ]); if (isAdmin) { return res.status(403).send({ message: '[[spam-be-gone-fix:cant-report-admin]]' }); } const data = { ip: ips[0], email: fields.email, username: fields.username, }; await stopforumspam.submit(data, `Manual submission from user: ${req.uid} to user: ${fields.uid} via ${pluginData.id}`); res.status(200).json({ message: '[[spam-be-gone-fix:user-reported]]' }); } catch (err) { winston.error(`[plugins/${pluginData.nbbId}][report-error] ${err.message}`); res.status(400).json({ message: err.message || 'Something went wrong' }); } }; // report a user that is in the registration queue Plugin.reportFromQueue = async (req, res) => { const data = await db.getObject(`registration:queue:name:${req.params.username}`); if (!data) { res.status(400).json({ message: '[[error:no-user]]' }); } const submitData = { ip: data.ip, email: data.email, username: data.username, }; try { await stopforumspam.submit(submitData, `Manual submission from user: ${req.uid} to user: ${data.username} via ${pluginData.id}`); res.status(200).json({ message: '[[spam-be-gone-fix:user-reported]]' }); } catch (err) { winston.error(`[plugins/${pluginData.nbbId}][report-error] ${err.message}\n${JSON.stringify(submitData, null, 4)}`); res.status(400).json({ message: err.message || 'Something went wrong' }); } }; Plugin.appendConfig = async (data) => { data['spam-be-gone-fix'] = {}; const { hCaptchaEnabled, hCaptchaSiteKey } = await Meta.settings.get('spam-be-gone-fix'); if (hCaptchaEnabled === 'on') { data['spam-be-gone-fix'].hCaptcha = { key: hCaptchaSiteKey, }; } return data; }; Plugin.addCaptcha = async (data) => { function addCaptchaData(templateData, loginCaptchaEnabled, captcha) { if (templateData.regFormEntry && Array.isArray(templateData.regFormEntry)) { templateData.regFormEntry.push(captcha); } else if (Array.isArray(templateData.loginFormEntry)) { if (loginCaptchaEnabled) { templateData.loginFormEntry.push(captcha); } } else { templateData.captcha = captcha; } } if (recaptchaArgs) { if (data.templateData) { data.templateData.recaptchaArgs = recaptchaArgs; addCaptchaData(data.templateData, recaptchaArgs.addLoginRecaptcha, { label: 'Captcha', html: `<div id="${pluginData.nbbId}-recaptcha-target"></div>`, styleName: pluginData.nbbId, }); } } const { hCaptchaEnabled, loginhCaptchaEnabled } = await Meta.settings.get('spam-be-gone-fix'); if (hCaptchaEnabled === 'on') { if (data.templateData) { addCaptchaData(data.templateData, loginhCaptchaEnabled === 'on', { label: 'CAPTCHA', html: `<div id="h-captcha"></div>`, styleName: pluginData.nbbId, }); } } return data; }; Plugin.onPostEdit = async function (data) { const cid = await Topics.getTopicField(data.post.tid, 'cid'); await Plugin.checkReply({ content: data.post.content, uid: data.post.uid, cid: cid, req: data.req, }, { type: 'post', edit: true }); return data; }; Plugin.onTopicEdit = async function (data) { await Plugin.checkReply({ title: data.topic.title || '', uid: data.topic.uid, cid: data.topic.cid, req: data.req, }, { type: 'topic', edit: true }); return data; }; Plugin.onTopicPost = async function (data) { await Plugin.checkReply(data, { type: 'topic' }); return data; }; Plugin.onTopicReply = async function (data) { await Plugin.checkReply(data, { type: 'post' }); return data; }; Plugin.checkReply = async function (data, options) { options = options || {}; // http://akismet.com/development/api/#comment-check if (!akismetClient || !data || !data.req) { return; } if (data.fromQueue) { // don't check if submitted from queue return; } const [isAdmin, isModerator, userData] = await Promise.all([ User.isAdministrator(data.req.uid), User.isModerator(data.req.uid, data.cid), User.getUserFields(data.req.uid, ['username', 'reputation', 'email']), ]); if (isAdmin || isModerator) { return; } const akismetData = { referrer: data.req.headers.referer, user_ip: data.req.ip, user_agent: data.req.headers['user-agent'], permalink: nconf.get('url').replace(/\/$/, '') + data.req.path, comment_content: (data.title ? `${data.title}\n\n` : '') + (data.content || ''), comment_author: userData.username, comment_author_email: userData.email, // https://github.com/akhoury/nodebb-plugin-spam-be-gone/issues/54 comment_type: options.type === 'topic' ? 'forum-post' : 'comment', }; if (options.edit) { akismetData.recheck_reason = 'edit'; } const isSpam = await akismetCheckSpam(akismetData); await db.incrObjectField(`${pluginData.nbbId}:akismet`, 'checks'); if (!isSpam) { return; } await db.incrObjectField(`${pluginData.nbbId}:akismet`, 'spam'); if (parseInt(userData.reputation, 10) >= parseInt(pluginSettings.akismetMinReputationHam, 10)) { await akismetSubmitHam(akismetData); } winston.verbose(`[plugins/${pluginData.nbbId}] Post "${akismetData.comment_content}" by uid: ${data.req.uid} username: ${userData.username}@${data.req.ip} was flagged as spam and rejected.`); throw new Error('Post content was flagged as spam by Akismet.com'); }; Plugin.checkRegister = async function (data) { await Promise.all([ Plugin._honeypotCheck(data.req, data.userData), Plugin._recaptchaCheck(data.req, data.userData), Plugin._hcaptchaCheck(data.userData), ]); return data; }; Plugin.checkLogin = async function (data) { const { loginhCaptchaEnabled } = await Meta.settings.get('spam-be-gone-fix'); if (loginhCaptchaEnabled === 'on') { await Plugin._hcaptchaCheck(data.userData); } if (!recaptchaArgs || !recaptchaArgs.addLoginRecaptcha) { return data; } await Plugin._recaptchaCheck(data.req, data.res, data.userData); return data; }; Plugin.getRegistrationQueue = async function (data) { if (pluginSettings.stopforumspamEnabled) { await Promise.all(data.users.map(augmentWitSpamData)); } return data; }; async function augmentWitSpamData(user) { // temporary: see http://www.stopforumspam.com/forum/viewtopic.php?id=6392 try { user.ip = user.ip.replace('::ffff:', ''); let body = await stopforumspam.isSpammer({ ip: user.ip, email: user.email, username: user.username, f: 'json' }); // body === false, then just set the default non spam response, // which stopforumspam node module doesn't return it's spam, but some template rely on it if (!body) { body = { success: 1, username: { frequency: 0, appears: 0 }, email: { frequency: 0, appears: 0 }, ip: { frequency: 0, appears: 0, asn: null }, }; } user.spamChecked = true; user.spamData = body; user.usernameSpam = body.username ? (body.username.frequency > 0 || body.username.appears > 0) : true; user.emailSpam = body.email ? (body.email.frequency > 0 || body.email.appears > 0) : true; user.ipSpam = body.ip ? (body.ip.frequency > 0 || body.ip.appears > 0) : true; user.customActions = user.customActions || []; if (pluginSettings.stopforumspamApiKey) { user.customActions.push({ title: '[[spam-be-gone-fix:report-user]]', id: `report-spam-user-${user.username}`, class: 'btn-warning report-spam-user', icon: 'fa-flag', }); } } catch (err) { // original nodebb core implementation did not pass the error to the cb, so im keeping it that way // https://github.com/NodeBB/NodeBB/blob/2cd1be0d041892742300a2ba2d5f1087b6272071/src/user/approval.js#L260-L264 if (err) { winston.error(err); } } } Plugin.userProfileMenu = function (data, next) { if (pluginSettings.stopforumspamEnabled && pluginSettings.stopforumspamApiKey) { data.links.push({ id: 'spamBeGoneReportUserBtn', route: 'report-user', icon: 'fa-flag', name: '[[spam-be-gone-fix:report-user]]', visibility: { self: false, other: false, moderator: false, globalMod: true, admin: true, }, }); } next(null, data); }; Plugin.onPostFlagged = async function (data) { const flagObj = data.flag; // Don't do anything if flag is not for a post and not for "spam" reason if (flagObj.type !== 'post' || flagObj.description !== 'Spam') { return; } if (akismetClient && pluginSettings.akismetFlagReporting && parseInt(flagObj.reporter.reputation, 10) >= parseInt(pluginSettings.akismetFlagReporting, 10)) { const [userData, permalink, ip] = await Promise.all([ User.getUserFields(flagObj.target.uid, ['username', 'email']), Topics.getTopicField(flagObj.target.tid, 'slug'), db.getSortedSetRevRange(`uid:${flagObj.target.uid}:ip`, 0, 1), ]); // todo: we don't have access to the req here :/ const submitted = { user_ip: ip ? ip[0] : '', permalink: `${nconf.get('url').replace(/\/$/, '')}/topic/${permalink}`, comment_author: userData.username, comment_author_email: userData.email, comment_content: flagObj.target.content, comment_type: 'forum-post', }; try { await akismetSubmitSpam(submitted); winston.info('Spam reported to Akismet.', submitted); } catch (err) { winston.error(`Error reporting to Akismet ${err.message}\n${JSON.stringify(submitted, null, 4)}`); } } }; Plugin._honeypotCheck = async function (req, userData) { if (honeypot && req && req.ip) { const honeypotQuery = util.promisify(honeypot.query); const results = await honeypotQuery(req.ip); if (results && results.found && results.type) { if (results.type.spammer || results.type.suspicious) { const message = `${userData.username} | ${userData.email} was detected as ${(results.type.spammer ? 'spammer' : 'suspicious')}`; winston.warn(`[plugins/${pluginData.nbbId}] ${message} and was denied registration.`); throw new Error(message); } } else { winston.verbose(`[plugins/${pluginData.nbbId}] username: ${userData.username} ip: ${req.ip} was not found in Honeypot database`); } } }; Plugin._recaptchaCheck = async function (req) { if (recaptchaArgs && req && req.ip && req.body) { /* const postData = JSON.stringify({ secret: pluginSettings.recaptchaPrivateKey, response: req.body['g-recaptcha-response'], remoteip: req.ip }); */ const postData = "secret=" + pluginSettings.recaptchaPrivateKey + "&response=" + req.body['g-recaptcha-response'] + "&remoteip=" + req.ip; const options = { hostname: 'www.recaptcha.net', path: '/recaptcha/api/siteverify', method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': postData.length } }; return new Promise((resolve, reject) => { const request = https.request(options, (res) => { let responseData = ''; res.on('data', (chunk) => { responseData += chunk; }); res.on('end', () => { const response = JSON.parse(responseData); if (response.success === true) { resolve(); } else { const message = '[[spam-be-gone-fix:captcha-not-verified]]'; reject(new Error(message)); } }); }); request.on('error', (error) => { const message = error.message || '[[spam-be-gone-fix:captcha-not-verified]]'; reject(new Error(message)); }); request.write(postData); request.end(); }); /* try { console.log("token" + recaptchaToken.getrecaptchaToken()); } catch (err) { const message = err.Error || '[[spam-be-gone-fix:captcha-not-verified]]'; winston.verbose(`[plugins/${pluginData.nbbId}] ${message}`); throw new Error(message); } */ /* const simpleRecaptchaAsync = util.promisify(simpleRecaptcha); try { await simpleRecaptchaAsync( pluginSettings.recaptchaPrivateKey, req.ip, req.body['g-recaptcha-response'] ); } catch (err) { const message = err.Error || '[[spam-be-gone-fix:captcha-not-verified]]'; winston.verbose(`[plugins/${pluginData.nbbId}] ${message}`); throw new Error(message); } */ } }; Plugin._hcaptchaCheck = async (userData) => { const { hCaptchaEnabled, hCaptchaSecretKey } = await Meta.settings.get('spam-be-gone-fix'); if (hCaptchaEnabled !== 'on') { return; } const response = await hCaptcha.verify(hCaptchaSecretKey, userData['h-captcha-response']); if (!response.success) { throw new Error('[[spam-be-gone-fix:captcha-not-verified]]'); } }; Plugin.admin = { menu: function (custom_header, callback) { custom_header.plugins.push({ route: `/plugins/${pluginData.nbbId}`, icon: pluginData.faIcon, name: pluginData.name, }); callback(null, custom_header); }, };