diff --git a/public/language/en-GB/admin/manage/privileges.json b/public/language/en-GB/admin/manage/privileges.json index a6b39716fd..dcd49779f4 100644 --- a/public/language/en-GB/admin/manage/privileges.json +++ b/public/language/en-GB/admin/manage/privileges.json @@ -25,6 +25,7 @@ "access-topics": "Access Topics", "create-topics": "Create Topics", "reply-to-topics": "Reply to Topics", + "schedule-topics": "Schedule Topics", "tag-topics": "Tag Topics", "edit-posts": "Edit Posts", "view-edit-history": "View Edit History", diff --git a/public/language/en-GB/error.json b/public/language/en-GB/error.json index 83f1751ae4..93f34f180f 100644 --- a/public/language/en-GB/error.json +++ b/public/language/en-GB/error.json @@ -104,6 +104,13 @@ "guest-upload-disabled": "Guest uploading has been disabled", "cors-error": "Unable to upload image due to misconfigured CORS", + "scheduling-to-past": "Please select a date in the future.", + "invalid-schedule-date": "Please enter a valid date and time.", + "cant-pin-scheduled": "Scheduled topics cannot be (un)pinned.", + "cant-merge-scheduled": "Scheduled topics cannot be merged.", + "cant-move-posts-to-scheduled": "Can't move posts to a scheduled topic.", + "cant-move-from-scheduled-to-existing": "Can't move posts from a scheduled topic to an existing topic.", + "already-bookmarked": "You have already bookmarked this post", "already-unbookmarked": "You have already unbookmarked this post", diff --git a/public/language/en-GB/modules.json b/public/language/en-GB/modules.json index 5d8500b287..fb16c945d7 100644 --- a/public/language/en-GB/modules.json +++ b/public/language/en-GB/modules.json @@ -62,6 +62,11 @@ "composer.zen_mode": "Zen Mode", "composer.select_category": "Select a category", "composer.textarea.placeholder": "Enter your post content here, drag and drop images", + "composer.schedule-for": "Schedule topic for", + "composer.schedule-date": "Date", + "composer.schedule-time": "Time", + "composer.cancel-scheduling": "Cancel Scheduling", + "composer.set-schedule-date": "Set Date", "bootbox.ok": "OK", diff --git a/public/language/en-GB/topic.json b/public/language/en-GB/topic.json index 363e1cc5c7..11f9d2547f 100644 --- a/public/language/en-GB/topic.json +++ b/public/language/en-GB/topic.json @@ -34,6 +34,7 @@ "locked": "Locked", "pinned": "Pinned", "pinned-with-expiry": "Pinned until %1", + "scheduled": "Scheduled", "moved": "Moved", "moved-from": "Moved from %1", "copy-ip": "Copy IP", @@ -153,6 +154,7 @@ "composer.handle_placeholder": "Enter your name/handle here", "composer.discard": "Discard", "composer.submit": "Submit", + "composer.schedule": "Schedule", "composer.replying_to": "Replying to %1", "composer.new_topic": "New Topic", "composer.editing": "Editing", diff --git a/public/openapi/components/schemas/PostObject.yaml b/public/openapi/components/schemas/PostObject.yaml index 5ff918cd7f..ea91579cc6 100644 --- a/public/openapi/components/schemas/PostObject.yaml +++ b/public/openapi/components/schemas/PostObject.yaml @@ -76,6 +76,13 @@ PostObject: type: string deleted: type: number + scheduled: + type: number + timestamp: + type: number + timestampISO: + type: string + description: An ISO 8601 formatted date string (complementing `timestamp`) postcount: type: number mainPid: diff --git a/public/openapi/components/schemas/TopicObject.yaml b/public/openapi/components/schemas/TopicObject.yaml index 3cb76088aa..64de19aeb8 100644 --- a/public/openapi/components/schemas/TopicObject.yaml +++ b/public/openapi/components/schemas/TopicObject.yaml @@ -213,6 +213,8 @@ TopicObjectSlim: type: number postercount: type: number + scheduled: + type: number deleted: type: number deleterUid: diff --git a/public/openapi/read/category/category_id.yaml b/public/openapi/read/category/category_id.yaml index 1e6ce9b637..10a8523774 100644 --- a/public/openapi/read/category/category_id.yaml +++ b/public/openapi/read/category/category_id.yaml @@ -70,6 +70,8 @@ get: type: boolean topics:tag: type: boolean + topics:schedule: + type: boolean read: type: boolean posts:view_deleted: diff --git a/public/openapi/read/topic/topic_id.yaml b/public/openapi/read/topic/topic_id.yaml index b013de211c..4b890143f7 100644 --- a/public/openapi/read/topic/topic_id.yaml +++ b/public/openapi/read/topic/topic_id.yaml @@ -335,6 +335,8 @@ get: type: boolean view_deleted: type: boolean + view_scheduled: + type: boolean isAdminOrMod: type: boolean disabled: diff --git a/public/src/client/category/tools.js b/public/src/client/category/tools.js index bc27ea7416..0a10cec5be 100644 --- a/public/src/client/category/tools.js +++ b/public/src/client/category/tools.js @@ -190,16 +190,20 @@ define('forum/category/tools', [ var areAllDeleted = areAll(isTopicDeleted, tids); var isAnyPinned = isAny(isTopicPinned, tids); var isAnyLocked = isAny(isTopicLocked, tids); + const isAnyScheduled = isAny(isTopicScheduled, tids); + const areAllScheduled = areAll(isTopicScheduled, tids); components.get('topic/delete').toggleClass('hidden', isAnyDeleted); - components.get('topic/restore').toggleClass('hidden', !isAnyDeleted); + components.get('topic/restore').toggleClass('hidden', isAnyScheduled || !isAnyDeleted); components.get('topic/purge').toggleClass('hidden', !areAllDeleted); components.get('topic/lock').toggleClass('hidden', isAnyLocked); components.get('topic/unlock').toggleClass('hidden', !isAnyLocked); - components.get('topic/pin').toggleClass('hidden', isAnyPinned); - components.get('topic/unpin').toggleClass('hidden', !isAnyPinned); + components.get('topic/pin').toggleClass('hidden', areAllScheduled || isAnyPinned); + components.get('topic/unpin').toggleClass('hidden', areAllScheduled || !isAnyPinned); + + components.get('topic/merge').toggleClass('hidden', isAnyScheduled); } function isAny(method, tids) { @@ -232,6 +236,10 @@ define('forum/category/tools', [ return getTopicEl(tid).hasClass('pinned'); } + function isTopicScheduled(tid) { + return getTopicEl(tid).hasClass('scheduled'); + } + function getTopicEl(tid) { return components.get('category/topic', 'tid', tid); } diff --git a/public/src/client/topic/move-post.js b/public/src/client/topic/move-post.js index bcd02582ac..9c04016654 100644 --- a/public/src/client/topic/move-post.js +++ b/public/src/client/topic/move-post.js @@ -99,6 +99,9 @@ define('forum/topic/move-post', [ if (!data || !data.tid) { return app.alertError('[[error:no-topic]]'); } + if (data.scheduled) { + return app.alertError('[[error:cant-move-posts-to-scheduled]]'); + } var translateStr = translator.compile('topic:x-posts-will-be-moved-to-y', postSelect.pids.length, data.title); moveModal.find('#pids').translateHtml(translateStr); }); diff --git a/public/src/client/topic/posts.js b/public/src/client/topic/posts.js index 2173f50af5..a0e098266a 100644 --- a/public/src/client/topic/posts.js +++ b/public/src/client/topic/posts.js @@ -25,8 +25,8 @@ define('forum/topic/posts', [ data.loggedIn = !!app.user.uid; data.privileges = ajaxify.data.privileges; - // prevent timeago in future by setting timestamp to 1 sec behind now - data.posts[0].timestamp = Date.now() - 1000; + // if not a scheduled topic, prevent timeago in future by setting timestamp to 1 sec behind now + data.posts[0].timestamp = data.posts[0].topic.scheduled ? data.posts[0].timestamp : Date.now() - 1000; data.posts[0].timestampISO = utils.toISOString(data.posts[0].timestamp); Posts.modifyPostsByPrivileges(data.posts); diff --git a/public/src/modules/helpers.js b/public/src/modules/helpers.js index 14e985f1e9..78fec7d112 100644 --- a/public/src/modules/helpers.js +++ b/public/src/modules/helpers.js @@ -155,6 +155,10 @@ style.push('unread'); } + if (topic.scheduled) { + style.push('scheduled'); + } + return style.join(' '); } diff --git a/public/src/modules/topicList.js b/public/src/modules/topicList.js index 0ef31e53f9..c3b90bffe1 100644 --- a/public/src/modules/topicList.js +++ b/public/src/modules/topicList.js @@ -23,6 +23,8 @@ define('topicList', [ var loadTopicsCallback; var topicListEl; + const scheduledTopics = []; + $(window).on('action:ajaxify.start', function () { TopicList.removeListeners(); categoryTools.removeListeners(); @@ -95,54 +97,48 @@ define('topicList', [ }; function onNewTopic(data) { - if ( - ( - ajaxify.data.selectedCids && - ajaxify.data.selectedCids.length && - ajaxify.data.selectedCids.indexOf(parseInt(data.cid, 10)) === -1 - ) || - ( - ajaxify.data.selectedFilter && - ajaxify.data.selectedFilter.filter === 'watched' - ) || - ( - ajaxify.data.template.category && - parseInt(ajaxify.data.cid, 10) !== parseInt(data.cid, 10) - ) - ) { + const d = ajaxify.data; + + const categories = d.selectedCids && + d.selectedCids.length && + d.selectedCids.indexOf(parseInt(data.cid, 10)) === -1; + const filterWatched = d.selectedFilter && + d.selectedFilter.filter === 'watched'; + const category = d.template.category && + parseInt(d.cid, 10) !== parseInt(data.cid, 10); + + if (categories || filterWatched || category || scheduledTopics.includes(data.tid)) { return; } + if (data.scheduled && data.tid) { + scheduledTopics.push(data.tid); + } newTopicCount += 1; updateAlertText(); } function onNewPost(data) { var post = data.posts[0]; - if (!post || !post.topic) { + if (!post || !post.topic || post.topic.isFollowing) { return; } - if (!post.topic.isFollowing && ( - parseInt(post.topic.mainPid, 10) === parseInt(post.pid, 10) || - ( - ajaxify.data.selectedCids && - ajaxify.data.selectedCids.length && - ajaxify.data.selectedCids.indexOf(parseInt(post.topic.cid, 10)) === -1 - ) || - ( - ajaxify.data.selectedFilter && - ajaxify.data.selectedFilter.filter === 'new' - ) || - ( - ajaxify.data.selectedFilter && - ajaxify.data.selectedFilter.filter === 'watched' && - !post.topic.isFollowing - ) || - ( - ajaxify.data.template.category && - parseInt(ajaxify.data.cid, 10) !== parseInt(post.topic.cid, 10) - ) - )) { + + const d = ajaxify.data; + + const isMain = parseInt(post.topic.mainPid, 10) === parseInt(post.pid, 10); + const categories = d.selectedCids && + d.selectedCids.length && + d.selectedCids.indexOf(parseInt(post.topic.cid, 10)) === -1; + const filterNew = d.selectedFilter && + d.selectedFilter.filter === 'new'; + const filterWatched = d.selectedFilter && + d.selectedFilter.filter === 'watched' && + !post.topic.isFollowing; + const category = d.template.category && + parseInt(d.cid, 10) !== parseInt(post.topic.cid, 10); + + if (isMain || categories || filterNew || filterWatched || category) { return; } diff --git a/src/api/helpers.js b/src/api/helpers.js index 56247b18d6..27481318e9 100644 --- a/src/api/helpers.js +++ b/src/api/helpers.js @@ -13,7 +13,7 @@ const events = require('../events'); exports.setDefaultPostData = function (reqOrSocket, data) { data.uid = reqOrSocket.uid; data.req = exports.buildReqObject(reqOrSocket, { ...data }); - data.timestamp = Date.now(); + data.timestamp = parseInt(data.timestamp, 10) || Date.now(); data.fromQueue = false; }; diff --git a/src/api/topics.js b/src/api/topics.js index c5ee6964c2..4925a62c7e 100644 --- a/src/api/topics.js +++ b/src/api/topics.js @@ -20,7 +20,12 @@ topicsAPI.get = async function (caller, data) { privileges.topics.get(data.tid, caller.uid), topics.getTopicData(data.tid), ]); - if (!topic || !userPrivileges.read || !userPrivileges['topics:read'] || (topic.deleted && !userPrivileges.view_deleted)) { + if ( + !topic || + !userPrivileges.read || + !userPrivileges['topics:read'] || + !privileges.topics.canViewDeletedScheduled(topic, userPrivileges) + ) { return null; } diff --git a/src/categories/create.js b/src/categories/create.js index eeb2b87f28..93121dc8b9 100644 --- a/src/categories/create.js +++ b/src/categories/create.js @@ -80,6 +80,7 @@ module.exports = function (Categories) { 'groups:topics:delete', ]; const modPrivileges = defaultPrivileges.concat([ + 'groups:topics:schedule', 'groups:posts:view_deleted', 'groups:purge', ]); diff --git a/src/categories/recentreplies.js b/src/categories/recentreplies.js index e56d31dc66..c1d1a540eb 100644 --- a/src/categories/recentreplies.js +++ b/src/categories/recentreplies.js @@ -55,7 +55,7 @@ module.exports = function (Categories) { topicData = await topics.getTopicData(postData.tid); } index += 1; - } while (!topicData || topicData.deleted); + } while (!topicData || topicData.deleted || topicData.scheduled); if (postData && postData.tid) { await Categories.updateRecentTid(cid, postData.tid); diff --git a/src/categories/topics.js b/src/categories/topics.js index b874e0bf5c..df6c3c5df4 100644 --- a/src/categories/topics.js +++ b/src/categories/topics.js @@ -4,6 +4,7 @@ const db = require('../database'); const topics = require('../topics'); const plugins = require('../plugins'); const meta = require('../meta'); +const privileges = require('../privileges'); const user = require('../user'); module.exports = function (Categories) { @@ -142,7 +143,12 @@ module.exports = function (Categories) { }); return result && result.pinnedTids; } - const pinnedTids = await db.getSortedSetRevRange(`cid:${data.cid}:tids:pinned`, data.start, data.stop); + const [allPinnedTids, canSchedule] = await Promise.all([ + db.getSortedSetRevRange(`cid:${data.cid}:tids:pinned`, data.start, data.stop), + privileges.categories.can('topics:schedule', data.cid, data.uid), + ]); + const pinnedTids = canSchedule ? allPinnedTids : await filterScheduledTids(allPinnedTids); + return await topics.tools.checkPinExpiry(pinnedTids); }; @@ -152,7 +158,7 @@ module.exports = function (Categories) { } topics.forEach((topic) => { - if (topic.deleted && !topic.isOwner) { + if (!topic.scheduled && topic.deleted && !topic.isOwner) { topic.title = '[[topic:topic_is_deleted]]'; topic.slug = topic.tid; topic.teaser = null; @@ -176,4 +182,10 @@ module.exports = function (Categories) { await Promise.all(promises); await Categories.updateRecentTidForCid(cid); }; + + async function filterScheduledTids(tids) { + const scores = await db.sortedSetScores('topics:scheduled', tids); + const now = Date.now(); + return tids.filter((tid, index) => tid && (!scores[index] || scores[index] <= now)); + } }; diff --git a/src/controllers/accounts/profile.js b/src/controllers/accounts/profile.js index e009a4aa9a..599d0bab79 100644 --- a/src/controllers/accounts/profile.js +++ b/src/controllers/accounts/profile.js @@ -8,6 +8,7 @@ const user = require('../../user'); const posts = require('../../posts'); const categories = require('../../categories'); const meta = require('../../meta'); +const privileges = require('../../privileges'); const accountHelpers = require('./helpers'); const helpers = require('../helpers'); const utils = require('../../utils'); @@ -91,11 +92,13 @@ async function getPosts(callerUid, userData, setSuffix) { const count = 10; const postData = []; - const [isAdmin, isModOfCids] = await Promise.all([ + const [isAdmin, isModOfCids, canSchedule] = await Promise.all([ user.isAdministrator(callerUid), user.isModerator(callerUid, cids), + privileges.categories.isUserAllowedTo('topics:schedule', cids, callerUid), ]); const cidToIsMod = _.zipObject(cids, isModOfCids); + const cidToCanSchedule = _.zipObject(cids, canSchedule); do { /* eslint-disable no-await-in-loop */ @@ -106,7 +109,8 @@ async function getPosts(callerUid, userData, setSuffix) { if (pids.length) { const p = await posts.getPostSummaryByPids(pids, callerUid, { stripTags: false }); postData.push(...p.filter( - p => p && p.topic && (isAdmin || cidToIsMod[p.topic.cid] || (!p.deleted && !p.topic.deleted)) + p => p && p.topic && (isAdmin || cidToIsMod[p.topic.cid] || + (p.topic.scheduled && cidToCanSchedule[p.topic.cid]) || (!p.deleted && !p.topic.deleted)) )); } start += count; diff --git a/src/controllers/topics.js b/src/controllers/topics.js index 005c332976..6e48f19664 100644 --- a/src/controllers/topics.js +++ b/src/controllers/topics.js @@ -43,15 +43,17 @@ topicsController.get = async function getTopic(req, res, callback) { let currentPage = parseInt(req.query.page, 10) || 1; const pageCount = Math.max(1, Math.ceil((topicData && topicData.postcount) / settings.postsPerPage)); + const validPagination = (settings.usePagination && (currentPage < 1 || currentPage > pageCount)); if ( !topicData || userPrivileges.disabled || - (settings.usePagination && (currentPage < 1 || currentPage > pageCount)) + validPagination || + (topicData.scheduled && !userPrivileges.view_scheduled) ) { return callback(); } - if (!userPrivileges['topics:read'] || (topicData.deleted && !userPrivileges.view_deleted)) { + if (!userPrivileges['topics:read'] || (!topicData.scheduled && topicData.deleted && !userPrivileges.view_deleted)) { return helpers.notAllowed(req, res); } @@ -343,7 +345,7 @@ topicsController.pagination = async function (req, res, callback) { return callback(); } - if (!userPrivileges.read || (topic.deleted && !userPrivileges.view_deleted)) { + if (!userPrivileges.read || !privileges.topics.canViewDeletedScheduled(topic, userPrivileges)) { return helpers.notAllowed(req, res); } diff --git a/src/posts/diffs.js b/src/posts/diffs.js index 68816d0853..9e8515ef97 100644 --- a/src/posts/diffs.js +++ b/src/posts/diffs.js @@ -38,12 +38,12 @@ module.exports = function (Posts) { }; Diffs.save = async function (data) { - const { pid, uid, oldContent, newContent } = data; - const now = Date.now(); + const { pid, uid, oldContent, newContent, edited } = data; + const editTimestamp = edited || Date.now(); const patch = diff.createPatch('', newContent, oldContent); await Promise.all([ - db.listPrepend(`post:${pid}:diffs`, now), - db.setObject(`diff:${pid}.${now}`, { + db.listPrepend(`post:${pid}:diffs`, editTimestamp), + db.setObject(`diff:${pid}.${editTimestamp}`, { uid: uid, pid: pid, patch: patch, @@ -135,7 +135,7 @@ module.exports = function (Posts) { function getValidatedTimestamp(timestamp) { timestamp = parseInt(timestamp, 10); - if (isNaN(timestamp) || timestamp > Date.now()) { + if (isNaN(timestamp)) { throw new Error('[[error:invalid-data]]'); } diff --git a/src/posts/edit.js b/src/posts/edit.js index 371815572e..6714648f22 100644 --- a/src/posts/edit.js +++ b/src/posts/edit.js @@ -29,11 +29,13 @@ module.exports = function (Posts) { throw new Error('[[error:no-post]]'); } + const topicData = await topics.getTopicFields(postData.tid, ['cid', 'title', 'timestamp', 'scheduled']); const oldContent = postData.content; // for diffing purposes - const now = Date.now(); + // For posts in scheduled topics, if edited before, use edit timestamp + const postTimestamp = topicData.scheduled ? (postData.edited || postData.timestamp) + 1 : Date.now(); const editPostData = { content: data.content, - edited: now, + edited: postTimestamp, editor: data.uid, }; if (data.handle) { @@ -49,7 +51,7 @@ module.exports = function (Posts) { const [editor, topic] = await Promise.all([ user.getUserFields(data.uid, ['username', 'userslug']), - editMainPost(data, postData), + editMainPost(data, postData, topicData), ]); await Posts.setPostFields(data.pid, result.post); @@ -60,6 +62,7 @@ module.exports = function (Posts) { uid: data.uid, oldContent: oldContent, newContent: data.content, + edited: postTimestamp, }); } await Posts.uploads.sync(data.pid); @@ -70,7 +73,7 @@ module.exports = function (Posts) { const returnPostData = { ...postData, ...result.post }; returnPostData.cid = topic.cid; returnPostData.topic = topic; - returnPostData.editedISO = utils.toISOString(now); + returnPostData.editedISO = utils.toISOString(postTimestamp); returnPostData.changed = oldContent !== data.content; await topics.notifyFollowers(returnPostData, data.uid, { @@ -93,15 +96,11 @@ module.exports = function (Posts) { }; }; - async function editMainPost(data, postData) { + async function editMainPost(data, postData, topicData) { const { tid } = postData; const title = data.title ? data.title.trim() : ''; - const [topicData, isMain] = await Promise.all([ - topics.getTopicFields(tid, ['cid', 'title', 'timestamp']), - Posts.isMain(data.pid), - ]); - + const isMain = await Posts.isMain(data.pid); if (!isMain) { return { tid: tid, diff --git a/src/posts/summary.js b/src/posts/summary.js index bad9849ab3..cefbb8cc15 100644 --- a/src/posts/summary.js +++ b/src/posts/summary.js @@ -76,7 +76,7 @@ module.exports = function (Posts) { } async function getTopicAndCategories(tids) { - const topicsData = await topics.getTopicsFields(tids, ['uid', 'tid', 'title', 'cid', 'slug', 'deleted', 'postcount', 'mainPid', 'teaserPid']); + const topicsData = await topics.getTopicsFields(tids, ['uid', 'tid', 'title', 'cid', 'slug', 'deleted', 'scheduled', 'postcount', 'mainPid', 'teaserPid']); const cids = _.uniq(topicsData.map(topic => topic && topic.cid)); const categoriesData = await categories.getCategoriesFields(cids, ['cid', 'name', 'icon', 'slug', 'parentCid', 'bgColor', 'color', 'backgroundImage', 'imageClass']); return { topics: topicsData, categories: categoriesData }; diff --git a/src/privileges/categories.js b/src/privileges/categories.js index ff7605cd9f..90fcffd2c3 100644 --- a/src/privileges/categories.js +++ b/src/privileges/categories.js @@ -18,6 +18,7 @@ privsCategories.privilegeLabels = [ { name: '[[admin/manage/privileges:access-topics]]' }, { name: '[[admin/manage/privileges:create-topics]]' }, { name: '[[admin/manage/privileges:reply-to-topics]]' }, + { name: '[[admin/manage/privileges:schedule-topics]]' }, { name: '[[admin/manage/privileges:tag-topics]]' }, { name: '[[admin/manage/privileges:edit-posts]]' }, { name: '[[admin/manage/privileges:view-edit-history]]' }, @@ -36,6 +37,7 @@ privsCategories.userPrivilegeList = [ 'topics:read', 'topics:create', 'topics:reply', + 'topics:schedule', 'topics:tag', 'posts:edit', 'posts:history', @@ -79,8 +81,8 @@ privsCategories.list = async function (cid) { privsCategories.get = async function (cid, uid) { const privs = [ - 'topics:create', 'topics:read', 'topics:tag', - 'read', 'posts:view_deleted', + 'topics:create', 'topics:read', 'topics:schedule', + 'topics:tag', 'read', 'posts:view_deleted', ]; const [userPrivileges, isAdministrator, isModerator] = await Promise.all([ @@ -162,6 +164,7 @@ privsCategories.getBase = async function (privilege, cids, uid) { categories: categories.getCategoriesFields(cids, ['disabled']), allowedTo: helpers.isAllowedTo(privilege, uid, cids), view_deleted: helpers.isAllowedTo('posts:view_deleted', uid, cids), + view_scheduled: helpers.isAllowedTo('topics:schedule', uid, cids), isAdmin: user.isAdministrator(uid), }); }; diff --git a/src/privileges/posts.js b/src/privileges/posts.js index 7252c98185..8b32065df6 100644 --- a/src/privileges/posts.js +++ b/src/privileges/posts.js @@ -11,6 +11,7 @@ const helpers = require('./helpers'); const plugins = require('../plugins'); const utils = require('../utils'); const privsCategories = require('./categories'); +const privsTopics = require('./topics'); const privsPosts = module.exports; @@ -73,7 +74,7 @@ privsPosts.filter = async function (privilege, pids, uid) { pids = _.uniq(pids); const postData = await posts.getPostsFields(pids, ['uid', 'tid', 'deleted']); const tids = _.uniq(postData.map(post => post && post.tid).filter(Boolean)); - const topicData = await topics.getTopicsFields(tids, ['deleted', 'cid']); + const topicData = await topics.getTopicsFields(tids, ['deleted', 'scheduled', 'cid']); const tidToTopic = _.zipObject(tids, topicData); @@ -93,11 +94,15 @@ privsPosts.filter = async function (privilege, pids, uid) { const cidsSet = new Set(allowedCids); const canViewDeleted = _.zipObject(cids, results.view_deleted); + const canViewScheduled = _.zipObject(cids, results.view_scheduled); pids = postData.filter(post => ( post.topic && cidsSet.has(post.topic.cid) && - ((!post.topic.deleted && !post.deleted) || canViewDeleted[post.topic.cid] || results.isAdmin) + (privsTopics.canViewDeletedScheduled({ + deleted: post.topic.deleted || post.deleted, + scheduled: post.topic.scheduled, + }, {}, canViewDeleted[post.topic.cid], canViewScheduled[post.topic.cid]) || results.isAdmin) )).map(post => post.pid); const data = await plugins.hooks.fire('filter:privileges.posts.filter', { diff --git a/src/privileges/topics.js b/src/privileges/topics.js index 10ffdc5591..20ad19aa19 100644 --- a/src/privileges/topics.js +++ b/src/privileges/topics.js @@ -17,11 +17,11 @@ privsTopics.get = async function (tid, uid) { uid = parseInt(uid, 10); const privs = [ - 'topics:reply', 'topics:read', 'topics:tag', + 'topics:reply', 'topics:read', 'topics:schedule', 'topics:tag', 'topics:delete', 'posts:edit', 'posts:history', 'posts:delete', 'posts:view_deleted', 'read', 'purge', ]; - const topicData = await topics.getTopicFields(tid, ['cid', 'uid', 'locked', 'deleted']); + const topicData = await topics.getTopicFields(tid, ['cid', 'uid', 'locked', 'deleted', 'scheduled']); const [userPrivileges, isAdministrator, isModerator, disabled] = await Promise.all([ helpers.isAllowedTo(privs, uid, topicData.cid), user.isAdministrator(uid), @@ -33,9 +33,10 @@ privsTopics.get = async function (tid, uid) { const isAdminOrMod = isAdministrator || isModerator; const editable = isAdminOrMod; const deletable = (privData['topics:delete'] && (isOwner || isModerator)) || isAdministrator; + const mayReply = privsTopics.canViewDeletedScheduled(topicData, {}, false, privData['topics:schedule']); return await plugins.hooks.fire('filter:privileges.topics.get', { - 'topics:reply': (privData['topics:reply'] && ((!topicData.locked && !topicData.deleted) || isModerator)) || isAdministrator, + 'topics:reply': (privData['topics:reply'] && ((!topicData.locked && mayReply) || isModerator)) || isAdministrator, 'topics:read': privData['topics:read'] || isAdministrator, 'topics:tag': privData['topics:tag'] || isAdministrator, 'topics:delete': (privData['topics:delete'] && (isOwner || isModerator)) || isAdministrator, @@ -50,6 +51,7 @@ privsTopics.get = async function (tid, uid) { editable: editable, deletable: deletable, view_deleted: isAdminOrMod || isOwner || privData['posts:view_deleted'], + view_scheduled: privData['topics:schedule'] || isAdministrator, isAdminOrMod: isAdminOrMod, disabled: disabled, tid: tid, @@ -67,7 +69,7 @@ privsTopics.filterTids = async function (privilege, tids, uid) { return []; } - const topicsData = await topics.getTopicsFields(tids, ['tid', 'cid', 'deleted']); + const topicsData = await topics.getTopicsFields(tids, ['tid', 'cid', 'deleted', 'scheduled']); const cids = _.uniq(topicsData.map(topic => topic.cid)); const results = await privsCategories.getBase(privilege, cids, uid); @@ -78,10 +80,11 @@ privsTopics.filterTids = async function (privilege, tids, uid) { const cidsSet = new Set(allowedCids); const canViewDeleted = _.zipObject(cids, results.view_deleted); + const canViewScheduled = _.zipObject(cids, results.view_scheduled); tids = topicsData.filter(t => ( cidsSet.has(t.cid) && - (!t.deleted || canViewDeleted[t.cid] || results.isAdmin) + (results.isAdmin || privsTopics.canViewDeletedScheduled(t, {}, canViewDeleted[t.cid], canViewScheduled[t.cid])) )).map(t => t.tid); const data = await plugins.hooks.fire('filter:privileges.topics.filter', { @@ -98,14 +101,20 @@ privsTopics.filterUids = async function (privilege, tid, uids) { } uids = _.uniq(uids); - const topicData = await topics.getTopicFields(tid, ['tid', 'cid', 'deleted']); + const topicData = await topics.getTopicFields(tid, ['tid', 'cid', 'deleted', 'scheduled']); const [disabled, allowedTo, isAdmins] = await Promise.all([ categories.getCategoryField(topicData.cid, 'disabled'), helpers.isUsersAllowedTo(privilege, uids, topicData.cid), user.isAdministrator(uids), ]); + + if (topicData.scheduled) { + const canViewScheduled = await helpers.isUsersAllowedTo('topics:schedule', uids, topicData.cid); + uids = uids.filter((uid, index) => canViewScheduled[index]); + } + return uids.filter((uid, index) => !disabled && - ((allowedTo[index] && !topicData.deleted) || isAdmins[index])); + ((allowedTo[index] && (topicData.scheduled || !topicData.deleted)) || isAdmins[index])); }; privsTopics.canPurge = async function (tid, uid) { @@ -163,3 +172,20 @@ privsTopics.isAdminOrMod = async function (tid, uid) { const cid = await topics.getTopicField(tid, 'cid'); return await privsCategories.isAdminOrMod(cid, uid); }; + +privsTopics.canViewDeletedScheduled = function (topic, privileges = {}, viewDeleted = false, viewScheduled = false) { + if (!topic) { + return false; + } + const { deleted = false, scheduled = false } = topic; + const { view_deleted = viewDeleted, view_scheduled = viewScheduled } = privileges; + + // conceptually exclusive, scheduled topics deemed to be not deleted (they can only be purged) + if (scheduled) { + return view_scheduled; + } else if (deleted) { + return view_deleted; + } + + return true; +}; diff --git a/src/routes/feeds.js b/src/routes/feeds.js index 9be9acf366..ff2e508ce7 100644 --- a/src/routes/feeds.js +++ b/src/routes/feeds.js @@ -72,7 +72,7 @@ async function generateForTopic(req, res) { topics.getTopicData(tid), ]); - if (!topic || (topic.deleted && !userPrivileges.view_deleted)) { + if (!privileges.topics.canViewDeletedScheduled(topic, userPrivileges)) { return controllers404.send404(req, res); } diff --git a/src/socket.io/topics/infinitescroll.js b/src/socket.io/topics/infinitescroll.js index bae4ba3a61..03266a3059 100644 --- a/src/socket.io/topics/infinitescroll.js +++ b/src/socket.io/topics/infinitescroll.js @@ -14,10 +14,10 @@ module.exports = function (SocketTopics) { const [userPrivileges, topicData] = await Promise.all([ privileges.topics.get(data.tid, socket.uid), - topics.getTopicFields(data.tid, ['postcount', 'deleted', 'uid']), + topics.getTopicFields(data.tid, ['postcount', 'deleted', 'scheduled', 'uid']), ]); - if (!userPrivileges['topics:read'] || (topicData.deleted && !userPrivileges.view_deleted)) { + if (!userPrivileges['topics:read'] || !privileges.topics.canViewDeletedScheduled(topicData, userPrivileges)) { throw new Error('[[error:no-privileges]]'); } diff --git a/src/start.js b/src/start.js index f5cf130aa2..5f7b42ce7b 100644 --- a/src/start.js +++ b/src/start.js @@ -38,6 +38,7 @@ start.start = async function () { require('./notifications').startJobs(); require('./user').startJobs(); require('./plugins').startJobs(); + require('./topics').scheduled.startJobs(); await db.delete('locks'); } diff --git a/src/topics/create.js b/src/topics/create.js index fb749485b4..a4b2c9ecac 100644 --- a/src/topics/create.js +++ b/src/topics/create.js @@ -38,23 +38,33 @@ module.exports = function (Topics) { topicData = result.topic; await db.setObject(`topic:${topicData.tid}`, topicData); + const timestampedSortedSetKeys = [ + 'topics:tid', + `cid:${topicData.cid}:tids`, + `cid:${topicData.cid}:uid:${topicData.uid}:tids`, + ]; + + const scheduled = timestamp > Date.now(); + if (scheduled) { + timestampedSortedSetKeys.push('topics:scheduled'); + } + await Promise.all([ - db.sortedSetsAdd([ - 'topics:tid', - `cid:${topicData.cid}:tids`, - `cid:${topicData.cid}:uid:${topicData.uid}:tids`, - ], timestamp, topicData.tid), + db.sortedSetsAdd(timestampedSortedSetKeys, timestamp, topicData.tid), db.sortedSetsAdd([ 'topics:views', 'topics:posts', 'topics:votes', `cid:${topicData.cid}:tids:votes`, `cid:${topicData.cid}:tids:posts`, ], 0, topicData.tid), - categories.updateRecentTid(topicData.cid, topicData.tid), user.addTopicIdToUser(topicData.uid, topicData.tid, timestamp), db.incrObjectField(`category:${topicData.cid}`, 'topic_count'), db.incrObjectField('global', 'topicCount'), Topics.createTags(data.tags, topicData.tid, timestamp), + scheduled ? Promise.resolve() : categories.updateRecentTid(topicData.cid, topicData.tid), ]); + if (scheduled) { + await Topics.scheduled.pin(tid, topicData); + } plugins.hooks.fire('action:topic.save', { topic: _.clone(topicData), data: data }); return topicData.tid; @@ -118,10 +128,14 @@ module.exports = function (Topics) { topicData.index = 0; postData.index = 0; + if (topicData.scheduled) { + await Topics.delete(tid); + } + analytics.increment(['topics', `topics:byCid:${topicData.cid}`]); plugins.hooks.fire('action:topic.post', { topic: topicData, post: postData, data: data }); - if (parseInt(uid, 10)) { + if (parseInt(uid, 10) && !topicData.scheduled) { user.notifications.sendTopicNotificationToFollowers(uid, topicData, postData); } @@ -136,28 +150,10 @@ module.exports = function (Topics) { const { uid } = data; const topicData = await Topics.getTopicData(tid); - if (!topicData) { - throw new Error('[[error:no-topic]]'); - } - - data.cid = topicData.cid; - const [canReply, isAdminOrMod] = await Promise.all([ - privileges.topics.can('topics:reply', tid, uid), - privileges.categories.isAdminOrMod(data.cid, uid), - ]); - - if (topicData.locked && !isAdminOrMod) { - throw new Error('[[error:topic-locked]]'); - } - - if (topicData.deleted && !isAdminOrMod) { - throw new Error('[[error:topic-deleted]]'); - } + await canReply(data, topicData); - if (!canReply) { - throw new Error('[[error:no-privileges]]'); - } + data.cid = topicData.cid; await guestHandleValid(data); if (!data.fromQueue) { @@ -169,6 +165,11 @@ module.exports = function (Topics) { } Topics.checkContent(data.content); + // For replies to scheduled topics, don't have a timestamp older than topic's itself + if (topicData.scheduled) { + data.timestamp = topicData.lastposttime + 1; + } + data.ip = data.req ? data.req.ip : null; let postData = await posts.create(data); postData = await onNewPost(postData, data); @@ -207,7 +208,7 @@ module.exports = function (Topics) { topicInfo, ] = await Promise.all([ posts.getUserInfoForPosts([postData.uid], uid), - Topics.getTopicFields(tid, ['tid', 'uid', 'title', 'slug', 'cid', 'postcount', 'mainPid']), + Topics.getTopicFields(tid, ['tid', 'uid', 'title', 'slug', 'cid', 'postcount', 'mainPid', 'scheduled']), Topics.addParentPosts([postData]), posts.parsePost(postData), ]); @@ -263,4 +264,34 @@ module.exports = function (Topics) { } } } + + async function canReply(data, topicData) { + if (!topicData) { + throw new Error('[[error:no-topic]]'); + } + const { tid, uid } = data; + const { cid, deleted, locked, scheduled } = topicData; + + const [canReply, canSchedule, isAdminOrMod] = await Promise.all([ + privileges.topics.can('topics:reply', tid, uid), + privileges.topics.can('topics:schedule', tid, uid), + privileges.categories.isAdminOrMod(cid, uid), + ]); + + if (locked && !isAdminOrMod) { + throw new Error('[[error:topic-locked]]'); + } + + if (!scheduled && deleted && !isAdminOrMod) { + throw new Error('[[error:topic-deleted]]'); + } + + if (scheduled && !canSchedule) { + throw new Error('[[error:no-privileges]]'); + } + + if (!canReply) { + throw new Error('[[error:no-privileges]]'); + } + } }; diff --git a/src/topics/data.js b/src/topics/data.js index 5ad5fa2a0b..db13036176 100644 --- a/src/topics/data.js +++ b/src/topics/data.js @@ -20,6 +20,12 @@ module.exports = function (Topics) { if (!Array.isArray(tids) || !tids.length) { return []; } + + // "scheduled" is derived from "timestamp" + if (fields.includes('scheduled') && !fields.includes('timestamp')) { + fields.push('timestamp'); + } + const keys = tids.map(tid => `topic:${tid}`); const topics = await db.getObjects(keys, fields); const result = await plugins.hooks.fire('filter:topic.getFields', { @@ -100,6 +106,9 @@ function modifyTopic(topic, fields) { if (topic.hasOwnProperty('timestamp')) { topic.timestampISO = utils.toISOString(topic.timestamp); + if (!fields.length || fields.includes('scheduled')) { + topic.scheduled = topic.timestamp > Date.now(); + } } if (topic.hasOwnProperty('lastposttime')) { diff --git a/src/topics/delete.js b/src/topics/delete.js index c55b1ba662..889f129c76 100644 --- a/src/topics/delete.js +++ b/src/topics/delete.js @@ -95,6 +95,7 @@ module.exports = function (Topics) { 'topics:posts', 'topics:views', 'topics:votes', + 'topics:scheduled', ], tid), deleteTopicFromCategoryAndUser(tid), Topics.deleteTopicTags(tid), diff --git a/src/topics/fork.js b/src/topics/fork.js index 0eb0ccdfe2..6ba03a33a3 100644 --- a/src/topics/fork.js +++ b/src/topics/fork.js @@ -39,7 +39,14 @@ module.exports = function (Topics) { if (!isAdminOrMod) { throw new Error('[[error:no-privileges]]'); } - const tid = await Topics.create({ uid: postData.uid, title: title, cid: cid }); + + const scheduled = postData.timestamp > Date.now(); + const tid = await Topics.create({ + uid: postData.uid, + title: title, + cid: cid, + timestamp: scheduled && postData.timestamp, + }); await Topics.updateTopicBookmarks(fromTid, pids); await async.eachSeries(pids, async (pid) => { @@ -47,10 +54,10 @@ module.exports = function (Topics) { if (!canEdit.flag) { throw new Error(canEdit.message); } - await Topics.movePostToTopic(uid, pid, tid); + await Topics.movePostToTopic(uid, pid, tid, scheduled); }); - await Topics.updateLastPostTime(tid, Date.now()); + await Topics.updateLastPostTime(tid, scheduled ? (postData.timestamp + 1) : Date.now()); await Promise.all([ Topics.setTopicFields(tid, { @@ -65,17 +72,25 @@ module.exports = function (Topics) { return await Topics.getTopicData(tid); }; - Topics.movePostToTopic = async function (callerUid, pid, tid) { + Topics.movePostToTopic = async function (callerUid, pid, tid, forceScheduled = false) { tid = parseInt(tid, 10); - const exists = await Topics.exists(tid); - if (!exists) { + const topicData = await Topics.getTopicFields(tid, ['tid', 'scheduled']); + if (!topicData.tid) { throw new Error('[[error:no-topic]]'); } + if (!forceScheduled && topicData.scheduled) { + throw new Error('[[error:cant-move-posts-to-scheduled]]'); + } const postData = await posts.getPostFields(pid, ['tid', 'uid', 'timestamp', 'upvotes', 'downvotes']); if (!postData || !postData.tid) { throw new Error('[[error:no-post]]'); } + const isSourceTopicScheduled = await Topics.getTopicField(postData.tid, 'scheduled'); + if (!forceScheduled && isSourceTopicScheduled) { + throw new Error('[[error:cant-move-from-scheduled-to-existing]]'); + } + if (postData.tid === tid) { throw new Error('[[error:cant-move-to-same-topic]]'); } diff --git a/src/topics/index.js b/src/topics/index.js index bce9bc2fa9..2108eaeb6a 100644 --- a/src/topics/index.js +++ b/src/topics/index.js @@ -27,6 +27,7 @@ require('./posts')(Topics); require('./follow')(Topics); require('./tags')(Topics); require('./teaser')(Topics); +Topics.scheduled = require('./scheduled'); require('./suggested')(Topics); require('./tools')(Topics); Topics.thumbs = require('./thumbs'); diff --git a/src/topics/merge.js b/src/topics/merge.js index 9a00163039..4d39280fb7 100644 --- a/src/topics/merge.js +++ b/src/topics/merge.js @@ -6,6 +6,12 @@ const plugins = require('../plugins'); module.exports = function (Topics) { Topics.merge = async function (tids, uid, options) { options = options || {}; + + const topicsData = await Topics.getTopicsFields(tids, ['scheduled']); + if (topicsData.some(t => t.scheduled)) { + throw new Error('[[error:cant-merge-scheduled]]'); + } + const oldestTid = findOldestTopic(tids); let mergeIntoTid = oldestTid; if (options.mainTid) { diff --git a/src/topics/scheduled.js b/src/topics/scheduled.js new file mode 100644 index 0000000000..2957634356 --- /dev/null +++ b/src/topics/scheduled.js @@ -0,0 +1,104 @@ +'use strict'; + +const _ = require('lodash'); +const winston = require('winston'); +const { CronJob } = require('cron'); + +const db = require('../database'); +const posts = require('../posts'); +const socketHelpers = require('../socket.io/helpers'); +const topics = require('./index'); +const user = require('../user'); + +const Scheduled = module.exports; + +Scheduled.startJobs = function () { + winston.verbose('[scheduled topics] Starting jobs.'); + new CronJob('*/1 * * * *', Scheduled.handleExpired, null, true); +}; + +Scheduled.handleExpired = async function () { + const now = Date.now(); + const tids = await db.getSortedSetRangeByScore('topics:scheduled', 0, -1, '-inf', now); + + if (!tids.length) { + return; + } + + let topicsData = await topics.getTopicsData(tids); + // Filter deleted + topicsData = topicsData.filter(topicData => Boolean(topicData)); + const uids = _.uniq(topicsData.map(topicData => topicData.uid)).filter(uid => uid); // Filter guests topics + + // Restore first to be not filtered for being deleted + // Restoring handles "updateRecentTid" + await Promise.all(topicsData.map(topicData => topics.restore(topicData.tid))); + + await Promise.all([].concat( + sendNotifications(uids, topicsData), + updateUserLastposttimes(uids, topicsData), + ...topicsData.map(topicData => unpin(topicData.tid, topicData)), + db.sortedSetsRemoveRangeByScore([`topics:scheduled`], '-inf', now) + )); +}; + +// topics/tools.js#pin/unpin would block non-admins/mods, thus the local versions +Scheduled.pin = async function (tid, topicData) { + return Promise.all([ + topics.setTopicField(tid, 'pinned', 1), + db.sortedSetAdd(`cid:${topicData.cid}:tids:pinned`, Date.now(), tid), + db.sortedSetsRemove([ + `cid:${topicData.cid}:tids`, + `cid:${topicData.cid}:tids:posts`, + `cid:${topicData.cid}:tids:votes`, + ], tid), + ]); +}; + +function unpin(tid, topicData) { + return [ + topics.setTopicField(tid, 'pinned', 0), + topics.deleteTopicField(tid, 'pinExpiry'), + db.sortedSetRemove(`cid:${topicData.cid}:tids:pinned`, tid), + db.sortedSetAddBulk([ + [`cid:${topicData.cid}:tids`, topicData.lastposttime, tid], + [`cid:${topicData.cid}:tids:posts`, topicData.postcount, tid], + [`cid:${topicData.cid}:tids:votes`, parseInt(topicData.votes, 10) || 0, tid], + ]), + ]; +} + +async function sendNotifications(uids, topicsData) { + const usernames = await Promise.all(uids.map(uid => user.getUserField(uid, 'username'))); + const uidToUsername = Object.fromEntries(uids.map((uid, idx) => [uid, usernames[idx]])); + + const postsData = await posts.getPostsData(topicsData.map(({ mainPid }) => mainPid)); + postsData.forEach((postData, idx) => { + postData.user = {}; + postData.user.username = uidToUsername[postData.uid]; + postData.topic = topicsData[idx]; + }); + + return topicsData.map( + (t, idx) => user.notifications.sendTopicNotificationToFollowers(t.uid, t, postsData[idx]) + ).concat( + topicsData.map( + (t, idx) => socketHelpers.notifyNew(t.uid, 'newTopic', { posts: [postsData[idx]], topic: t }) + ) + ); +} + +async function updateUserLastposttimes(uids, topicsData) { + const lastposttimes = (await user.getUsersFields(uids, ['lastposttime'])).map(u => u.lastposttime); + + let timestampByUid = {}; + topicsData.forEach((tD) => { + timestampByUid[tD.uid] = timestampByUid[tD.uid] ? timestampByUid[tD.uid].concat(tD.timestamp) : [tD.timestamp]; + }); + timestampByUid = Object.fromEntries( + Object.entries(timestampByUid).filter(uidTimestamp => [uidTimestamp[0], Math.max(...uidTimestamp[1])]) + ); + + const uidsToUpdate = uids.filter((uid, idx) => timestampByUid[uid] > lastposttimes[idx]); + return uidsToUpdate.map(uid => user.setUserField(uid, 'lastposttime', String(timestampByUid[uid]))); +} diff --git a/src/topics/tools.js b/src/topics/tools.js index a48b5d8eea..5bc7554407 100644 --- a/src/topics/tools.js +++ b/src/topics/tools.js @@ -27,6 +27,10 @@ module.exports = function (Topics) { if (!topicData) { throw new Error('[[error:no-topic]]'); } + // Scheduled topics can only be purged + if (topicData.scheduled) { + throw new Error('[[error:invalid-data]]'); + } const canDelete = await privileges.topics.canDelete(tid, uid); const data = await plugins.hooks.fire(isDelete ? 'filter:topic.delete' : 'filter:topic.restore', { topicData: topicData, uid: uid, isDelete: isDelete, canDelete: canDelete, canRestore: canDelete }); @@ -149,6 +153,10 @@ module.exports = function (Topics) { throw new Error('[[error:no-topic]]'); } + if (topicData.scheduled) { + throw new Error('[[error:cant-pin-scheduled]]'); + } + if (uid !== 'system' && !await privileges.topics.isAdminOrMod(tid, uid)) { throw new Error('[[error:no-privileges]]'); } diff --git a/src/topics/unread.js b/src/topics/unread.js index 82a68d1af6..6627fc8074 100644 --- a/src/topics/unread.js +++ b/src/topics/unread.js @@ -101,7 +101,7 @@ module.exports = function (Topics) { db.getSortedSetRevRangeWithScores(`uid:${params.uid}:tids_unread`, 0, -1), ]); - const userReadTime = _.mapValues(_.keyBy(userScores, 'value'), 'score'); + const userReadTimes = _.mapValues(_.keyBy(userScores, 'value'), 'score'); const isTopicsFollowed = {}; followedTids.forEach((t) => { isTopicsFollowed[t.value] = true; @@ -115,7 +115,7 @@ module.exports = function (Topics) { }); const unreadTopics = _.unionWith(categoryTids, followedTids, (a, b) => a.value === b.value) - .filter(t => !ignoredTids.includes(t.value) && (!userReadTime[t.value] || t.score > userReadTime[t.value])) + .filter(t => !ignoredTids.includes(t.value) && (!userReadTimes[t.value] || t.score > userReadTimes[t.value])) .concat(tids_unread.filter(t => !ignoredTids.includes(t.value))) .sort((a, b) => b.score - a.score); @@ -135,7 +135,8 @@ module.exports = function (Topics) { }); tids = await privileges.topics.filterTids('topics:read', tids, params.uid); - const topicData = (await Topics.getTopicsFields(tids, ['tid', 'cid', 'uid', 'postcount', 'deleted'])).filter(t => !t.deleted); + const topicData = (await Topics.getTopicsFields(tids, ['tid', 'cid', 'uid', 'postcount', 'deleted', 'scheduled'])) + .filter(t => t.scheduled || !t.deleted); const topicCids = _.uniq(topicData.map(topic => topic.cid)).filter(Boolean); const categoryWatchState = await categories.getWatchState(topicCids, params.uid); @@ -157,7 +158,7 @@ module.exports = function (Topics) { tidsByFilter.unreplied.push(topic.tid); } - if (!userReadTime[topic.tid]) { + if (!userReadTimes[topic.tid]) { tidsByFilter.new.push(topic.tid); } } @@ -273,19 +274,19 @@ module.exports = function (Topics) { return false; } const [topicScores, userScores] = await Promise.all([ - Topics.getTopicsFields(tids, ['tid', 'lastposttime']), + Topics.getTopicsFields(tids, ['tid', 'lastposttime', 'scheduled']), db.sortedSetScores(`uid:${uid}:tids_read`, tids), ]); - tids = topicScores.filter((t, i) => t.lastposttime && (!userScores[i] || userScores[i] < t.lastposttime)) - .map(t => t.tid); + const topics = topicScores.filter((t, i) => t.lastposttime && (!userScores[i] || userScores[i] < t.lastposttime)); + tids = topics.map(t => t.tid); if (!tids.length) { return false; } const now = Date.now(); - const scores = tids.map(() => now); + const scores = topics.map(topic => (topic.scheduled ? topic.lastposttime : now)); const [topicData] = await Promise.all([ Topics.getTopicsFields(tids, ['cid']), db.sortedSetAdd(`uid:${uid}:tids_read`, scores, tids), diff --git a/src/upgrades/1.17.0/schedule_privilege_for_existing_categories.js b/src/upgrades/1.17.0/schedule_privilege_for_existing_categories.js new file mode 100644 index 0000000000..ae2fef6d9f --- /dev/null +++ b/src/upgrades/1.17.0/schedule_privilege_for_existing_categories.js @@ -0,0 +1,18 @@ +'use strict'; + +const db = require('../../database'); +const privileges = require('../../privileges'); + +module.exports = { + name: 'Add "schedule" to default privileges of admins and gmods for existing categories', + timestamp: Date.UTC(2021, 2, 11), + method: async () => { + const privilegeToGive = ['groups:topics:schedule']; + + const cids = await db.getSortedSetRevRange('categories:cid', 0, -1); + for (const cid of cids) { + /* eslint-disable no-await-in-loop */ + await privileges.categories.give(privilegeToGive, cid, ['administrators', 'Global Moderators']); + } + }, +}; diff --git a/src/user/posts.js b/src/user/posts.js index 8537f7114d..b106897c05 100644 --- a/src/user/posts.js +++ b/src/user/posts.js @@ -53,9 +53,12 @@ module.exports = function (User) { } User.onNewPostMade = async function (postData) { + // For scheduled posts, use "action" time. It'll be updated in related cron job when post is published + const lastposttime = postData.timestamp > Date.now() ? Date.now() : postData.timestamp; + await User.addPostIdToUser(postData); await User.incrementUserPostCountBy(postData.uid, 1); - await User.setUserField(postData.uid, 'lastposttime', postData.timestamp); + await User.setUserField(postData.uid, 'lastposttime', lastposttime); await User.updateLastOnlineTime(postData.uid); }; diff --git a/test/categories.js b/test/categories.js index fc0262171c..d1e5cbf746 100644 --- a/test/categories.js +++ b/test/categories.js @@ -764,6 +764,7 @@ describe('Categories', () => { 'topics:create': false, 'topics:tag': false, 'topics:delete': false, + 'topics:schedule': false, 'posts:edit': false, 'posts:history': false, 'posts:upvote': false, @@ -815,6 +816,7 @@ describe('Categories', () => { 'groups:topics:create': true, 'groups:topics:reply': true, 'groups:topics:tag': true, + 'groups:topics:schedule': false, 'groups:posts:delete': true, 'groups:read': true, 'groups:topics:read': true, diff --git a/test/topics.js b/test/topics.js index cf2ea098fe..a49d621cb2 100644 --- a/test/topics.js +++ b/test/topics.js @@ -29,6 +29,7 @@ describe('Topic\'s', () => { let categoryObj; let adminUid; let adminJar; + let csrf_token; let fooUid; before(async () => { @@ -36,6 +37,7 @@ describe('Topic\'s', () => { fooUid = await User.create({ username: 'foo' }); await groups.join('administrators', adminUid); adminJar = await helpers.loginUser('admin', '123456'); + csrf_token = (await requestType('get', `${nconf.get('url')}/api/config`, { json: true, jar: adminJar })).body.csrf_token; categoryObj = await categories.create({ name: 'Test Category', @@ -2639,4 +2641,175 @@ describe('Topic\'s', () => { }); }); }); + + describe('scheduled topics', () => { + let categoryObj; + let topicData; + let topic; + let adminApiOpts; + let postData; + const replyData = { + form: { + content: 'a reply by guest', + }, + json: true, + }; + + before(async () => { + adminApiOpts = { + json: true, + jar: adminJar, + headers: { + 'x-csrf-token': csrf_token, + }, + }; + categoryObj = await categories.create({ + name: 'Another Test Category', + description: 'Another test category created by testing script', + }); + topic = { + uid: adminUid, + cid: categoryObj.cid, + title: 'Scheduled Test Topic Title', + content: 'The content of scheduled test topic', + timestamp: new Date(Date.now() + 86400000).getTime(), + }; + }); + + it('should create a scheduled topic as pinned, deleted, included in "topics:scheduled" zset and with a timestamp in future', async () => { + topicData = (await topics.post(topic)).topicData; + topicData = await topics.getTopicData(topicData.tid); + + assert(topicData.pinned); + assert(topicData.deleted); + assert(topicData.scheduled); + assert(topicData.timestamp > Date.now()); + const score = await db.sortedSetScore('topics:scheduled', topicData.tid); + assert(score); + // should not be in regular category zsets + const isMember = await db.isMemberOfSortedSets([ + `cid:${categoryObj.cid}:tids`, + `cid:${categoryObj.cid}:tids:votes`, + `cid:${categoryObj.cid}:tids:posts`, + ], topicData.tid); + assert.deepStrictEqual(isMember, [false, false, false]); + }); + + it('should not update poster\'s lastposttime', async () => { + const data = await User.getUsersFields([adminUid], ['lastposttime']); + assert.notStrictEqual(data[0].lastposttime, topicData.lastposttime); + }); + + it('should not load topic for an unprivileged user', async () => { + const response = await requestType('get', `${nconf.get('url')}/topic/${topicData.slug}`); + assert.strictEqual(response.statusCode, 404); + assert(response.body); + }); + + it('should load topic for a privileged user', async () => { + const response = (await requestType('get', `${nconf.get('url')}/topic/${topicData.slug}`, { jar: adminJar })).res; + assert.strictEqual(response.statusCode, 200); + assert(response.body); + }); + + it('should not be amongst topics of the category for an unprivileged user', async () => { + const response = await requestType('get', `${nconf.get('url')}/api/category/${categoryObj.slug}`, { json: true }); + assert.strictEqual(response.body.topics.filter(topic => topic.tid === topicData.tid).length, 0); + }); + + it('should be amongst topics of the category for a privileged user', async () => { + const response = await requestType('get', `${nconf.get('url')}/api/category/${categoryObj.slug}`, { json: true, jar: adminJar }); + const topic = response.body.topics.filter(topic => topic.tid === topicData.tid)[0]; + assert.strictEqual(topic && topic.tid, topicData.tid); + }); + + it('should load topic for guests if privilege is given', async () => { + await privileges.categories.give(['groups:topics:schedule'], categoryObj.cid, 'guests'); + const response = await requestType('get', `${nconf.get('url')}/topic/${topicData.slug}`); + assert.strictEqual(response.statusCode, 200); + assert(response.body); + }); + + it('should be amongst topics of the category for guests if privilege is given', async () => { + const response = await requestType('get', `${nconf.get('url')}/api/category/${categoryObj.slug}`, { json: true }); + const topic = response.body.topics.filter(topic => topic.tid === topicData.tid)[0]; + assert.strictEqual(topic && topic.tid, topicData.tid); + }); + + it('should not allow deletion of a scheduled topic', async () => { + const response = await requestType('delete', `${nconf.get('url')}/api/v3/topics/${topicData.tid}/state`, adminApiOpts); + assert.strictEqual(response.res.statusCode, 400); + }); + + it('should not allow to unpin a scheduled topic', async () => { + const response = await requestType('delete', `${nconf.get('url')}/api/v3/topics/${topicData.tid}/pin`, adminApiOpts); + assert.strictEqual(response.res.statusCode, 400); + }); + + it('should not allow to restore a scheduled topic', async () => { + const response = await requestType('put', `${nconf.get('url')}/api/v3/topics/${topicData.tid}/state`, adminApiOpts); + assert.strictEqual(response.res.statusCode, 400); + }); + + it('should not allow unprivileged to reply', async () => { + await privileges.categories.rescind(['groups:topics:schedule'], categoryObj.cid, 'guests'); + await privileges.categories.give(['groups:topics:reply'], categoryObj.cid, 'guests'); + const response = await requestType('post', `${nconf.get('url')}/api/v3/topics/${topicData.tid}`, replyData); + assert.strictEqual(response.res.statusCode, 403); + }); + + it('should allow guests to reply if privilege is given', async () => { + await privileges.categories.give(['groups:topics:schedule'], categoryObj.cid, 'guests'); + const response = await requestType('post', `${nconf.get('url')}/api/v3/topics/${topicData.tid}`, replyData); + assert.strictEqual(response.body.response.content, 'a reply by guest'); + assert.strictEqual(response.body.response.user.username, '[[global:guest]]'); + }); + + it('should have replies with greater timestamp than the scheduled topics itself', async () => { + const response = await requestType('get', `${nconf.get('url')}/api/topic/${topicData.slug}`, { json: true }); + postData = response.body.posts[1]; + assert(postData.timestamp > response.body.posts[0].timestamp); + }); + + it('should have post edits with greater timestamp than the original', async () => { + const editData = { ...adminApiOpts, form: { content: 'an edit by the admin' } }; + const result = await requestType('put', `${nconf.get('url')}/api/v3/posts/${postData.pid}`, editData); + assert(result.body.response.edited > postData.timestamp); + + const diffsResult = await requestType('get', `${nconf.get('url')}/api/v3/posts/${postData.pid}/diffs`, adminApiOpts); + const { revisions } = diffsResult.body.response; + // diffs are LIFO + assert(revisions[0].timestamp > revisions[1].timestamp); + }); + + it('should allow to purge a scheduled topic', async () => { + const response = await requestType('delete', `${nconf.get('url')}/api/v3/topics/${topicData.tid}`, adminApiOpts); + assert.strictEqual(response.res.statusCode, 200); + }); + + it('should remove from topics:scheduled on purge', async () => { + const score = await db.sortedSetScore('topics:scheduled', topicData.tid); + assert(!score); + }); + + it('should able to publish a scheduled topic', async () => { + topicData = (await topics.post(topic)).topicData; + // Manually trigger publishing + await db.sortedSetRemove('topics:scheduled', topicData.tid); + await db.sortedSetAdd('topics:scheduled', Date.now() - 1000, topicData.tid); + await topics.scheduled.handleExpired(); + + topicData = await topics.getTopicData(topicData.tid); + assert(!topicData.pinned); + assert(!topicData.deleted); + // Should remove from topics:scheduled upon publishing + const score = await db.sortedSetScore('topics:scheduled', topicData.tid); + assert(!score); + }); + + it('should update poster\'s lastposttime after a ST published', async () => { + const data = await User.getUsersFields([adminUid], ['lastposttime']); + assert.strictEqual(data[0].lastposttime, topicData.lastposttime); + }); + }); });