feat: scheduled topics (#9399)

* feat: scheduled topics

* refactor: linting fixes

* fix: tests

* fix(test): race condition

* fix: make a single request
v1.18.x
gasoved 4 years ago committed by GitHub
parent 67b09cba5f
commit 077330b764
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -25,6 +25,7 @@
"access-topics": "Access Topics", "access-topics": "Access Topics",
"create-topics": "Create Topics", "create-topics": "Create Topics",
"reply-to-topics": "Reply to Topics", "reply-to-topics": "Reply to Topics",
"schedule-topics": "Schedule Topics",
"tag-topics": "Tag Topics", "tag-topics": "Tag Topics",
"edit-posts": "Edit Posts", "edit-posts": "Edit Posts",
"view-edit-history": "View Edit History", "view-edit-history": "View Edit History",

@ -104,6 +104,13 @@
"guest-upload-disabled": "Guest uploading has been disabled", "guest-upload-disabled": "Guest uploading has been disabled",
"cors-error": "Unable to upload image due to misconfigured CORS", "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-bookmarked": "You have already bookmarked this post",
"already-unbookmarked": "You have already unbookmarked this post", "already-unbookmarked": "You have already unbookmarked this post",

@ -62,6 +62,11 @@
"composer.zen_mode": "Zen Mode", "composer.zen_mode": "Zen Mode",
"composer.select_category": "Select a category", "composer.select_category": "Select a category",
"composer.textarea.placeholder": "Enter your post content here, drag and drop images", "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", "bootbox.ok": "OK",

@ -34,6 +34,7 @@
"locked": "Locked", "locked": "Locked",
"pinned": "Pinned", "pinned": "Pinned",
"pinned-with-expiry": "Pinned until %1", "pinned-with-expiry": "Pinned until %1",
"scheduled": "Scheduled",
"moved": "Moved", "moved": "Moved",
"moved-from": "Moved from %1", "moved-from": "Moved from %1",
"copy-ip": "Copy IP", "copy-ip": "Copy IP",
@ -153,6 +154,7 @@
"composer.handle_placeholder": "Enter your name/handle here", "composer.handle_placeholder": "Enter your name/handle here",
"composer.discard": "Discard", "composer.discard": "Discard",
"composer.submit": "Submit", "composer.submit": "Submit",
"composer.schedule": "Schedule",
"composer.replying_to": "Replying to %1", "composer.replying_to": "Replying to %1",
"composer.new_topic": "New Topic", "composer.new_topic": "New Topic",
"composer.editing": "Editing", "composer.editing": "Editing",

@ -76,6 +76,13 @@ PostObject:
type: string type: string
deleted: deleted:
type: number type: number
scheduled:
type: number
timestamp:
type: number
timestampISO:
type: string
description: An ISO 8601 formatted date string (complementing `timestamp`)
postcount: postcount:
type: number type: number
mainPid: mainPid:

@ -213,6 +213,8 @@ TopicObjectSlim:
type: number type: number
postercount: postercount:
type: number type: number
scheduled:
type: number
deleted: deleted:
type: number type: number
deleterUid: deleterUid:

@ -70,6 +70,8 @@ get:
type: boolean type: boolean
topics:tag: topics:tag:
type: boolean type: boolean
topics:schedule:
type: boolean
read: read:
type: boolean type: boolean
posts:view_deleted: posts:view_deleted:

@ -335,6 +335,8 @@ get:
type: boolean type: boolean
view_deleted: view_deleted:
type: boolean type: boolean
view_scheduled:
type: boolean
isAdminOrMod: isAdminOrMod:
type: boolean type: boolean
disabled: disabled:

@ -190,16 +190,20 @@ define('forum/category/tools', [
var areAllDeleted = areAll(isTopicDeleted, tids); var areAllDeleted = areAll(isTopicDeleted, tids);
var isAnyPinned = isAny(isTopicPinned, tids); var isAnyPinned = isAny(isTopicPinned, tids);
var isAnyLocked = isAny(isTopicLocked, 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/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/purge').toggleClass('hidden', !areAllDeleted);
components.get('topic/lock').toggleClass('hidden', isAnyLocked); components.get('topic/lock').toggleClass('hidden', isAnyLocked);
components.get('topic/unlock').toggleClass('hidden', !isAnyLocked); components.get('topic/unlock').toggleClass('hidden', !isAnyLocked);
components.get('topic/pin').toggleClass('hidden', isAnyPinned); components.get('topic/pin').toggleClass('hidden', areAllScheduled || isAnyPinned);
components.get('topic/unpin').toggleClass('hidden', !isAnyPinned); components.get('topic/unpin').toggleClass('hidden', areAllScheduled || !isAnyPinned);
components.get('topic/merge').toggleClass('hidden', isAnyScheduled);
} }
function isAny(method, tids) { function isAny(method, tids) {
@ -232,6 +236,10 @@ define('forum/category/tools', [
return getTopicEl(tid).hasClass('pinned'); return getTopicEl(tid).hasClass('pinned');
} }
function isTopicScheduled(tid) {
return getTopicEl(tid).hasClass('scheduled');
}
function getTopicEl(tid) { function getTopicEl(tid) {
return components.get('category/topic', 'tid', tid); return components.get('category/topic', 'tid', tid);
} }

@ -99,6 +99,9 @@ define('forum/topic/move-post', [
if (!data || !data.tid) { if (!data || !data.tid) {
return app.alertError('[[error:no-topic]]'); 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); var translateStr = translator.compile('topic:x-posts-will-be-moved-to-y', postSelect.pids.length, data.title);
moveModal.find('#pids').translateHtml(translateStr); moveModal.find('#pids').translateHtml(translateStr);
}); });

@ -25,8 +25,8 @@ define('forum/topic/posts', [
data.loggedIn = !!app.user.uid; data.loggedIn = !!app.user.uid;
data.privileges = ajaxify.data.privileges; data.privileges = ajaxify.data.privileges;
// prevent timeago in future by setting timestamp to 1 sec behind now // if not a scheduled topic, prevent timeago in future by setting timestamp to 1 sec behind now
data.posts[0].timestamp = Date.now() - 1000; 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); data.posts[0].timestampISO = utils.toISOString(data.posts[0].timestamp);
Posts.modifyPostsByPrivileges(data.posts); Posts.modifyPostsByPrivileges(data.posts);

@ -155,6 +155,10 @@
style.push('unread'); style.push('unread');
} }
if (topic.scheduled) {
style.push('scheduled');
}
return style.join(' '); return style.join(' ');
} }

@ -23,6 +23,8 @@ define('topicList', [
var loadTopicsCallback; var loadTopicsCallback;
var topicListEl; var topicListEl;
const scheduledTopics = [];
$(window).on('action:ajaxify.start', function () { $(window).on('action:ajaxify.start', function () {
TopicList.removeListeners(); TopicList.removeListeners();
categoryTools.removeListeners(); categoryTools.removeListeners();
@ -95,54 +97,48 @@ define('topicList', [
}; };
function onNewTopic(data) { function onNewTopic(data) {
if ( const d = ajaxify.data;
(
ajaxify.data.selectedCids && const categories = d.selectedCids &&
ajaxify.data.selectedCids.length && d.selectedCids.length &&
ajaxify.data.selectedCids.indexOf(parseInt(data.cid, 10)) === -1 d.selectedCids.indexOf(parseInt(data.cid, 10)) === -1;
) || const filterWatched = d.selectedFilter &&
( d.selectedFilter.filter === 'watched';
ajaxify.data.selectedFilter && const category = d.template.category &&
ajaxify.data.selectedFilter.filter === 'watched' parseInt(d.cid, 10) !== parseInt(data.cid, 10);
) ||
( if (categories || filterWatched || category || scheduledTopics.includes(data.tid)) {
ajaxify.data.template.category &&
parseInt(ajaxify.data.cid, 10) !== parseInt(data.cid, 10)
)
) {
return; return;
} }
if (data.scheduled && data.tid) {
scheduledTopics.push(data.tid);
}
newTopicCount += 1; newTopicCount += 1;
updateAlertText(); updateAlertText();
} }
function onNewPost(data) { function onNewPost(data) {
var post = data.posts[0]; var post = data.posts[0];
if (!post || !post.topic) { if (!post || !post.topic || post.topic.isFollowing) {
return; return;
} }
if (!post.topic.isFollowing && (
parseInt(post.topic.mainPid, 10) === parseInt(post.pid, 10) || const d = ajaxify.data;
(
ajaxify.data.selectedCids && const isMain = parseInt(post.topic.mainPid, 10) === parseInt(post.pid, 10);
ajaxify.data.selectedCids.length && const categories = d.selectedCids &&
ajaxify.data.selectedCids.indexOf(parseInt(post.topic.cid, 10)) === -1 d.selectedCids.length &&
) || d.selectedCids.indexOf(parseInt(post.topic.cid, 10)) === -1;
( const filterNew = d.selectedFilter &&
ajaxify.data.selectedFilter && d.selectedFilter.filter === 'new';
ajaxify.data.selectedFilter.filter === 'new' const filterWatched = d.selectedFilter &&
) || d.selectedFilter.filter === 'watched' &&
( !post.topic.isFollowing;
ajaxify.data.selectedFilter && const category = d.template.category &&
ajaxify.data.selectedFilter.filter === 'watched' && parseInt(d.cid, 10) !== parseInt(post.topic.cid, 10);
!post.topic.isFollowing
) || if (isMain || categories || filterNew || filterWatched || category) {
(
ajaxify.data.template.category &&
parseInt(ajaxify.data.cid, 10) !== parseInt(post.topic.cid, 10)
)
)) {
return; return;
} }

@ -13,7 +13,7 @@ const events = require('../events');
exports.setDefaultPostData = function (reqOrSocket, data) { exports.setDefaultPostData = function (reqOrSocket, data) {
data.uid = reqOrSocket.uid; data.uid = reqOrSocket.uid;
data.req = exports.buildReqObject(reqOrSocket, { ...data }); data.req = exports.buildReqObject(reqOrSocket, { ...data });
data.timestamp = Date.now(); data.timestamp = parseInt(data.timestamp, 10) || Date.now();
data.fromQueue = false; data.fromQueue = false;
}; };

@ -20,7 +20,12 @@ topicsAPI.get = async function (caller, data) {
privileges.topics.get(data.tid, caller.uid), privileges.topics.get(data.tid, caller.uid),
topics.getTopicData(data.tid), 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; return null;
} }

@ -80,6 +80,7 @@ module.exports = function (Categories) {
'groups:topics:delete', 'groups:topics:delete',
]; ];
const modPrivileges = defaultPrivileges.concat([ const modPrivileges = defaultPrivileges.concat([
'groups:topics:schedule',
'groups:posts:view_deleted', 'groups:posts:view_deleted',
'groups:purge', 'groups:purge',
]); ]);

@ -55,7 +55,7 @@ module.exports = function (Categories) {
topicData = await topics.getTopicData(postData.tid); topicData = await topics.getTopicData(postData.tid);
} }
index += 1; index += 1;
} while (!topicData || topicData.deleted); } while (!topicData || topicData.deleted || topicData.scheduled);
if (postData && postData.tid) { if (postData && postData.tid) {
await Categories.updateRecentTid(cid, postData.tid); await Categories.updateRecentTid(cid, postData.tid);

@ -4,6 +4,7 @@ const db = require('../database');
const topics = require('../topics'); const topics = require('../topics');
const plugins = require('../plugins'); const plugins = require('../plugins');
const meta = require('../meta'); const meta = require('../meta');
const privileges = require('../privileges');
const user = require('../user'); const user = require('../user');
module.exports = function (Categories) { module.exports = function (Categories) {
@ -142,7 +143,12 @@ module.exports = function (Categories) {
}); });
return result && result.pinnedTids; 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); return await topics.tools.checkPinExpiry(pinnedTids);
}; };
@ -152,7 +158,7 @@ module.exports = function (Categories) {
} }
topics.forEach((topic) => { topics.forEach((topic) => {
if (topic.deleted && !topic.isOwner) { if (!topic.scheduled && topic.deleted && !topic.isOwner) {
topic.title = '[[topic:topic_is_deleted]]'; topic.title = '[[topic:topic_is_deleted]]';
topic.slug = topic.tid; topic.slug = topic.tid;
topic.teaser = null; topic.teaser = null;
@ -176,4 +182,10 @@ module.exports = function (Categories) {
await Promise.all(promises); await Promise.all(promises);
await Categories.updateRecentTidForCid(cid); 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));
}
}; };

@ -8,6 +8,7 @@ const user = require('../../user');
const posts = require('../../posts'); const posts = require('../../posts');
const categories = require('../../categories'); const categories = require('../../categories');
const meta = require('../../meta'); const meta = require('../../meta');
const privileges = require('../../privileges');
const accountHelpers = require('./helpers'); const accountHelpers = require('./helpers');
const helpers = require('../helpers'); const helpers = require('../helpers');
const utils = require('../../utils'); const utils = require('../../utils');
@ -91,11 +92,13 @@ async function getPosts(callerUid, userData, setSuffix) {
const count = 10; const count = 10;
const postData = []; const postData = [];
const [isAdmin, isModOfCids] = await Promise.all([ const [isAdmin, isModOfCids, canSchedule] = await Promise.all([
user.isAdministrator(callerUid), user.isAdministrator(callerUid),
user.isModerator(callerUid, cids), user.isModerator(callerUid, cids),
privileges.categories.isUserAllowedTo('topics:schedule', cids, callerUid),
]); ]);
const cidToIsMod = _.zipObject(cids, isModOfCids); const cidToIsMod = _.zipObject(cids, isModOfCids);
const cidToCanSchedule = _.zipObject(cids, canSchedule);
do { do {
/* eslint-disable no-await-in-loop */ /* eslint-disable no-await-in-loop */
@ -106,7 +109,8 @@ async function getPosts(callerUid, userData, setSuffix) {
if (pids.length) { if (pids.length) {
const p = await posts.getPostSummaryByPids(pids, callerUid, { stripTags: false }); const p = await posts.getPostSummaryByPids(pids, callerUid, { stripTags: false });
postData.push(...p.filter( 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; start += count;

@ -43,15 +43,17 @@ topicsController.get = async function getTopic(req, res, callback) {
let currentPage = parseInt(req.query.page, 10) || 1; let currentPage = parseInt(req.query.page, 10) || 1;
const pageCount = Math.max(1, Math.ceil((topicData && topicData.postcount) / settings.postsPerPage)); const pageCount = Math.max(1, Math.ceil((topicData && topicData.postcount) / settings.postsPerPage));
const validPagination = (settings.usePagination && (currentPage < 1 || currentPage > pageCount));
if ( if (
!topicData || !topicData ||
userPrivileges.disabled || userPrivileges.disabled ||
(settings.usePagination && (currentPage < 1 || currentPage > pageCount)) validPagination ||
(topicData.scheduled && !userPrivileges.view_scheduled)
) { ) {
return callback(); 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); return helpers.notAllowed(req, res);
} }
@ -343,7 +345,7 @@ topicsController.pagination = async function (req, res, callback) {
return callback(); return callback();
} }
if (!userPrivileges.read || (topic.deleted && !userPrivileges.view_deleted)) { if (!userPrivileges.read || !privileges.topics.canViewDeletedScheduled(topic, userPrivileges)) {
return helpers.notAllowed(req, res); return helpers.notAllowed(req, res);
} }

@ -38,12 +38,12 @@ module.exports = function (Posts) {
}; };
Diffs.save = async function (data) { Diffs.save = async function (data) {
const { pid, uid, oldContent, newContent } = data; const { pid, uid, oldContent, newContent, edited } = data;
const now = Date.now(); const editTimestamp = edited || Date.now();
const patch = diff.createPatch('', newContent, oldContent); const patch = diff.createPatch('', newContent, oldContent);
await Promise.all([ await Promise.all([
db.listPrepend(`post:${pid}:diffs`, now), db.listPrepend(`post:${pid}:diffs`, editTimestamp),
db.setObject(`diff:${pid}.${now}`, { db.setObject(`diff:${pid}.${editTimestamp}`, {
uid: uid, uid: uid,
pid: pid, pid: pid,
patch: patch, patch: patch,
@ -135,7 +135,7 @@ module.exports = function (Posts) {
function getValidatedTimestamp(timestamp) { function getValidatedTimestamp(timestamp) {
timestamp = parseInt(timestamp, 10); timestamp = parseInt(timestamp, 10);
if (isNaN(timestamp) || timestamp > Date.now()) { if (isNaN(timestamp)) {
throw new Error('[[error:invalid-data]]'); throw new Error('[[error:invalid-data]]');
} }

@ -29,11 +29,13 @@ module.exports = function (Posts) {
throw new Error('[[error:no-post]]'); 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 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 = { const editPostData = {
content: data.content, content: data.content,
edited: now, edited: postTimestamp,
editor: data.uid, editor: data.uid,
}; };
if (data.handle) { if (data.handle) {
@ -49,7 +51,7 @@ module.exports = function (Posts) {
const [editor, topic] = await Promise.all([ const [editor, topic] = await Promise.all([
user.getUserFields(data.uid, ['username', 'userslug']), user.getUserFields(data.uid, ['username', 'userslug']),
editMainPost(data, postData), editMainPost(data, postData, topicData),
]); ]);
await Posts.setPostFields(data.pid, result.post); await Posts.setPostFields(data.pid, result.post);
@ -60,6 +62,7 @@ module.exports = function (Posts) {
uid: data.uid, uid: data.uid,
oldContent: oldContent, oldContent: oldContent,
newContent: data.content, newContent: data.content,
edited: postTimestamp,
}); });
} }
await Posts.uploads.sync(data.pid); await Posts.uploads.sync(data.pid);
@ -70,7 +73,7 @@ module.exports = function (Posts) {
const returnPostData = { ...postData, ...result.post }; const returnPostData = { ...postData, ...result.post };
returnPostData.cid = topic.cid; returnPostData.cid = topic.cid;
returnPostData.topic = topic; returnPostData.topic = topic;
returnPostData.editedISO = utils.toISOString(now); returnPostData.editedISO = utils.toISOString(postTimestamp);
returnPostData.changed = oldContent !== data.content; returnPostData.changed = oldContent !== data.content;
await topics.notifyFollowers(returnPostData, data.uid, { 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 { tid } = postData;
const title = data.title ? data.title.trim() : ''; const title = data.title ? data.title.trim() : '';
const [topicData, isMain] = await Promise.all([ const isMain = await Posts.isMain(data.pid);
topics.getTopicFields(tid, ['cid', 'title', 'timestamp']),
Posts.isMain(data.pid),
]);
if (!isMain) { if (!isMain) {
return { return {
tid: tid, tid: tid,

@ -76,7 +76,7 @@ module.exports = function (Posts) {
} }
async function getTopicAndCategories(tids) { 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 cids = _.uniq(topicsData.map(topic => topic && topic.cid));
const categoriesData = await categories.getCategoriesFields(cids, ['cid', 'name', 'icon', 'slug', 'parentCid', 'bgColor', 'color', 'backgroundImage', 'imageClass']); const categoriesData = await categories.getCategoriesFields(cids, ['cid', 'name', 'icon', 'slug', 'parentCid', 'bgColor', 'color', 'backgroundImage', 'imageClass']);
return { topics: topicsData, categories: categoriesData }; return { topics: topicsData, categories: categoriesData };

@ -18,6 +18,7 @@ privsCategories.privilegeLabels = [
{ name: '[[admin/manage/privileges:access-topics]]' }, { name: '[[admin/manage/privileges:access-topics]]' },
{ name: '[[admin/manage/privileges:create-topics]]' }, { name: '[[admin/manage/privileges:create-topics]]' },
{ name: '[[admin/manage/privileges:reply-to-topics]]' }, { name: '[[admin/manage/privileges:reply-to-topics]]' },
{ name: '[[admin/manage/privileges:schedule-topics]]' },
{ name: '[[admin/manage/privileges:tag-topics]]' }, { name: '[[admin/manage/privileges:tag-topics]]' },
{ name: '[[admin/manage/privileges:edit-posts]]' }, { name: '[[admin/manage/privileges:edit-posts]]' },
{ name: '[[admin/manage/privileges:view-edit-history]]' }, { name: '[[admin/manage/privileges:view-edit-history]]' },
@ -36,6 +37,7 @@ privsCategories.userPrivilegeList = [
'topics:read', 'topics:read',
'topics:create', 'topics:create',
'topics:reply', 'topics:reply',
'topics:schedule',
'topics:tag', 'topics:tag',
'posts:edit', 'posts:edit',
'posts:history', 'posts:history',
@ -79,8 +81,8 @@ privsCategories.list = async function (cid) {
privsCategories.get = async function (cid, uid) { privsCategories.get = async function (cid, uid) {
const privs = [ const privs = [
'topics:create', 'topics:read', 'topics:tag', 'topics:create', 'topics:read', 'topics:schedule',
'read', 'posts:view_deleted', 'topics:tag', 'read', 'posts:view_deleted',
]; ];
const [userPrivileges, isAdministrator, isModerator] = await Promise.all([ const [userPrivileges, isAdministrator, isModerator] = await Promise.all([
@ -162,6 +164,7 @@ privsCategories.getBase = async function (privilege, cids, uid) {
categories: categories.getCategoriesFields(cids, ['disabled']), categories: categories.getCategoriesFields(cids, ['disabled']),
allowedTo: helpers.isAllowedTo(privilege, uid, cids), allowedTo: helpers.isAllowedTo(privilege, uid, cids),
view_deleted: helpers.isAllowedTo('posts:view_deleted', uid, cids), view_deleted: helpers.isAllowedTo('posts:view_deleted', uid, cids),
view_scheduled: helpers.isAllowedTo('topics:schedule', uid, cids),
isAdmin: user.isAdministrator(uid), isAdmin: user.isAdministrator(uid),
}); });
}; };

@ -11,6 +11,7 @@ const helpers = require('./helpers');
const plugins = require('../plugins'); const plugins = require('../plugins');
const utils = require('../utils'); const utils = require('../utils');
const privsCategories = require('./categories'); const privsCategories = require('./categories');
const privsTopics = require('./topics');
const privsPosts = module.exports; const privsPosts = module.exports;
@ -73,7 +74,7 @@ privsPosts.filter = async function (privilege, pids, uid) {
pids = _.uniq(pids); pids = _.uniq(pids);
const postData = await posts.getPostsFields(pids, ['uid', 'tid', 'deleted']); const postData = await posts.getPostsFields(pids, ['uid', 'tid', 'deleted']);
const tids = _.uniq(postData.map(post => post && post.tid).filter(Boolean)); 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); const tidToTopic = _.zipObject(tids, topicData);
@ -93,11 +94,15 @@ privsPosts.filter = async function (privilege, pids, uid) {
const cidsSet = new Set(allowedCids); const cidsSet = new Set(allowedCids);
const canViewDeleted = _.zipObject(cids, results.view_deleted); const canViewDeleted = _.zipObject(cids, results.view_deleted);
const canViewScheduled = _.zipObject(cids, results.view_scheduled);
pids = postData.filter(post => ( pids = postData.filter(post => (
post.topic && post.topic &&
cidsSet.has(post.topic.cid) && 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); )).map(post => post.pid);
const data = await plugins.hooks.fire('filter:privileges.posts.filter', { const data = await plugins.hooks.fire('filter:privileges.posts.filter', {

@ -17,11 +17,11 @@ privsTopics.get = async function (tid, uid) {
uid = parseInt(uid, 10); uid = parseInt(uid, 10);
const privs = [ const privs = [
'topics:reply', 'topics:read', 'topics:tag', 'topics:reply', 'topics:read', 'topics:schedule', 'topics:tag',
'topics:delete', 'posts:edit', 'posts:history', 'topics:delete', 'posts:edit', 'posts:history',
'posts:delete', 'posts:view_deleted', 'read', 'purge', '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([ const [userPrivileges, isAdministrator, isModerator, disabled] = await Promise.all([
helpers.isAllowedTo(privs, uid, topicData.cid), helpers.isAllowedTo(privs, uid, topicData.cid),
user.isAdministrator(uid), user.isAdministrator(uid),
@ -33,9 +33,10 @@ privsTopics.get = async function (tid, uid) {
const isAdminOrMod = isAdministrator || isModerator; const isAdminOrMod = isAdministrator || isModerator;
const editable = isAdminOrMod; const editable = isAdminOrMod;
const deletable = (privData['topics:delete'] && (isOwner || isModerator)) || isAdministrator; 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', { 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:read': privData['topics:read'] || isAdministrator,
'topics:tag': privData['topics:tag'] || isAdministrator, 'topics:tag': privData['topics:tag'] || isAdministrator,
'topics:delete': (privData['topics:delete'] && (isOwner || isModerator)) || isAdministrator, 'topics:delete': (privData['topics:delete'] && (isOwner || isModerator)) || isAdministrator,
@ -50,6 +51,7 @@ privsTopics.get = async function (tid, uid) {
editable: editable, editable: editable,
deletable: deletable, deletable: deletable,
view_deleted: isAdminOrMod || isOwner || privData['posts:view_deleted'], view_deleted: isAdminOrMod || isOwner || privData['posts:view_deleted'],
view_scheduled: privData['topics:schedule'] || isAdministrator,
isAdminOrMod: isAdminOrMod, isAdminOrMod: isAdminOrMod,
disabled: disabled, disabled: disabled,
tid: tid, tid: tid,
@ -67,7 +69,7 @@ privsTopics.filterTids = async function (privilege, tids, uid) {
return []; 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 cids = _.uniq(topicsData.map(topic => topic.cid));
const results = await privsCategories.getBase(privilege, cids, uid); 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 cidsSet = new Set(allowedCids);
const canViewDeleted = _.zipObject(cids, results.view_deleted); const canViewDeleted = _.zipObject(cids, results.view_deleted);
const canViewScheduled = _.zipObject(cids, results.view_scheduled);
tids = topicsData.filter(t => ( tids = topicsData.filter(t => (
cidsSet.has(t.cid) && 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); )).map(t => t.tid);
const data = await plugins.hooks.fire('filter:privileges.topics.filter', { const data = await plugins.hooks.fire('filter:privileges.topics.filter', {
@ -98,14 +101,20 @@ privsTopics.filterUids = async function (privilege, tid, uids) {
} }
uids = _.uniq(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([ const [disabled, allowedTo, isAdmins] = await Promise.all([
categories.getCategoryField(topicData.cid, 'disabled'), categories.getCategoryField(topicData.cid, 'disabled'),
helpers.isUsersAllowedTo(privilege, uids, topicData.cid), helpers.isUsersAllowedTo(privilege, uids, topicData.cid),
user.isAdministrator(uids), 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 && 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) { privsTopics.canPurge = async function (tid, uid) {
@ -163,3 +172,20 @@ privsTopics.isAdminOrMod = async function (tid, uid) {
const cid = await topics.getTopicField(tid, 'cid'); const cid = await topics.getTopicField(tid, 'cid');
return await privsCategories.isAdminOrMod(cid, uid); 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;
};

@ -72,7 +72,7 @@ async function generateForTopic(req, res) {
topics.getTopicData(tid), topics.getTopicData(tid),
]); ]);
if (!topic || (topic.deleted && !userPrivileges.view_deleted)) { if (!privileges.topics.canViewDeletedScheduled(topic, userPrivileges)) {
return controllers404.send404(req, res); return controllers404.send404(req, res);
} }

@ -14,10 +14,10 @@ module.exports = function (SocketTopics) {
const [userPrivileges, topicData] = await Promise.all([ const [userPrivileges, topicData] = await Promise.all([
privileges.topics.get(data.tid, socket.uid), 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]]'); throw new Error('[[error:no-privileges]]');
} }

@ -38,6 +38,7 @@ start.start = async function () {
require('./notifications').startJobs(); require('./notifications').startJobs();
require('./user').startJobs(); require('./user').startJobs();
require('./plugins').startJobs(); require('./plugins').startJobs();
require('./topics').scheduled.startJobs();
await db.delete('locks'); await db.delete('locks');
} }

@ -38,23 +38,33 @@ module.exports = function (Topics) {
topicData = result.topic; topicData = result.topic;
await db.setObject(`topic:${topicData.tid}`, topicData); await db.setObject(`topic:${topicData.tid}`, topicData);
await Promise.all([ const timestampedSortedSetKeys = [
db.sortedSetsAdd([
'topics:tid', 'topics:tid',
`cid:${topicData.cid}:tids`, `cid:${topicData.cid}:tids`,
`cid:${topicData.cid}:uid:${topicData.uid}:tids`, `cid:${topicData.cid}:uid:${topicData.uid}:tids`,
], timestamp, topicData.tid), ];
const scheduled = timestamp > Date.now();
if (scheduled) {
timestampedSortedSetKeys.push('topics:scheduled');
}
await Promise.all([
db.sortedSetsAdd(timestampedSortedSetKeys, timestamp, topicData.tid),
db.sortedSetsAdd([ db.sortedSetsAdd([
'topics:views', 'topics:posts', 'topics:votes', 'topics:views', 'topics:posts', 'topics:votes',
`cid:${topicData.cid}:tids:votes`, `cid:${topicData.cid}:tids:votes`,
`cid:${topicData.cid}:tids:posts`, `cid:${topicData.cid}:tids:posts`,
], 0, topicData.tid), ], 0, topicData.tid),
categories.updateRecentTid(topicData.cid, topicData.tid),
user.addTopicIdToUser(topicData.uid, topicData.tid, timestamp), user.addTopicIdToUser(topicData.uid, topicData.tid, timestamp),
db.incrObjectField(`category:${topicData.cid}`, 'topic_count'), db.incrObjectField(`category:${topicData.cid}`, 'topic_count'),
db.incrObjectField('global', 'topicCount'), db.incrObjectField('global', 'topicCount'),
Topics.createTags(data.tags, topicData.tid, timestamp), 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 }); plugins.hooks.fire('action:topic.save', { topic: _.clone(topicData), data: data });
return topicData.tid; return topicData.tid;
@ -118,10 +128,14 @@ module.exports = function (Topics) {
topicData.index = 0; topicData.index = 0;
postData.index = 0; postData.index = 0;
if (topicData.scheduled) {
await Topics.delete(tid);
}
analytics.increment(['topics', `topics:byCid:${topicData.cid}`]); analytics.increment(['topics', `topics:byCid:${topicData.cid}`]);
plugins.hooks.fire('action:topic.post', { topic: topicData, post: postData, data: data }); 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); user.notifications.sendTopicNotificationToFollowers(uid, topicData, postData);
} }
@ -136,28 +150,10 @@ module.exports = function (Topics) {
const { uid } = data; const { uid } = data;
const topicData = await Topics.getTopicData(tid); const topicData = await Topics.getTopicData(tid);
if (!topicData) {
throw new Error('[[error:no-topic]]');
}
data.cid = topicData.cid; await canReply(data, topicData);
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]]');
}
if (!canReply) { data.cid = topicData.cid;
throw new Error('[[error:no-privileges]]');
}
await guestHandleValid(data); await guestHandleValid(data);
if (!data.fromQueue) { if (!data.fromQueue) {
@ -169,6 +165,11 @@ module.exports = function (Topics) {
} }
Topics.checkContent(data.content); 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; data.ip = data.req ? data.req.ip : null;
let postData = await posts.create(data); let postData = await posts.create(data);
postData = await onNewPost(postData, data); postData = await onNewPost(postData, data);
@ -207,7 +208,7 @@ module.exports = function (Topics) {
topicInfo, topicInfo,
] = await Promise.all([ ] = await Promise.all([
posts.getUserInfoForPosts([postData.uid], uid), 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]), Topics.addParentPosts([postData]),
posts.parsePost(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]]');
}
}
}; };

@ -20,6 +20,12 @@ module.exports = function (Topics) {
if (!Array.isArray(tids) || !tids.length) { if (!Array.isArray(tids) || !tids.length) {
return []; return [];
} }
// "scheduled" is derived from "timestamp"
if (fields.includes('scheduled') && !fields.includes('timestamp')) {
fields.push('timestamp');
}
const keys = tids.map(tid => `topic:${tid}`); const keys = tids.map(tid => `topic:${tid}`);
const topics = await db.getObjects(keys, fields); const topics = await db.getObjects(keys, fields);
const result = await plugins.hooks.fire('filter:topic.getFields', { const result = await plugins.hooks.fire('filter:topic.getFields', {
@ -100,6 +106,9 @@ function modifyTopic(topic, fields) {
if (topic.hasOwnProperty('timestamp')) { if (topic.hasOwnProperty('timestamp')) {
topic.timestampISO = utils.toISOString(topic.timestamp); topic.timestampISO = utils.toISOString(topic.timestamp);
if (!fields.length || fields.includes('scheduled')) {
topic.scheduled = topic.timestamp > Date.now();
}
} }
if (topic.hasOwnProperty('lastposttime')) { if (topic.hasOwnProperty('lastposttime')) {

@ -95,6 +95,7 @@ module.exports = function (Topics) {
'topics:posts', 'topics:posts',
'topics:views', 'topics:views',
'topics:votes', 'topics:votes',
'topics:scheduled',
], tid), ], tid),
deleteTopicFromCategoryAndUser(tid), deleteTopicFromCategoryAndUser(tid),
Topics.deleteTopicTags(tid), Topics.deleteTopicTags(tid),

@ -39,7 +39,14 @@ module.exports = function (Topics) {
if (!isAdminOrMod) { if (!isAdminOrMod) {
throw new Error('[[error:no-privileges]]'); 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 Topics.updateTopicBookmarks(fromTid, pids);
await async.eachSeries(pids, async (pid) => { await async.eachSeries(pids, async (pid) => {
@ -47,10 +54,10 @@ module.exports = function (Topics) {
if (!canEdit.flag) { if (!canEdit.flag) {
throw new Error(canEdit.message); 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([ await Promise.all([
Topics.setTopicFields(tid, { Topics.setTopicFields(tid, {
@ -65,17 +72,25 @@ module.exports = function (Topics) {
return await Topics.getTopicData(tid); 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); tid = parseInt(tid, 10);
const exists = await Topics.exists(tid); const topicData = await Topics.getTopicFields(tid, ['tid', 'scheduled']);
if (!exists) { if (!topicData.tid) {
throw new Error('[[error:no-topic]]'); 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']); const postData = await posts.getPostFields(pid, ['tid', 'uid', 'timestamp', 'upvotes', 'downvotes']);
if (!postData || !postData.tid) { if (!postData || !postData.tid) {
throw new Error('[[error:no-post]]'); 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) { if (postData.tid === tid) {
throw new Error('[[error:cant-move-to-same-topic]]'); throw new Error('[[error:cant-move-to-same-topic]]');
} }

@ -27,6 +27,7 @@ require('./posts')(Topics);
require('./follow')(Topics); require('./follow')(Topics);
require('./tags')(Topics); require('./tags')(Topics);
require('./teaser')(Topics); require('./teaser')(Topics);
Topics.scheduled = require('./scheduled');
require('./suggested')(Topics); require('./suggested')(Topics);
require('./tools')(Topics); require('./tools')(Topics);
Topics.thumbs = require('./thumbs'); Topics.thumbs = require('./thumbs');

@ -6,6 +6,12 @@ const plugins = require('../plugins');
module.exports = function (Topics) { module.exports = function (Topics) {
Topics.merge = async function (tids, uid, options) { Topics.merge = async function (tids, uid, options) {
options = 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); const oldestTid = findOldestTopic(tids);
let mergeIntoTid = oldestTid; let mergeIntoTid = oldestTid;
if (options.mainTid) { if (options.mainTid) {

@ -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])));
}

@ -27,6 +27,10 @@ module.exports = function (Topics) {
if (!topicData) { if (!topicData) {
throw new Error('[[error:no-topic]]'); 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 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 }); 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]]'); 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)) { if (uid !== 'system' && !await privileges.topics.isAdminOrMod(tid, uid)) {
throw new Error('[[error:no-privileges]]'); throw new Error('[[error:no-privileges]]');
} }

@ -101,7 +101,7 @@ module.exports = function (Topics) {
db.getSortedSetRevRangeWithScores(`uid:${params.uid}:tids_unread`, 0, -1), 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 = {}; const isTopicsFollowed = {};
followedTids.forEach((t) => { followedTids.forEach((t) => {
isTopicsFollowed[t.value] = true; isTopicsFollowed[t.value] = true;
@ -115,7 +115,7 @@ module.exports = function (Topics) {
}); });
const unreadTopics = _.unionWith(categoryTids, followedTids, (a, b) => a.value === b.value) 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))) .concat(tids_unread.filter(t => !ignoredTids.includes(t.value)))
.sort((a, b) => b.score - a.score); .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); 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 topicCids = _.uniq(topicData.map(topic => topic.cid)).filter(Boolean);
const categoryWatchState = await categories.getWatchState(topicCids, params.uid); const categoryWatchState = await categories.getWatchState(topicCids, params.uid);
@ -157,7 +158,7 @@ module.exports = function (Topics) {
tidsByFilter.unreplied.push(topic.tid); tidsByFilter.unreplied.push(topic.tid);
} }
if (!userReadTime[topic.tid]) { if (!userReadTimes[topic.tid]) {
tidsByFilter.new.push(topic.tid); tidsByFilter.new.push(topic.tid);
} }
} }
@ -273,19 +274,19 @@ module.exports = function (Topics) {
return false; return false;
} }
const [topicScores, userScores] = await Promise.all([ const [topicScores, userScores] = await Promise.all([
Topics.getTopicsFields(tids, ['tid', 'lastposttime']), Topics.getTopicsFields(tids, ['tid', 'lastposttime', 'scheduled']),
db.sortedSetScores(`uid:${uid}:tids_read`, tids), db.sortedSetScores(`uid:${uid}:tids_read`, tids),
]); ]);
tids = topicScores.filter((t, i) => t.lastposttime && (!userScores[i] || userScores[i] < t.lastposttime)) const topics = topicScores.filter((t, i) => t.lastposttime && (!userScores[i] || userScores[i] < t.lastposttime));
.map(t => t.tid); tids = topics.map(t => t.tid);
if (!tids.length) { if (!tids.length) {
return false; return false;
} }
const now = Date.now(); const now = Date.now();
const scores = tids.map(() => now); const scores = topics.map(topic => (topic.scheduled ? topic.lastposttime : now));
const [topicData] = await Promise.all([ const [topicData] = await Promise.all([
Topics.getTopicsFields(tids, ['cid']), Topics.getTopicsFields(tids, ['cid']),
db.sortedSetAdd(`uid:${uid}:tids_read`, scores, tids), db.sortedSetAdd(`uid:${uid}:tids_read`, scores, tids),

@ -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']);
}
},
};

@ -53,9 +53,12 @@ module.exports = function (User) {
} }
User.onNewPostMade = async function (postData) { 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.addPostIdToUser(postData);
await User.incrementUserPostCountBy(postData.uid, 1); 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); await User.updateLastOnlineTime(postData.uid);
}; };

@ -764,6 +764,7 @@ describe('Categories', () => {
'topics:create': false, 'topics:create': false,
'topics:tag': false, 'topics:tag': false,
'topics:delete': false, 'topics:delete': false,
'topics:schedule': false,
'posts:edit': false, 'posts:edit': false,
'posts:history': false, 'posts:history': false,
'posts:upvote': false, 'posts:upvote': false,
@ -815,6 +816,7 @@ describe('Categories', () => {
'groups:topics:create': true, 'groups:topics:create': true,
'groups:topics:reply': true, 'groups:topics:reply': true,
'groups:topics:tag': true, 'groups:topics:tag': true,
'groups:topics:schedule': false,
'groups:posts:delete': true, 'groups:posts:delete': true,
'groups:read': true, 'groups:read': true,
'groups:topics:read': true, 'groups:topics:read': true,

@ -29,6 +29,7 @@ describe('Topic\'s', () => {
let categoryObj; let categoryObj;
let adminUid; let adminUid;
let adminJar; let adminJar;
let csrf_token;
let fooUid; let fooUid;
before(async () => { before(async () => {
@ -36,6 +37,7 @@ describe('Topic\'s', () => {
fooUid = await User.create({ username: 'foo' }); fooUid = await User.create({ username: 'foo' });
await groups.join('administrators', adminUid); await groups.join('administrators', adminUid);
adminJar = await helpers.loginUser('admin', '123456'); 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({ categoryObj = await categories.create({
name: 'Test Category', 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);
});
});
}); });

Loading…
Cancel
Save