You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

397 lines
13 KiB
JavaScript

'use strict';
const _ = require('lodash');
const validator = require('validator');
const nconf = require('nconf');
const winston = require('winston');
const db = require('../database');
const user = require('../user');
const posts = require('../posts');
const meta = require('../meta');
const plugins = require('../plugins');
const utils = require('../../public/src/utils');
const backlinkRegex = new RegExp(`(?:${nconf.get('url').replace('/', '\\/')}|\b|\\s)\\/topic\\/(\\d+)(?:\\/\\w+)?`, 'g');
module.exports = function (Topics) {
Topics.onNewPostMade = async function (postData) {
await Topics.updateLastPostTime(postData.tid, postData.timestamp);
await Topics.addPostToTopic(postData.tid, postData);
};
Topics.getTopicPosts = async function (topicOrTid, set, start, stop, uid, reverse) {
if (topicOrTid && typeof topicOrTid !== 'object') {
// TODO: remove in 1.19.0
winston.warn('[deprecated] Topics.getTopicPosts(tid, ...) usage is deprecated, pass a topic object as first argument!');
topicOrTid = await Topics.getTopicData(topicOrTid);
}
let repliesStart = start;
let repliesStop = stop;
if (stop > 0) {
repliesStop -= 1;
if (start > 0) {
repliesStart -= 1;
}
}
const pids = await posts.getPidsFromSet(set, repliesStart, repliesStop, reverse);
if (!pids.length && !topicOrTid.mainPid) {
return [];
}
if (topicOrTid.mainPid && start === 0) {
pids.unshift(topicOrTid.mainPid);
}
const postData = await posts.getPostsByPids(pids, uid);
if (!postData.length) {
return [];
}
let replies = postData;
if (topicOrTid.mainPid && start === 0) {
postData[0].index = 0;
replies = postData.slice(1);
}
Topics.calculatePostIndices(replies, repliesStart);
await addEventStartEnd(postData, set, reverse, topicOrTid);
const result = await plugins.hooks.fire('filter:topic.getPosts', {
topic: topicOrTid,
uid: uid,
posts: await Topics.addPostData(postData, uid),
});
return result.posts;
};
async function addEventStartEnd(postData, set, reverse, topicData) {
if (!postData.length) {
return;
}
postData.forEach((p, index) => {
if (p && p.index === 0 && reverse) {
p.eventStart = topicData.lastposttime;
p.eventEnd = Date.now();
} else if (p && postData[index + 1]) {
p.eventStart = reverse ? postData[index + 1].timestamp : p.timestamp;
p.eventEnd = reverse ? p.timestamp : postData[index + 1].timestamp;
}
});
const lastPost = postData[postData.length - 1];
if (lastPost) {
lastPost.eventStart = reverse ? topicData.timestamp : lastPost.timestamp;
lastPost.eventEnd = reverse ? lastPost.timestamp : Date.now();
if (lastPost.index) {
const nextPost = await db[reverse ? 'getSortedSetRevRangeWithScores' : 'getSortedSetRangeWithScores'](set, lastPost.index, lastPost.index);
if (reverse) {
lastPost.eventStart = nextPost.length ? nextPost[0].score : lastPost.eventStart;
} else {
lastPost.eventEnd = nextPost.length ? nextPost[0].score : lastPost.eventEnd;
}
}
}
}
Topics.addPostData = async function (postData, uid) {
if (!Array.isArray(postData) || !postData.length) {
return [];
}
const pids = postData.map(post => post && post.pid);
async function getPostUserData(field, method) {
const uids = _.uniq(postData.filter(p => p && parseInt(p[field], 10) >= 0).map(p => p[field]));
const userData = await method(uids);
return _.zipObject(uids, userData);
}
const [
bookmarks,
voteData,
userData,
editors,
replies,
] = await Promise.all([
posts.hasBookmarked(pids, uid),
posts.getVoteStatusByPostIDs(pids, uid),
getPostUserData('uid', async uids => await posts.getUserInfoForPosts(uids, uid)),
getPostUserData('editor', async uids => await user.getUsersFields(uids, ['uid', 'username', 'userslug'])),
getPostReplies(pids, uid),
Topics.addParentPosts(postData),
]);
postData.forEach((postObj, i) => {
if (postObj) {
postObj.user = postObj.uid ? userData[postObj.uid] : { ...userData[postObj.uid] };
postObj.editor = postObj.editor ? editors[postObj.editor] : null;
postObj.bookmarked = bookmarks[i];
postObj.upvoted = voteData.upvotes[i];
postObj.downvoted = voteData.downvotes[i];
postObj.votes = postObj.votes || 0;
postObj.replies = replies[i];
postObj.selfPost = parseInt(uid, 10) > 0 && parseInt(uid, 10) === postObj.uid;
// Username override for guests, if enabled
if (meta.config.allowGuestHandles && postObj.uid === 0 && postObj.handle) {
postObj.user.username = validator.escape(String(postObj.handle));
postObj.user.displayname = postObj.user.username;
}
}
});
const result = await plugins.hooks.fire('filter:topics.addPostData', {
posts: postData,
uid: uid,
});
return result.posts;
};
Topics.modifyPostsByPrivilege = function (topicData, topicPrivileges) {
const loggedIn = parseInt(topicPrivileges.uid, 10) > 0;
topicData.posts.forEach((post) => {
if (post) {
post.topicOwnerPost = parseInt(topicData.uid, 10) === parseInt(post.uid, 10);
post.display_edit_tools = topicPrivileges.isAdminOrMod || (post.selfPost && topicPrivileges['posts:edit']);
post.display_delete_tools = topicPrivileges.isAdminOrMod || (post.selfPost && topicPrivileges['posts:delete']);
post.display_moderator_tools = post.display_edit_tools || post.display_delete_tools;
post.display_move_tools = topicPrivileges.isAdminOrMod && post.index !== 0;
post.display_post_menu = topicPrivileges.isAdminOrMod ||
(post.selfPost && !topicData.locked && !post.deleted) ||
(post.selfPost && post.deleted && parseInt(post.deleterUid, 10) === parseInt(topicPrivileges.uid, 10)) ||
((loggedIn || topicData.postSharing.length) && !post.deleted);
post.ip = topicPrivileges.isAdminOrMod ? post.ip : undefined;
posts.modifyPostByPrivilege(post, topicPrivileges);
}
});
};
Topics.addParentPosts = async function (postData) {
let parentPids = postData.map(postObj => (postObj && postObj.hasOwnProperty('toPid') ? parseInt(postObj.toPid, 10) : null)).filter(Boolean);
if (!parentPids.length) {
return;
}
parentPids = _.uniq(parentPids);
const parentPosts = await posts.getPostsFields(parentPids, ['uid']);
const parentUids = _.uniq(parentPosts.map(postObj => postObj && postObj.uid));
const userData = await user.getUsersFields(parentUids, ['username']);
const usersMap = {};
userData.forEach((user) => {
usersMap[user.uid] = user.username;
});
const parents = {};
parentPosts.forEach((post, i) => {
parents[parentPids[i]] = { username: usersMap[post.uid] };
});
postData.forEach((post) => {
post.parent = parents[post.toPid];
});
};
Topics.calculatePostIndices = function (posts, start) {
posts.forEach((post, index) => {
if (post) {
post.index = start + index + 1;
}
});
};
Topics.getLatestUndeletedPid = async function (tid) {
const pid = await Topics.getLatestUndeletedReply(tid);
if (pid) {
return pid;
}
const mainPid = await Topics.getTopicField(tid, 'mainPid');
const mainPost = await posts.getPostFields(mainPid, ['pid', 'deleted']);
return mainPost.pid && !mainPost.deleted ? mainPost.pid : null;
};
Topics.getLatestUndeletedReply = async function (tid) {
let isDeleted = false;
let index = 0;
do {
/* eslint-disable no-await-in-loop */
const pids = await db.getSortedSetRevRange(`tid:${tid}:posts`, index, index);
if (!pids.length) {
return null;
}
isDeleted = await posts.getPostField(pids[0], 'deleted');
if (!isDeleted) {
return parseInt(pids[0], 10);
}
index += 1;
} while (isDeleted);
};
Topics.addPostToTopic = async function (tid, postData) {
const mainPid = await Topics.getTopicField(tid, 'mainPid');
if (!parseInt(mainPid, 10)) {
await Topics.setTopicField(tid, 'mainPid', postData.pid);
} else {
const upvotes = parseInt(postData.upvotes, 10) || 0;
const downvotes = parseInt(postData.downvotes, 10) || 0;
const votes = upvotes - downvotes;
await db.sortedSetsAdd([
`tid:${tid}:posts`, `tid:${tid}:posts:votes`,
], [postData.timestamp, votes], postData.pid);
}
await Topics.increasePostCount(tid);
await db.sortedSetIncrBy(`tid:${tid}:posters`, 1, postData.uid);
const posterCount = await db.sortedSetCard(`tid:${tid}:posters`);
await Topics.setTopicField(tid, 'postercount', posterCount);
await Topics.updateTeaser(tid);
};
Topics.removePostFromTopic = async function (tid, postData) {
await db.sortedSetsRemove([
`tid:${tid}:posts`,
`tid:${tid}:posts:votes`,
], postData.pid);
await Topics.decreasePostCount(tid);
await db.sortedSetIncrBy(`tid:${tid}:posters`, -1, postData.uid);
await db.sortedSetsRemoveRangeByScore([`tid:${tid}:posters`], '-inf', 0);
const posterCount = await db.sortedSetCard(`tid:${tid}:posters`);
await Topics.setTopicField(tid, 'postercount', posterCount);
await Topics.updateTeaser(tid);
};
Topics.getPids = async function (tid) {
let [mainPid, pids] = await Promise.all([
Topics.getTopicField(tid, 'mainPid'),
db.getSortedSetRange(`tid:${tid}:posts`, 0, -1),
]);
if (parseInt(mainPid, 10)) {
pids = [mainPid].concat(pids);
}
return pids;
};
Topics.increasePostCount = async function (tid) {
incrementFieldAndUpdateSortedSet(tid, 'postcount', 1, 'topics:posts');
};
Topics.decreasePostCount = async function (tid) {
incrementFieldAndUpdateSortedSet(tid, 'postcount', -1, 'topics:posts');
};
Topics.increaseViewCount = async function (tid) {
const cid = await Topics.getTopicField(tid, 'cid');
incrementFieldAndUpdateSortedSet(tid, 'viewcount', 1, ['topics:views', `cid:${cid}:tids:views`]);
};
async function incrementFieldAndUpdateSortedSet(tid, field, by, set) {
const value = await db.incrObjectFieldBy(`topic:${tid}`, field, by);
await db[Array.isArray(set) ? 'sortedSetsAdd' : 'sortedSetAdd'](set, value, tid);
}
Topics.getTitleByPid = async function (pid) {
return await Topics.getTopicFieldByPid('title', pid);
};
Topics.getTopicFieldByPid = async function (field, pid) {
const tid = await posts.getPostField(pid, 'tid');
return await Topics.getTopicField(tid, field);
};
Topics.getTopicDataByPid = async function (pid) {
const tid = await posts.getPostField(pid, 'tid');
return await Topics.getTopicData(tid);
};
Topics.getPostCount = async function (tid) {
return await db.getObjectField(`topic:${tid}`, 'postcount');
};
async function getPostReplies(pids, callerUid) {
const keys = pids.map(pid => `pid:${pid}:replies`);
const arrayOfReplyPids = await db.getSortedSetsMembers(keys);
const uniquePids = _.uniq(_.flatten(arrayOfReplyPids));
let replyData = await posts.getPostsFields(uniquePids, ['pid', 'uid', 'timestamp']);
const result = await plugins.hooks.fire('filter:topics.getPostReplies', {
uid: callerUid,
replies: replyData,
});
replyData = await user.blocks.filter(callerUid, result.replies);
const uids = replyData.map(replyData => replyData && replyData.uid);
const uniqueUids = _.uniq(uids);
const userData = await user.getUsersWithFields(uniqueUids, ['uid', 'username', 'userslug', 'picture'], callerUid);
const uidMap = _.zipObject(uniqueUids, userData);
const pidMap = _.zipObject(replyData.map(r => r.pid), replyData);
const returnData = arrayOfReplyPids.map((replyPids) => {
replyPids = replyPids.filter(pid => pidMap[pid]);
const uidsUsed = {};
const currentData = {
hasMore: false,
users: [],
text: replyPids.length > 1 ? `[[topic:replies_to_this_post, ${replyPids.length}]]` : '[[topic:one_reply_to_this_post]]',
count: replyPids.length,
timestampISO: replyPids.length ? utils.toISOString(pidMap[replyPids[0]].timestamp) : undefined,
};
replyPids.sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
replyPids.forEach((replyPid) => {
const replyData = pidMap[replyPid];
if (!uidsUsed[replyData.uid] && currentData.users.length < 6) {
currentData.users.push(uidMap[replyData.uid]);
uidsUsed[replyData.uid] = true;
}
});
if (currentData.users.length > 5) {
currentData.users.pop();
currentData.hasMore = true;
}
return currentData;
});
return returnData;
}
Topics.syncBacklinks = async (postData) => {
if (!postData) {
throw new Error('[[error:invalid-data]]');
}
// Scan post content for topic links
const matches = [...postData.content.matchAll(backlinkRegex)];
if (!matches) {
return 0;
}
const { pid, uid, tid } = postData;
let add = matches.map(match => match[1]);
const now = Date.now();
const topicsExist = await Topics.exists(add);
const current = (await db.getSortedSetMembers(`pid:${pid}:backlinks`)).map(tid => parseInt(tid, 10));
const remove = current.filter(tid => !add.includes(tid));
add = add.filter((_tid, idx) => topicsExist[idx] && !current.includes(_tid) && tid !== parseInt(_tid, 10));
// Remove old backlinks
await db.sortedSetRemove(`pid:${pid}:backlinks`, remove);
// Add new backlinks
await db.sortedSetAdd(`pid:${pid}:backlinks`, add.map(Number.bind(null, now)), add);
await Promise.all(add.map(async (tid) => {
await Topics.events.log(tid, {
uid,
type: 'backlink',
href: `/post/${pid}`,
});
}));
return add.length + (current - remove);
};
};