diff --git a/src/core/ChatProvider.js b/src/core/ChatProvider.js index cac0e4d..cf18069 100644 --- a/src/core/ChatProvider.js +++ b/src/core/ChatProvider.js @@ -18,6 +18,7 @@ import { unmute, allowedChat, } from '../data/redis/chat'; +import { banIP } from '../data/sql/Ban'; import { DailyCron } from '../utils/cron'; import { escapeMd } from './utils'; import ttags from './ttag'; @@ -52,6 +53,7 @@ export class ChatProvider { this.enChannelId = 0; this.infoUserId = 1; this.eventUserId = 1; + this.autobanPhrase = null; this.apiSocketUserId = 1; this.caseCheck = /^[A-Z !.]*$/; this.cyrillic = /[\u0436-\u043B]'/; @@ -360,6 +362,22 @@ export class ChatProvider { return 'No country is currently muted from this channel'; } + case 'autoban': { + if (args[0]) { + this.autobanPhrase = args.join(' '); + if (this.autobanPhrase === 'unset' || this.autobanPhrase.length < 5) { + this.autobanPhrase = null; + } + return `Set autoban phrase on shard to ${this.autobanPhrase}`; + } + // eslint-disable-next-line + if (this.autobanPhrase) { + // eslint-disable-next-line + return `Current autoban phrase on shard is ${this.autobanPhrase}, use "/autoban unset" to remove it`; + } + return 'Autoban phrase is currently not set on this shard'; + } + default: return `Couln't parse command ${cmd}`; } @@ -387,6 +405,20 @@ export class ChatProvider { } const country = user.regUser.flag || 'xx'; + if (this.autobanPhrase && message.includes(this.autobanPhrase)) { + const { ipSub } = user; + if (!user.banned) { + banIP(ipSub, 'CHATBAN', 0, 1); + mute(id); + logger.info(`CHAT AUTOBANNED: ${ipSub}`); + user.banned = true; + } + return 'nope'; + } + if (user.banned) { + return 'nope'; + } + if (!user.userlvl) { const [allowed, needProxycheck] = await allowedChat( channelId, @@ -405,6 +437,8 @@ export class ChatProvider { } if (allowed === 101) { // eslint-disable-next-line max-len return t`You are permanently muted, join our guilded to apppeal the mute`; + } if (allowed === 102) { + return t`You must solve a captcha. Place a pixel to get one.`; } if (allowed === 2) { return t`You are banned`; } if (allowed === 3) { diff --git a/src/core/isAllowed.js b/src/core/isAllowed.js index 2a2532d..8e6c787 100644 --- a/src/core/isAllowed.js +++ b/src/core/isAllowed.js @@ -16,9 +16,16 @@ import { proxyLogger as logger } from './logger'; import { USE_PROXYCHECK, PROXYCHECK_KEY } from './config'; -const checker = (USE_PROXYCHECK && PROXYCHECK_KEY) - ? new ProxyCheck(PROXYCHECK_KEY, logger).checkIp - : () => ({ allowed: true, status: 0, pcheck: 'dummy' }); +// checker for IP address validity (proxy or vpn or not) +let checker = () => ({ allowed: true, status: 0, pcheck: 'dummy' }); +// checker for mail address (disposable or not) +let mailChecker = () => false; + +if (USE_PROXYCHECK && PROXYCHECK_KEY) { + const pc = new ProxyCheck(PROXYCHECK_KEY, logger); + checker = pc.checkIp; + mailChecker = pc.checkEmail; +} /* * save information of ip into database @@ -125,7 +132,7 @@ async function withCache(f, ip) { * check if ip is allowed * @param ip IP * @param disableCache if we fetch result from cache - * @return { + * @return Promise { * allowed: boolean if allowed to use site * , status: -2: not yet checked * -1: whitelisted @@ -143,4 +150,16 @@ function checkIfAllowed(ip, disableCache = false) { return withCache(checker, ip); } +/* + * check if email is disposable + * @param email + * @return Promise + * null: some error occured + * false: legit provider + * true: disposable + */ +export function checkIfMailDisposable(email) { + return mailChecker(email); +} + export default checkIfAllowed; diff --git a/src/core/passport.js b/src/core/passport.js index 243fad6..1015987 100644 --- a/src/core/passport.js +++ b/src/core/passport.js @@ -12,7 +12,7 @@ import RedditStrategy from 'passport-reddit/lib/passport-reddit/strategy'; import VkontakteStrategy from 'passport-vkontakte/lib/strategy'; import { sanitizeName } from '../utils/validation'; - +import logger from './logger'; import { RegUser } from '../data/sql'; import User, { regUserQueryInclude as include } from '../data/User'; import { auth } from './config'; @@ -70,7 +70,7 @@ passport.use(new JsonStrategy({ * OAuth SignIns, mail based * */ -async function oauthLogin(email, name, discordid = null) { +async function oauthLogin(provider, email, name, discordid = null) { if (!email) { throw new Error('You don\'t have a mail set in your account.'); } @@ -94,6 +94,8 @@ async function oauthLogin(email, name, discordid = null) { where: { name }, }); } + // eslint-disable-next-line max-len + logger.info(`Create new user from ${provider} oauth login ${email} / ${name}`); reguser = await RegUser.create({ email, name, @@ -121,7 +123,7 @@ passport.use(new FacebookStrategy({ try { const { displayName: name, emails } = profile; const email = emails[0].value; - const user = await oauthLogin(email, name); + const user = await oauthLogin('facebook', email, name); done(null, user); } catch (err) { done(err); @@ -144,7 +146,7 @@ passport.use(new DiscordStrategy({ 'Sorry, you can not use discord login with an discord account that does not have email set.', ); } - const user = await oauthLogin(email, name, id); + const user = await oauthLogin('discord', email, name, id); done(null, user); } catch (err) { done(err); @@ -162,7 +164,7 @@ passport.use(new GoogleStrategy({ try { const { displayName: name, emails } = profile; const email = emails[0].value; - const user = await oauthLogin(email, name); + const user = await oauthLogin('google', email, name); done(null, user); } catch (err) { done(err); @@ -201,6 +203,8 @@ passport.use(new RedditStrategy({ where: { name }, }); } + // eslint-disable-next-line max-len + logger.info(`Create new user from reddit oauth login ${name} / ${redditid}`); reguser = await RegUser.create({ name, verified: 1, @@ -234,7 +238,7 @@ passport.use(new VkontakteStrategy({ 'Sorry, you can not use vk login with an account that does not have a verified email set.', ); } - const user = await oauthLogin(email, name); + const user = await oauthLogin('vkontakte', email, name); done(null, user); } catch (err) { done(err); diff --git a/src/data/redis/chat.js b/src/data/redis/chat.js index 92d088c..f143fad 100644 --- a/src/data/redis/chat.js +++ b/src/data/redis/chat.js @@ -3,6 +3,8 @@ */ import client from './client'; import { PREFIX as ALLOWED_PREFIX } from './isAllowedCache'; +import { PREFIX as CAPTCHA_PREFIX } from './captcha'; +import { CAPTCHA_TIME } from '../../core/config'; const MUTE_PREFIX = 'MUTE_PREFIX'; const MUTEC_PREFIX = 'MUTE_PREFIXc'; @@ -22,8 +24,9 @@ export async function allowedChat( const mutecKey = `${MUTEC_PREFIX}:${channelId}`; const muteKey = `${MUTE_PREFIX}:${userId}`; const isalKey = `${ALLOWED_PREFIX}:${ip}`; + const captKey = (CAPTCHA_TIME >= 0) ? `${CAPTCHA_PREFIX}:${ip}` : 'nope'; return client.allowedChat( - mutecKey, muteKey, isalKey, + mutecKey, muteKey, isalKey, captKey, cc, ); } diff --git a/src/data/redis/client.js b/src/data/redis/client.js index ae65d30..fdf3068 100644 --- a/src/data/redis/client.js +++ b/src/data/redis/client.js @@ -18,7 +18,7 @@ const scripts = { transformReply(arr) { return arr.map((r) => Number(r)); }, }), allowedChat: defineScript({ - NUMBER_OF_KEYS: 3, + NUMBER_OF_KEYS: 4, SCRIPT: fs.readFileSync('./workers/lua/allowedChat.lua'), transformArguments(...args) { return args.map((a) => ((typeof a === 'string') ? a : a.toString())); diff --git a/src/data/redis/lua/allowedChat.lua b/src/data/redis/lua/allowedChat.lua index d437084..f0e1067 100644 --- a/src/data/redis/lua/allowedChat.lua +++ b/src/data/redis/lua/allowedChat.lua @@ -3,6 +3,7 @@ -- mutecKey: 'mutec:cid' hash of channel for country mutes -- muteKey: 'mute:uid' key for user mute -- isalKey: 'isal:ip' (proxycheck, blacklist, whitelist) +-- ishuman: 'human:ip' if captcha needed -- Args: -- cc: two letter country code of user -- Returns: @@ -10,6 +11,7 @@ -- 1: return status code -- 100: country muted -- 101: user permanently muted +-- 102: got captcha -- >0: isAllowed status code (see core/isAllowed) -- 0: success -- <0: time left for mute in seconds * -1 @@ -27,6 +29,12 @@ if ttl == -1 then ret[1] = 101 return ret end +-- check if captcha is needed +if KEYS[4] ~= "nope" and not redis.call('get', KEYS[4]) then + -- captcha + ret[1] = 102 + return ret +end if ttl > 0 then ret[1] = -ttl return ret diff --git a/src/routes/api/auth/register.js b/src/routes/api/auth/register.js index da88ea8..cad6e48 100644 --- a/src/routes/api/auth/register.js +++ b/src/routes/api/auth/register.js @@ -5,6 +5,7 @@ import { RegUser } from '../../../data/sql'; import mailProvider from '../../../core/MailProvider'; import getMe from '../../../core/me'; import { getIPFromRequest, getHostFromRequest } from '../../../utils/ip'; +import { checkIfMailDisposable } from '../../../core/isAllowed'; import { validateEMail, validateName, @@ -17,7 +18,11 @@ import { async function validate(email, name, password, captcha, captchaid, t, gettext) { const errors = []; const emailerror = gettext(validateEMail(email)); - if (emailerror) errors.push(emailerror); + if (emailerror) { + errors.push(emailerror); + } else if (await checkIfMailDisposable(email)) { + errors.push(t`This email provider is not allowed`); + } const nameerror = validateName(name); if (nameerror) errors.push(nameerror); const passworderror = gettext(validatePassword(password)); diff --git a/src/utils/ProxyCheck.js b/src/utils/ProxyCheck.js index fd2e994..98dba52 100644 --- a/src/utils/ProxyCheck.js +++ b/src/utils/ProxyCheck.js @@ -157,6 +157,7 @@ class PcKeyProvider { } /* + * TODO: proxycheck added the used burst token to API * query the API for limits * @param key */ @@ -229,7 +230,7 @@ class ProxyCheck { * queue of ip-checking tasks * [[ip, callbackFunction],...] */ - this.ipQueue = []; + this.queue = []; this.fetching = false; this.checkFromQueue = this.checkFromQueue.bind(this); this.checkIp = this.checkIp.bind(this); @@ -237,7 +238,7 @@ class ProxyCheck { this.logger = logger; } - reqProxyCheck(ips) { + reqProxyCheck(values) { return new Promise((resolve, reject) => { const key = this.pcKeyProvider.getKey(); if (!key) { @@ -247,7 +248,7 @@ class ProxyCheck { ); return; } - const postData = `ips=${ips.join(',')}`; + const postData = `ips=${values.join(',')}`; const options = { hostname: 'proxycheck.io', @@ -276,16 +277,17 @@ class ProxyCheck { const jsonString = data.join(''); const result = JSON.parse(jsonString); if (result.status !== 'ok') { - if (result.status === 'error' && ips.length === 1) { + if (result.status === 'error' && values.length === 1) { /* * invalid ip, like a link local address * Error is either thrown in the top, when requesting only one ip * or in the ip-part as "error": "No valid.." when multiple * */ resolve({ - [ips[0]]: { + [values[0]]: { proxy: 'yes', type: 'Invalid IP', + disposable: 'yes', }, }); return; @@ -299,11 +301,12 @@ class ProxyCheck { this.logger.warn(`Warning: ${key}: ${result.message}`); } } - ips.forEach((ip) => { - if (result[ip] && result[ip].error) { - result[ip] = { + values.forEach((value) => { + if (result[value] && result[value].error) { + result[value] = { proxy: 'yes', type: 'Invalid IP', + disposable: 'yes', }; } }); @@ -330,45 +333,57 @@ class ProxyCheck { } async checkFromQueue() { - const { ipQueue } = this; - if (!ipQueue.length) { + const { queue } = this; + if (!queue.length) { this.fetching = false; return; } this.fetching = true; - const tasks = ipQueue.slice(0, 50); - const ips = tasks.map((i) => i[0]); + const tasks = queue.slice(0, 50); + const values = tasks.map((i) => i[0]); let res = {}; try { - res = await this.reqProxyCheck(ips); + res = await this.reqProxyCheck(values); } catch (err) { this.logger.error(`Eroor: ${err.message}`); } for (let i = 0; i < tasks.length; i += 1) { const task = tasks[i]; - const pos = ipQueue.indexOf(task); - if (~pos) ipQueue.splice(pos, 1); + const pos = queue.indexOf(task); + if (~pos) queue.splice(pos, 1); - const [ip, cb] = task; + const [value, cb] = task; - let allowed = true; - let status = -2; - let pcheck = 'N/A'; + if (~value.indexOf('@')) { + // email check + let disposable = null; - if (res[ip]) { - this.logger.info(`${ip}: ${JSON.stringify(res[ip])}`); - const { proxy, type, city } = res[ip]; - allowed = proxy === 'no'; - status = (allowed) ? 0 : 1; - pcheck = `${type},${city}`; + if (res[value]) { + disposable = !!res[value].disposable; + } + + cb(disposable); + } else { + // ip check + let allowed = true; + let status = -2; + let pcheck = 'N/A'; + + if (res[value]) { + this.logger.info(`${value}: ${JSON.stringify(res[value])}`); + const { proxy, type, city } = res[value]; + allowed = proxy === 'no'; + status = (allowed) ? 0 : 1; + pcheck = `${type},${city}`; + } + + cb({ + allowed, + status, + pcheck, + }); } - - cb({ - allowed, - status, - pcheck, - }); } setTimeout(this.checkFromQueue, 10); } @@ -385,7 +400,26 @@ class ProxyCheck { */ checkIp(ip) { return new Promise((resolve) => { - this.ipQueue.push([ip, resolve]); + this.queue.push([ip, resolve]); + if (!this.fetching) { + this.checkFromQueue(); + } + }); + } + + /* + * same as for ip + * TODO: cache for mail providers, remember + * a disposable provider for an hour or so + * @param email + * @return Promise that resolves to + * null: failure + * false: is legit provider + * true: is disposable provider + */ + checkEmail(email) { + return new Promise((resolve) => { + this.queue.push([email, resolve]); if (!this.fetching) { this.checkFromQueue(); }