From f161bbf956a4115b4489c8eb984c643052914ca1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Tue, 15 Aug 2017 12:59:40 -0400 Subject: [PATCH] Post queue (#5878) * #5876 * add user to post queue table * add time of post into queue add posting restrictions to queue * fix shouldQueueLogic for guests * add test for post-queue route --- .../en-GB/admin/manage/post-queue.json | 8 + public/language/en-GB/admin/menu.json | 1 + .../language/en-GB/admin/settings/post.json | 2 + public/language/en-GB/success.json | 1 + public/src/admin/manage/post-queue.js | 25 +++ src/controllers/admin.js | 1 + src/controllers/admin/postqueue.js | 66 ++++++++ src/controllers/globalmods.js | 15 ++ src/posts.js | 1 + src/posts/queue.js | 153 ++++++++++++++++++ src/routes/admin.js | 1 + src/routes/index.js | 1 + src/socket.io/posts.js | 40 ++++- src/socket.io/topics.js | 18 ++- src/views/admin/manage/post-queue.tpl | 59 +++++++ src/views/admin/partials/menu.tpl | 2 + src/views/admin/settings/post.tpl | 11 ++ test/controllers-admin.js | 25 +++ test/posts.js | 76 ++++++++- 19 files changed, 502 insertions(+), 4 deletions(-) create mode 100644 public/language/en-GB/admin/manage/post-queue.json create mode 100644 public/src/admin/manage/post-queue.js create mode 100644 src/controllers/admin/postqueue.js create mode 100644 src/posts/queue.js create mode 100644 src/views/admin/manage/post-queue.tpl diff --git a/public/language/en-GB/admin/manage/post-queue.json b/public/language/en-GB/admin/manage/post-queue.json new file mode 100644 index 0000000000..5c52e25538 --- /dev/null +++ b/public/language/en-GB/admin/manage/post-queue.json @@ -0,0 +1,8 @@ +{ + "post-queue": "Post Queue", + "description": "There are no posts in the post queue.
To enable this feature, go to Settings → Post → Posting Restrictions and enable Post Queue.", + "user": "User", + "title": "Title", + "content": "Content", + "posted": "Posted" +} \ No newline at end of file diff --git a/public/language/en-GB/admin/menu.json b/public/language/en-GB/admin/menu.json index 985c540e8a..3ec4ad41c4 100644 --- a/public/language/en-GB/admin/menu.json +++ b/public/language/en-GB/admin/menu.json @@ -12,6 +12,7 @@ "manage/tags": "Tags", "manage/users": "Users", "manage/registration": "Registration Queue", + "manage/post-queue": "Post Queue", "manage/groups": "Groups", "manage/ip-blacklist": "IP Blacklist", diff --git a/public/language/en-GB/admin/settings/post.json b/public/language/en-GB/admin/settings/post.json index aca8b39d64..a789025597 100644 --- a/public/language/en-GB/admin/settings/post.json +++ b/public/language/en-GB/admin/settings/post.json @@ -6,6 +6,8 @@ "sorting.most-votes": "Most Votes", "sorting.topic-default": "Default Topic Sorting", "restrictions": "Posting Restrictions", + "restrictions.post-queue": "Enable post queue", + "restrictions.post-queue-help": "Enabling post queue will put the posts of new users in a queue for approval.", "restrictions.seconds-between": "Seconds between Posts", "restrictions.seconds-between-new": "Seconds between Posts for New Users", "restrictions.rep-threshold": "Reputation threshold before this restriction is lifted", diff --git a/public/language/en-GB/success.json b/public/language/en-GB/success.json index 497b6fcd20..17093d3efe 100644 --- a/public/language/en-GB/success.json +++ b/public/language/en-GB/success.json @@ -1,6 +1,7 @@ { "success": "Success", "topic-post": "You have successfully posted.", + "post-queued": "Your post is queued for approval.", "authentication-successful": "Authentication Successful", "settings-saved": "Settings saved!" } \ No newline at end of file diff --git a/public/src/admin/manage/post-queue.js b/public/src/admin/manage/post-queue.js new file mode 100644 index 0000000000..2bb9931dec --- /dev/null +++ b/public/src/admin/manage/post-queue.js @@ -0,0 +1,25 @@ +'use strict'; + + +define('admin/manage/post-queue', function () { + var PostQueue = {}; + + PostQueue.init = function () { + $('.posts-list').on('click', '[data-action]', function () { + var parent = $(this).parents('[data-id]'); + var action = $(this).attr('data-action'); + var id = parent.attr('data-id'); + var method = action === 'accept' ? 'posts.accept' : 'posts.reject'; + + socket.emit(method, { id: id }, function (err) { + if (err) { + return app.alertError(err.message); + } + parent.remove(); + }); + return false; + }); + }; + + return PostQueue; +}); diff --git a/src/controllers/admin.js b/src/controllers/admin.js index 91922bec0b..6ef000fa14 100644 --- a/src/controllers/admin.js +++ b/src/controllers/admin.js @@ -4,6 +4,7 @@ var adminController = { dashboard: require('./admin/dashboard'), categories: require('./admin/categories'), tags: require('./admin/tags'), + postQueue: require('./admin/postqueue'), blacklist: require('./admin/blacklist'), groups: require('./admin/groups'), appearance: require('./admin/appearance'), diff --git a/src/controllers/admin/postqueue.js b/src/controllers/admin/postqueue.js new file mode 100644 index 0000000000..e401f7dedd --- /dev/null +++ b/src/controllers/admin/postqueue.js @@ -0,0 +1,66 @@ +'use strict'; + +var async = require('async'); + +var db = require('../../database'); +var user = require('../../user'); +var pagination = require('../../pagination'); +var utils = require('../../utils'); + +var postQueueController = module.exports; + +postQueueController.get = function (req, res, next) { + var page = parseInt(req.query.page, 10) || 1; + var postsPerPage = 20; + var pageCount = 0; + + var start = (page - 1) * postsPerPage; + var stop = start + postsPerPage - 1; + + var postData; + + async.waterfall([ + function (next) { + async.parallel({ + count: function (next) { + db.sortedSetCard('post:queue', next); + }, + ids: function (next) { + db.getSortedSetRange('post:queue', start, stop, next); + }, + }, next); + }, + function (results, next) { + pageCount = Math.ceil(results.count / postsPerPage); + + var keys = results.ids.map(function (id) { + return 'post:queue:' + id; + }); + + db.getObjects(keys, next); + }, + function (data, next) { + postData = data; + data.forEach(function (data) { + data.data = JSON.parse(data.data); + data.data.timestampISO = utils.toISOString(data.data.timestamp); + return data; + }); + var uids = data.map(function (data) { + return data && data.uid; + }); + user.getUsersFields(uids, ['username', 'userslug', 'picture'], next); + }, + function (userData) { + postData.forEach(function (postData, index) { + postData.user = userData[index]; + }); + + res.render('admin/manage/post-queue', { + title: '[[pages:post-queue]]', + posts: postData, + pagination: pagination.create(page, pageCount), + }); + }, + ], next); +}; diff --git a/src/controllers/globalmods.js b/src/controllers/globalmods.js index 1cae97a4e2..19a5bd02ee 100644 --- a/src/controllers/globalmods.js +++ b/src/controllers/globalmods.js @@ -4,6 +4,7 @@ var async = require('async'); var user = require('../user'); var adminBlacklistController = require('./admin/blacklist'); +var adminPostQueueController = require('./admin/postqueue'); var globalModsController = module.exports; @@ -20,3 +21,17 @@ globalModsController.ipBlacklist = function (req, res, next) { }, ], next); }; + +globalModsController.postQueue = function (req, res, next) { + async.waterfall([ + function (next) { + user.isAdminOrGlobalMod(req.uid, next); + }, + function (isAdminOrGlobalMod, next) { + if (!isAdminOrGlobalMod) { + return next(); + } + adminPostQueueController.get(req, res, next); + }, + ], next); +}; diff --git a/src/posts.js b/src/posts.js index 7c4678830f..f6b22b89ed 100644 --- a/src/posts.js +++ b/src/posts.js @@ -24,6 +24,7 @@ require('./posts/recent')(Posts); require('./posts/tools')(Posts); require('./posts/votes')(Posts); require('./posts/bookmarks')(Posts); +require('./posts/queue')(Posts); Posts.exists = function (pid, callback) { db.isSortedSetMember('posts:pid', pid, callback); diff --git a/src/posts/queue.js b/src/posts/queue.js new file mode 100644 index 0000000000..3b9f555f9d --- /dev/null +++ b/src/posts/queue.js @@ -0,0 +1,153 @@ +'use strict'; + +var async = require('async'); + +var db = require('../database'); +var user = require('../user'); +var meta = require('../meta'); +var topics = require('../topics'); +var privileges = require('../privileges'); +var socketHelpers = require('../socket.io/helpers'); + +module.exports = function (Posts) { + Posts.shouldQueue = function (uid, data, callback) { + async.waterfall([ + function (next) { + user.getUserFields(uid, ['reputation', 'postcount'], next); + }, + function (userData, next) { + var shouldQueue = parseInt(meta.config.postQueue, 10) === 1 && (!parseInt(uid, 10) || (parseInt(userData.reputation, 10) <= 0 && parseInt(userData.postcount, 10) <= 0)); + next(null, shouldQueue); + }, + ], callback); + }; + + Posts.addToQueue = function (data, callback) { + var type = data.title ? 'topic' : 'reply'; + var id = type + '-' + Date.now(); + async.waterfall([ + function (next) { + canPost(type, data, next); + }, + function (next) { + db.sortedSetAdd('post:queue', Date.now(), id, next); + }, + function (next) { + db.setObject('post:queue:' + id, { + id: id, + uid: data.uid, + type: type, + data: JSON.stringify(data), + }, next); + }, + function (next) { + user.setUserField(data.uid, 'lastposttime', Date.now(), next); + }, + function (next) { + next(null, { + queued: true, + message: '[[success:post-queued]]', + }); + }, + ], callback); + }; + + function canPost(type, data, callback) { + async.waterfall([ + function (next) { + if (type === 'topic') { + next(null, data.cid); + } else if (type === 'reply') { + topics.getTopicField(data.tid, 'cid', next); + } + }, + function (cid, next) { + async.parallel({ + canPost: function (next) { + if (type === 'topic') { + privileges.categories.can('topics:create', data.cid, data.uid, next); + } else if (type === 'reply') { + privileges.categories.can('topics:reply', cid, data.uid, next); + } + }, + isReadyToPost: function (next) { + user.isReadyToPost(data.uid, cid, next); + }, + }, next); + }, + function (results, next) { + if (!results.canPost) { + return next(new Error('[[error:no-privileges]]')); + } + next(); + }, + ], callback); + } + + Posts.removeFromQueue = function (id, callback) { + async.waterfall([ + function (next) { + db.sortedSetRemove('post:queue', id, next); + }, + function (next) { + db.delete('post:queue:' + id, next); + }, + ], callback); + }; + + Posts.submitFromQueue = function (id, callback) { + async.waterfall([ + function (next) { + db.getObject('post:queue:' + id, next); + }, + function (data, next) { + if (!data) { + return callback(); + } + try { + data.data = JSON.parse(data.data); + } catch (err) { + return next(err); + } + + if (data.type === 'topic') { + createTopic(data.data, next); + } else if (data.type === 'reply') { + createReply(data.data, next); + } + }, + function (next) { + Posts.removeFromQueue(id, next); + }, + ], callback); + }; + + function createTopic(data, callback) { + async.waterfall([ + function (next) { + topics.post(data, next); + }, + function (result, next) { + socketHelpers.notifyNew(data.uid, 'newTopic', { posts: [result.postData], topic: result.topicData }); + next(); + }, + ], callback); + } + + function createReply(data, callback) { + async.waterfall([ + function (next) { + topics.reply(data, next); + }, + function (postData, next) { + var result = { + posts: [postData], + 'reputation:disabled': parseInt(meta.config['reputation:disabled'], 10) === 1, + 'downvote:disabled': parseInt(meta.config['downvote:disabled'], 10) === 1, + }; + socketHelpers.notifyNew(data.uid, 'newPost', result); + next(); + }, + ], callback); + } +}; diff --git a/src/routes/admin.js b/src/routes/admin.js index 42fd935aa7..ba4048516e 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -56,6 +56,7 @@ function addRoutes(router, middleware, controllers) { router.get('/manage/categories/:category_id/analytics', middlewares, controllers.admin.categories.getAnalytics); router.get('/manage/tags', middlewares, controllers.admin.tags.get); + router.get('/manage/post-queue', middlewares, controllers.admin.postQueue.get); router.get('/manage/ip-blacklist', middlewares, controllers.admin.blacklist.get); router.get('/manage/users', middlewares, controllers.admin.users.sortByJoinDate); diff --git a/src/routes/index.js b/src/routes/index.js index 5139dda53f..14d8f8cee4 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -42,6 +42,7 @@ function modRoutes(app, middleware, controllers) { } function globalModRoutes(app, middleware, controllers) { + setupPageRoute(app, '/post-queue', middleware, [], controllers.globalMods.postQueue); setupPageRoute(app, '/ip-blacklist', middleware, [], controllers.globalMods.ipBlacklist); } diff --git a/src/socket.io/posts.js b/src/socket.io/posts.js index d5b586d627..a5462ef5bb 100644 --- a/src/socket.io/posts.js +++ b/src/socket.io/posts.js @@ -30,6 +30,21 @@ SocketPosts.reply = function (socket, data, callback) { data.req = websockets.reqFromSocket(socket); data.timestamp = Date.now(); + async.waterfall([ + function (next) { + posts.shouldQueue(socket.uid, data, next); + }, + function (shouldQueue, next) { + if (shouldQueue) { + posts.addToQueue(data, next); + } else { + postReply(socket, data, next); + } + }, + ], callback); +}; + +function postReply(socket, data, callback) { async.waterfall([ function (next) { topics.reply(data, next); @@ -50,7 +65,7 @@ SocketPosts.reply = function (socket, data, callback) { socketHelpers.notifyNew(socket.uid, 'newPost', result); }, ], callback); -}; +} SocketPosts.getRawPost = function (socket, pid, callback) { async.waterfall([ @@ -152,3 +167,26 @@ SocketPosts.getReplies = function (socket, pid, callback) { }, ], callback); }; + +SocketPosts.accept = function (socket, data, callback) { + acceptOrReject(posts.submitFromQueue, socket, data, callback); +}; + +SocketPosts.reject = function (socket, data, callback) { + acceptOrReject(posts.removeFromQueue, socket, data, callback); +}; + +function acceptOrReject(method, socket, data, callback) { + async.waterfall([ + function (next) { + user.isAdminOrGlobalMod(socket.uid, next); + }, + function (isAdminOrGlobalMod, next) { + if (!isAdminOrGlobalMod) { + return callback(new Error('[[error:no-privileges]]')); + } + + method(data.id, next); + }, + ], callback); +} diff --git a/src/socket.io/topics.js b/src/socket.io/topics.js index 3b8a69d0f4..f8d333e67a 100644 --- a/src/socket.io/topics.js +++ b/src/socket.io/topics.js @@ -3,6 +3,7 @@ var async = require('async'); var topics = require('../topics'); +var posts = require('../posts'); var websockets = require('./index'); var user = require('../user'); var apiController = require('../controllers/api'); @@ -25,6 +26,21 @@ SocketTopics.post = function (socket, data, callback) { data.req = websockets.reqFromSocket(socket); data.timestamp = Date.now(); + async.waterfall([ + function (next) { + posts.shouldQueue(socket.uid, data, next); + }, + function (shouldQueue, next) { + if (shouldQueue) { + posts.addToQueue(data, next); + } else { + postTopic(socket, data, next); + } + }, + ], callback); +}; + +function postTopic(socket, data, callback) { async.waterfall([ function (next) { topics.post(data, next); @@ -38,7 +54,7 @@ SocketTopics.post = function (socket, data, callback) { socketHelpers.notifyNew(socket.uid, 'newTopic', { posts: [result.postData], topic: result.topicData }); }, ], callback); -}; +} SocketTopics.postcount = function (socket, tid, callback) { topics.getTopicField(tid, 'postcount', callback); diff --git a/src/views/admin/manage/post-queue.tpl b/src/views/admin/manage/post-queue.tpl new file mode 100644 index 0000000000..86c70f2f21 --- /dev/null +++ b/src/views/admin/manage/post-queue.tpl @@ -0,0 +1,59 @@ +
+
+
+
+ [[admin/manage/post-queue:post-queue]] +
+ + +

+ [[admin/manage/post-queue:description, {config.relative_path}/admin/settings/post#posting-restrictions]] +

+ + +
+ + + + + + + + + + + + + + + + + + + + + +
[[admin/manage/post-queue:user]][[admin/manage/post-queue:title]][[admin/manage/post-queue:content]][[admin/manage/post-queue:posted]]
+ + {posts.user.username} + + {posts.user.username} + + + {posts.data.title} + + {posts.data.content} + + + +
+ + +
+
+
+ + +
+
+
\ No newline at end of file diff --git a/src/views/admin/partials/menu.tpl b/src/views/admin/partials/menu.tpl index fb7f35807a..afe3b27af1 100644 --- a/src/views/admin/partials/menu.tpl +++ b/src/views/admin/partials/menu.tpl @@ -19,6 +19,7 @@
  • [[admin/menu:manage/groups]]
  • [[admin/menu:manage/tags]]
  • [[admin/menu:manage/registration]]
  • +
  • [[admin/menu:manage/post-queue]]
  • [[admin/menu:manage/ip-blacklist]]
  • @@ -191,6 +192,7 @@
  • [[admin/menu:manage/groups]]
  • [[admin/menu:manage/tags]]
  • [[admin/menu:manage/registration]]
  • +
  • [[admin/menu:manage/post-queue]]
  • [[admin/menu:manage/ip-blacklist]]
  • diff --git a/src/views/admin/settings/post.tpl b/src/views/admin/settings/post.tpl index 18d31c0cdf..da379a74d7 100644 --- a/src/views/admin/settings/post.tpl +++ b/src/views/admin/settings/post.tpl @@ -28,6 +28,17 @@
    [[admin/settings/post:restrictions]]
    +
    +
    + +
    +

    + [[admin/settings/post:restrictions.post-queue-help]] +

    +
    diff --git a/test/controllers-admin.js b/test/controllers-admin.js index a10b9dc5cc..e7664ca53f 100644 --- a/test/controllers-admin.js +++ b/test/controllers-admin.js @@ -424,6 +424,31 @@ describe('Admin Controllers', function () { }); }); + it('should load /admin/manage/post-queue', function (done) { + request(nconf.get('url') + '/api/admin/manage/post-queue', { jar: jar, json: true }, function (err, res, body) { + assert.ifError(err); + assert(body); + done(); + }); + }); + + it('/post-queue should 404 for regular user', function (done) { + request(nconf.get('url') + '/api/post-queue', { json: true }, function (err, res, body) { + assert.ifError(err); + assert(body); + assert.equal(res.statusCode, 404); + done(); + }); + }); + + it('should load /post-queue', function (done) { + request(nconf.get('url') + '/api/post-queue', { jar: jar, json: true }, function (err, res, body) { + assert.ifError(err); + assert(body); + done(); + }); + }); + it('should load /admin/manage/ip-blacklist', function (done) { request(nconf.get('url') + '/api/admin/manage/ip-blacklist', { jar: jar, json: true }, function (err, res, body) { assert.ifError(err); diff --git a/test/posts.js b/test/posts.js index 1f35a3b145..14e5241b63 100644 --- a/test/posts.js +++ b/test/posts.js @@ -12,6 +12,8 @@ var privileges = require('../src/privileges'); var user = require('../src/user'); var groups = require('../src/groups'); var socketPosts = require('../src/socket.io/posts'); +var socketTopics = require('../src/socket.io/topics'); +var meta = require('../src/meta'); describe('Post\'s', function () { var voterUid; @@ -296,7 +298,6 @@ describe('Post\'s', function () { var pid; var replyPid; var tid; - var meta = require('../src/meta'); before(function (done) { topics.post({ uid: voterUid, @@ -553,7 +554,6 @@ describe('Post\'s', function () { }); it('should parse signature and remove links and images', function (done) { - var meta = require('../src/meta'); meta.config['signatures:disableLinks'] = 1; meta.config['signatures:disableImages'] = 1; var userData = { @@ -749,4 +749,76 @@ describe('Post\'s', function () { }); }); }); + + describe('post queue', function () { + var uid; + before(function (done) { + meta.config.postQueue = 1; + user.create({ username: 'newuser' }, function (err, _uid) { + assert.ifError(err); + uid = _uid; + done(); + }); + }); + + after(function (done) { + meta.config.postQueue = 0; + done(); + }); + + it('should add topic to post queue', function (done) { + socketTopics.post({ uid: uid }, { title: 'should be queued', content: 'queued topic content', cid: cid }, function (err, result) { + assert.ifError(err); + assert.strictEqual(result.queued, true); + assert.equal(result.message, '[[success:post-queued]]'); + done(); + }); + }); + + it('should add reply to post queue', function (done) { + socketPosts.reply({ uid: uid }, { content: 'this is a queued reply', tid: topicData.tid }, function (err, result) { + assert.ifError(err); + assert.strictEqual(result.queued, true); + assert.equal(result.message, '[[success:post-queued]]'); + done(); + }); + }); + + it('should get queued posts and submit', function (done) { + var ids; + async.waterfall([ + function (next) { + db.getSortedSetRange('post:queue', 0, -1, next); + }, + function (_ids, next) { + ids = _ids; + var keys = ids.map(function (id) { + return 'post:queue:' + id; + }); + db.getObjects(keys, next); + }, + function (data, next) { + data.forEach(function (data) { + data.data = JSON.parse(data.data); + }); + assert.equal(data[0].type, 'topic'); + assert.equal(data[0].data.content, 'queued topic content'); + assert.equal(data[1].type, 'reply'); + assert.equal(data[1].data.content, 'this is a queued reply'); + + socketPosts.accept({ uid: globalModUid }, { id: ids[0] }, next); + }, + function (next) { + socketPosts.accept({ uid: globalModUid }, { id: ids[1] }, next); + }, + ], done); + }); + + it('should prevent regular users from approving posts', function (done) { + socketPosts.accept({ uid: uid }, { id: 1 }, function (err) { + assert.equal(err.message, '[[error:no-privileges]]'); + done(); + }); + }); + }); });