diff --git a/src/controllers/admin/groups.js b/src/controllers/admin/groups.js index 51614c7a7fe0..c099d2093e3f 100644 --- a/src/controllers/admin/groups.js +++ b/src/controllers/admin/groups.js @@ -67,7 +67,11 @@ groupsController.get = async function (req, res, next) { async function getGroupNames() { const groupNames = await db.getSortedSetRange('groups:createtime', 0, -1); - return groupNames.filter(name => name !== 'registered-users' && !groups.isPrivilegeGroup(name)); + return groupNames.filter(name => name !== 'registered-users' && + name !== 'verified-users' && + name !== 'unverified-users' && + !groups.isPrivilegeGroup(name) + ); } groupsController.getCSV = async function (req, res) { diff --git a/src/controllers/admin/users.js b/src/controllers/admin/users.js index 79e023db55f0..e86adb213dce 100644 --- a/src/controllers/admin/users.js +++ b/src/controllers/admin/users.js @@ -53,8 +53,11 @@ async function getUsers(req, res) { if (sortBy) { set.push(sortToSet[sortBy]); } - if (filterBy.includes('notvalidated')) { - set.push('users:notvalidated'); + if (filterBy.includes('unverified')) { + set.push('group:unverified-users:members'); + } + if (filterBy.includes('verified')) { + set.push('group:verified-users:members'); } if (filterBy.includes('banned')) { set.push('users:banned'); @@ -219,9 +222,8 @@ async function getInvites() { function render(req, res, data) { data.pagination = pagination.create(data.page, data.pageCount, req.query); - data.requireEmailConfirmation = meta.config.requireEmailConfirmation; - var registrationType = meta.config.registrationType; + const registrationType = meta.config.registrationType; data.inviteOnly = registrationType === 'invite-only' || registrationType === 'admin-invite-only'; data.adminInviteOnly = registrationType === 'admin-invite-only'; diff --git a/src/groups/create.js b/src/groups/create.js index 20fe4ae4657c..8260e24fa857 100644 --- a/src/groups/create.js +++ b/src/groups/create.js @@ -67,7 +67,7 @@ module.exports = function (Groups) { function isSystemGroup(data) { return data.system === true || parseInt(data.system, 10) === 1 || - data.name === 'administrators' || data.name === 'registered-users' || data.name === 'Global Moderators' || + Groups.systemGroups.includes(data.name) || Groups.isPrivilegeGroup(data.name); } diff --git a/src/groups/index.js b/src/groups/index.js index bbcb0cde576e..eb3f507518e5 100644 --- a/src/groups/index.js +++ b/src/groups/index.js @@ -25,6 +25,14 @@ require('./cache')(Groups); Groups.ephemeralGroups = ['guests', 'spiders']; +Groups.systemGroups = [ + 'registered-users', + 'verified-users', + 'unverified-users', + 'administrators', + 'Global Moderators', +]; + Groups.getEphemeralGroup = function (groupName) { return { name: groupName, diff --git a/src/groups/join.js b/src/groups/join.js index 06519ce57b95..d29808c612c5 100644 --- a/src/groups/join.js +++ b/src/groups/join.js @@ -82,7 +82,7 @@ module.exports = function (Groups) { }); } catch (err) { if (err && err.message !== '[[error:group-already-exists]]') { - winston.error('[groups.join] Could not create new hidden group', err.stack); + winston.error('[groups.join] Could not create new hidden group (' + groupName + ')\n' + err.stack); throw err; } } diff --git a/src/messaging/edit.js b/src/messaging/edit.js index 303ff5a2e1d5..ca1e5a80ebda 100644 --- a/src/messaging/edit.js +++ b/src/messaging/edit.js @@ -3,6 +3,7 @@ const meta = require('../meta'); const user = require('../user'); const plugins = require('../plugins'); +const privileges = require('../privileges'); const sockets = require('../socket.io'); @@ -52,12 +53,13 @@ module.exports = function (Messaging) { throw new Error('[[error:chat-message-editing-disabled]]'); } - const userData = await user.getUserFields(uid, ['banned', 'email:confirmed']); + const userData = await user.getUserFields(uid, ['banned']); if (userData.banned) { throw new Error('[[error:user-banned]]'); } - if (meta.config.requireEmailConfirmation && !userData['email:confirmed']) { - throw new Error('[[error:email-not-confirmed]]'); + const canChat = await privileges.global.can('chat', uid); + if (!canChat) { + throw new Error('[[error:no-privileges]]'); } const [isAdmin, messageData] = await Promise.all([ diff --git a/src/messaging/index.js b/src/messaging/index.js index 9a05e682e9a6..757884e9a84d 100644 --- a/src/messaging/index.js +++ b/src/messaging/index.js @@ -5,6 +5,7 @@ const validator = require('validator'); const db = require('../database'); const user = require('../user'); +const privileges = require('../privileges'); const plugins = require('../plugins'); const meta = require('../meta'); const utils = require('../utils'); @@ -201,13 +202,13 @@ Messaging.canMessageUser = async (uid, toUid) => { throw new Error('[[error:no-user]]'); } - const userData = await user.getUserFields(uid, ['banned', 'email:confirmed']); + const userData = await user.getUserFields(uid, ['banned']); if (userData.banned) { throw new Error('[[error:user-banned]]'); } - - if (meta.config.requireEmailConfirmation && !userData['email:confirmed']) { - throw new Error('[[error:email-not-confirmed-chat]]'); + const canChat = await privileges.global.can('chat', uid); + if (!canChat) { + throw new Error('[[error:no-privileges]]'); } const results = await utils.promiseParallel({ @@ -237,13 +238,13 @@ Messaging.canMessageRoom = async (uid, roomId) => { throw new Error('[[error:not-in-room]]'); } - const userData = await user.getUserFields(uid, ['banned', 'email:confirmed']); + const userData = await user.getUserFields(uid, ['banned']); if (userData.banned) { throw new Error('[[error:user-banned]]'); } - - if (meta.config.requireEmailConfirmation && !userData['email:confirmed']) { - throw new Error('[[error:email-not-confirmed-chat]]'); + const canChat = await privileges.global.can('chat', uid); + if (!canChat) { + throw new Error('[[error:no-privileges]]'); } await plugins.fireHook('static:messaging.canMessageRoom', { diff --git a/src/privileges/helpers.js b/src/privileges/helpers.js index 93e189f6a9e5..ab596bf39536 100644 --- a/src/privileges/helpers.js +++ b/src/privileges/helpers.js @@ -119,13 +119,15 @@ helpers.getGroupPrivileges = async function (cid, groupPrivileges) { groupNames = groups.ephemeralGroups.concat(groupNames); moveToFront(groupNames, 'Global Moderators'); + moveToFront(groupNames, 'unverified-users'); + moveToFront(groupNames, 'verified-users'); moveToFront(groupNames, 'registered-users'); const adminIndex = groupNames.indexOf('administrators'); if (adminIndex !== -1) { groupNames.splice(adminIndex, 1); } - const groupData = await groups.getGroupsFields(groupNames, ['private']); + const groupData = await groups.getGroupsFields(groupNames, ['private', 'system']); const memberData = groupNames.map(function (member, index) { const memberPrivs = {}; @@ -137,6 +139,7 @@ helpers.getGroupPrivileges = async function (cid, groupPrivileges) { nameEscaped: translator.escape(validator.escape(member)), privileges: memberPrivs, isPrivate: groupData[index] && !!groupData[index].private, + isSystem: groupData[index] && !!groupData[index].system, }; }); return memberData; diff --git a/src/socket.io/admin/user.js b/src/socket.io/admin/user.js index 990869bb7e2d..3bd5f999dce6 100644 --- a/src/socket.io/admin/user.js +++ b/src/socket.io/admin/user.js @@ -80,7 +80,10 @@ User.validateEmail = async function (socket, uids) { uids = uids.filter(uid => parseInt(uid, 10)); await db.setObjectField(uids.map(uid => 'user:' + uid), 'email:confirmed', 1); - await db.sortedSetRemove('users:notvalidated', uids); + for (const uid of uids) { + await groups.join('verified-users', uid); + await groups.leave('unverified-users', uid); + } }; User.sendValidationEmail = async function (socket, uids) { diff --git a/src/upgrade.js b/src/upgrade.js index 0ab82a135f24..2136913c684e 100644 --- a/src/upgrade.js +++ b/src/upgrade.js @@ -129,6 +129,7 @@ Upgrade.process = async function (files, skipCount) { const version = path.dirname(file).split('/').pop(); const progress = { current: 0, + counter: 0, total: 0, incr: Upgrade.incrementProgress, script: scriptExport, @@ -177,9 +178,11 @@ Upgrade.incrementProgress = function (value) { } this.current += value || 1; + this.counter += value || 1; + const step = (this.total ? Math.floor(this.total / 100) : 100); - // Redraw the progress bar every 100 units - if (this.current % (this.total ? Math.floor(this.total / 100) : 100) === 0 || this.current === this.total) { + if (this.counter > step || this.current >= this.total) { + this.counter -= step; var percentage = 0; var filled = 0; var unfilled = 15; diff --git a/src/upgrades/1.15.0/verified_users_group.js b/src/upgrades/1.15.0/verified_users_group.js new file mode 100644 index 000000000000..1dae396938ca --- /dev/null +++ b/src/upgrades/1.15.0/verified_users_group.js @@ -0,0 +1,93 @@ +'use strict'; + +const db = require('../../database'); + +const batch = require('../../batch'); +const user = require('../../user'); +const groups = require('../../groups'); +const meta = require('../../meta'); +const privileges = require('../../privileges'); + +module.exports = { + name: 'Create verified/unverified user groups', + timestamp: Date.UTC(2020, 9, 13), + method: async function () { + const progress = this.progress; + const timestamp = await db.getObjectField('group:administrators', 'timestamp'); + const verifiedExists = await groups.exists('verified-users'); + if (!verifiedExists) { + await groups.create({ + name: 'verified-users', + hidden: 1, + private: 1, + system: 1, + disableLeave: 1, + disableJoinRequests: 1, + timestamp: timestamp + 1, + }); + } + const unverifiedExists = await groups.exists('unverified-users'); + if (!unverifiedExists) { + await groups.create({ + name: 'unverified-users', + hidden: 1, + private: 1, + system: 1, + disableLeave: 1, + disableJoinRequests: 1, + timestamp: timestamp + 1, + }); + } + + await batch.processSortedSet('users:joindate', async function (uids) { + progress.incr(uids.length); + const userData = await user.getUsersFields(uids, ['uid', 'email:confirmed']); + + const verified = userData.filter(u => parseInt(u['email:confirmed'], 10) === 1); + const unverified = userData.filter(u => parseInt(u['email:confirmed'], 10) !== 1); + + for (const user of verified) { + // eslint-disable-next-line no-await-in-loop + await groups.join('verified-users', user.uid); + } + for (const user of unverified) { + // eslint-disable-next-line no-await-in-loop + await groups.join('unverified-users', user.uid); + } + }, { + batch: 500, + progress: this.progress, + }); + + await db.delete('users:notvalidated'); + + + const cids = await db.getSortedSetRevRange('categories:cid', 0, -1); + const canChat = await privileges.global.canGroup('chat', 'registered-users'); + // if email confirmation is required + // give chat, posting privs to "verified-users" group + // remove chat, posting privs from "registered-users" group + if (1 || meta.config.requireEmailConfirmation) { + if (canChat) { + await privileges.global.give(['groups:chat'], 'verified-users'); + await privileges.global.rescind(['groups:chat'], 'registered-users'); + } + for (const cid of cids) { + /* eslint-disable no-await-in-loop */ + const data = await privileges.categories.list(cid); + + const registeredUsersPrivs = data.groups.find(d => d.name === 'registered-users').privileges; + + if (registeredUsersPrivs['groups:topics:create']) { + await privileges.categories.give(['groups:topics:create'], cid, 'verified-users'); + await privileges.categories.rescind(['groups:topics:create'], cid, 'registered-users'); + } + + if (registeredUsersPrivs['groups:topics:reply']) { + await privileges.categories.give(['groups:topics:reply'], cid, 'verified-users'); + await privileges.categories.rescind(['groups:topics:reply'], cid, 'registered-users'); + } + } + } + }, +}; diff --git a/src/user/create.js b/src/user/create.js index e0cf8f18cacf..69caf97877a1 100644 --- a/src/user/create.js +++ b/src/user/create.js @@ -86,9 +86,6 @@ module.exports = function (User) { ['users:reputation', 0, userData.uid], ]; - if (parseInt(userData.uid, 10) !== 1) { - bulkAdd.push(['users:notvalidated', timestamp, userData.uid]); - } if (userData.email) { bulkAdd.push(['email:uid', userData.uid, userData.email.toLowerCase()]); bulkAdd.push(['email:sorted', 0, userData.email.toLowerCase() + ':' + userData.uid]); @@ -99,10 +96,15 @@ module.exports = function (User) { bulkAdd.push(['fullname:sorted', 0, userData.fullname.toLowerCase() + ':' + userData.uid]); } + const groupsToJoin = ['registered-users'].concat( + parseInt(userData.uid, 10) !== 1 ? + 'unverified-users' : 'verified-users' + ); + await Promise.all([ db.incrObjectField('global', 'userCount'), db.sortedSetAddBulk(bulkAdd), - groups.join('registered-users', userData.uid), + groups.join(groupsToJoin, userData.uid), User.notifications.sendWelcomeNotification(userData.uid), storePassword(userData.uid, data.password), User.updateDigestSetting(userData.uid, meta.config.dailyDigestFreq), diff --git a/src/user/delete.js b/src/user/delete.js index e5d2a6850ea3..96dd6029c8ba 100644 --- a/src/user/delete.js +++ b/src/user/delete.js @@ -81,7 +81,6 @@ module.exports = function (User) { 'users:banned:expire', 'users:flags', 'users:online', - 'users:notvalidated', 'digest:day:uids', 'digest:week:uids', 'digest:month:uids', diff --git a/src/user/email.js b/src/user/email.js index 9e75a40f48f7..9fab6c9ca20f 100644 --- a/src/user/email.js +++ b/src/user/email.js @@ -9,6 +9,7 @@ var plugins = require('../plugins'); var db = require('../database'); var meta = require('../meta'); var emailer = require('../emailer'); +const groups = require('../groups'); var UserEmail = module.exports; @@ -96,8 +97,9 @@ UserEmail.confirm = async function (code) { throw new Error('[[error:invalid-email]]'); } await user.setUserField(confirmObj.uid, 'email:confirmed', 1); + await groups.join('verified-users', confirmObj.uid); + await groups.leave('unverified-users', confirmObj.uid); await db.delete('confirm:' + code); await db.delete('uid:' + confirmObj.uid + ':confirm:email:sent'); - await db.sortedSetRemove('users:notvalidated', confirmObj.uid); await plugins.fireHook('action:user.email.confirmed', { uid: confirmObj.uid, email: confirmObj.email }); }; diff --git a/src/user/posts.js b/src/user/posts.js index e7e917484522..9f85b3456ade 100644 --- a/src/user/posts.js +++ b/src/user/posts.js @@ -18,7 +18,7 @@ module.exports = function (User) { return; } const [userData, isAdminOrMod] = await Promise.all([ - User.getUserFields(uid, ['uid', 'banned', 'joindate', 'email', 'email:confirmed', 'reputation'].concat([field])), + User.getUserFields(uid, ['uid', 'banned', 'joindate', 'email', 'reputation'].concat([field])), privileges.categories.isAdminOrMod(cid, uid), ]); @@ -34,16 +34,12 @@ module.exports = function (User) { throw new Error('[[error:user-banned]]'); } - if (meta.config.requireEmailConfirmation && !userData['email:confirmed']) { - throw new Error('[[error:email-not-confirmed]]'); - } - - var now = Date.now(); + const now = Date.now(); if (now - userData.joindate < meta.config.initialPostDelay * 1000) { throw new Error('[[error:user-too-new, ' + meta.config.initialPostDelay + ']]'); } - var lasttime = userData[field] || 0; + const lasttime = userData[field] || 0; if (meta.config.newbiePostDelay > 0 && meta.config.newbiePostDelayThreshold > userData.reputation && now - lasttime < meta.config.newbiePostDelay * 1000) { throw new Error('[[error:too-many-posts-newbie, ' + meta.config.newbiePostDelay + ', ' + meta.config.newbiePostDelayThreshold + ']]'); diff --git a/src/user/profile.js b/src/user/profile.js index 229c5243bd80..e6dab413fbaf 100644 --- a/src/user/profile.js +++ b/src/user/profile.js @@ -234,9 +234,10 @@ module.exports = function (User) { ['email:uid', uid, newEmail.toLowerCase()], ['email:sorted', 0, newEmail.toLowerCase() + ':' + uid], ['user:' + uid + ':emails', Date.now(), newEmail + ':' + Date.now()], - ['users:notvalidated', Date.now(), uid], ]), User.setUserFields(uid, { email: newEmail, 'email:confirmed': 0 }), + groups.leave('verified-users', uid), + groups.join('unverified-users', uid), User.reset.cleanByUid(uid), ]); diff --git a/src/user/reset.js b/src/user/reset.js index d8765b9401ce..91ac9627da01 100644 --- a/src/user/reset.js +++ b/src/user/reset.js @@ -4,6 +4,7 @@ var nconf = require('nconf'); var winston = require('winston'); var user = require('./index'); +const groups = require('../groups'); var utils = require('../utils'); var batch = require('../batch'); @@ -70,11 +71,12 @@ UserReset.commit = async function (code, password) { const hash = await user.hashPassword(password); await user.setUserFields(uid, { password: hash, 'email:confirmed': 1 }); + await groups.join('verified-users', uid); + await groups.leave('unverified-users', uid); await db.deleteObjectField('reset:uid', code); await db.sortedSetRemoveBulk([ ['reset:issueDate', code], ['reset:issueDate:uid', uid], - ['users:notvalidated', uid], ]); await user.reset.updateExpiry(uid); await user.auth.resetLockout(uid); diff --git a/test/user.js b/test/user.js index ba75b773be07..ee8295f11a98 100644 --- a/test/user.js +++ b/test/user.js @@ -2073,34 +2073,23 @@ describe('User', function () { }); }); - it('should confirm email of user', function (done) { - var email = 'confirm@me.com'; - User.create({ + it('should confirm email of user', async function () { + const email = 'confirm@me.com'; + const uid = await User.create({ username: 'confirme', email: email, - }, function (err, uid) { - assert.ifError(err); - User.email.sendValidationEmail(uid, email, function (err, code) { - assert.ifError(err); - User.email.confirm(code, function (err) { - assert.ifError(err); - - async.parallel({ - confirmed: function (next) { - db.getObjectField('user:' + uid, 'email:confirmed', next); - }, - isMember: function (next) { - db.isSortedSetMember('users:notvalidated', uid, next); - }, - }, function (err, results) { - assert.ifError(err); - assert.equal(results.confirmed, 1); - assert.equal(results.isMember, false); - done(); - }); - }); - }); }); + + const code = await User.email.sendValidationEmail(uid, email); + const unverified = await groups.isMember(uid, 'unverified-users'); + assert.strictEqual(unverified, true); + await User.email.confirm(code); + const [confirmed, isVerified] = await Promise.all([ + db.getObjectField('user:' + uid, 'email:confirmed'), + groups.isMember(uid, 'verified-users', uid), + ]); + assert.strictEqual(parseInt(confirmed, 10), 1); + assert.strictEqual(isVerified, true); }); });