diff --git a/public/language/en-GB/admin/dashboard.json b/public/language/en-GB/admin/dashboard.json index 0be6d5866c..a35775fdce 100644 --- a/public/language/en-GB/admin/dashboard.json +++ b/public/language/en-GB/admin/dashboard.json @@ -6,6 +6,8 @@ "new-users": "New Users", "posts": "Posts", "topics": "Topics", + "remote-posts": "Remote Posts", + "remote-topics": "Remote Topics", "page-views-seven": "Last 7 Days", "page-views-thirty": "Last 30 Days", "page-views-last-day": "Last 24 hours", diff --git a/src/controllers/admin/dashboard.js b/src/controllers/admin/dashboard.js index df1f94a830..439b79af0d 100644 --- a/src/controllers/admin/dashboard.js +++ b/src/controllers/admin/dashboard.js @@ -130,13 +130,15 @@ async function getStats() { return cachedStats; } - let results = await Promise.all([ + let results = (await Promise.all([ getStatsFromAnalytics('uniquevisitors', ''), getStatsFromAnalytics('logins', 'loginCount'), getStatsForSet('users:joindate', 'userCount'), getStatsForSet('posts:pid', 'postCount'), getStatsForSet('topics:tid', 'topicCount'), - ]); + meta.config.activitypubEnabled ? getStatsForSet('postsRemote:pid', '') : null, + meta.config.activitypubEnabled ? getStatsForSet('topicsRemote:tid', '') : null, + ])).filter(Boolean); results[0].name = '[[admin/dashboard:unique-visitors]]'; @@ -151,6 +153,13 @@ async function getStats() { results[4].name = '[[admin/dashboard:topics]]'; results[4].href = `${nconf.get('relative_path')}/admin/dashboard/topics`; + if (results[5]) { + results[5].name = '[[admin/dashboard:remote-posts]]'; + } + if (results[6]) { + results[6].name = '[[admin/dashboard:remote-topics]]'; + } + ({ results } = await plugins.hooks.fire('filter:admin.getStats', { results, helpers: { getStatsForSet, getStatsFromAnalytics }, diff --git a/src/posts/create.js b/src/posts/create.js index 1cce927c11..72efd0c64c 100644 --- a/src/posts/create.js +++ b/src/posts/create.js @@ -59,10 +59,10 @@ module.exports = function (Posts) { const topicData = await topics.getTopicFields(tid, ['cid', 'pinned']); postData.cid = topicData.cid; - + const isRemote = !utils.isNumber(pid); await Promise.all([ - db.sortedSetAdd('posts:pid', timestamp, postData.pid), - utils.isNumber(pid) ? db.incrObjectField('global', 'postCount') : null, + db.sortedSetAdd(!isRemote ? 'posts:pid' : 'postsRemote:pid', timestamp, postData.pid), + !isRemote ? db.incrObjectField('global', 'postCount') : null, user.onNewPostMade(postData), topics.onNewPostMade(postData), categories.onNewPostMade(topicData.cid, topicData.pinned, postData), diff --git a/src/posts/delete.js b/src/posts/delete.js index aa76bc5f5f..4fde39d59a 100644 --- a/src/posts/delete.js +++ b/src/posts/delete.js @@ -79,7 +79,9 @@ module.exports = function (Posts) { deleteFromGroups(pids), deleteDiffs(pids), deleteFromUploads(pids), - db.sortedSetsRemove(['posts:pid', 'posts:votes', 'posts:flagged'], pids), + db.sortedSetsRemove([ + 'posts:pid', 'posts:votes', 'posts:flagged', 'postsRemote:pid', + ], pids), Posts.attachments.empty(pids), activitypub.notes.delete(pids), db.deleteAll(pids.map(pid => `pid:${pid}:editors`)), diff --git a/src/posts/votes.js b/src/posts/votes.js index 64724778c1..d38f35d0ea 100644 --- a/src/posts/votes.js +++ b/src/posts/votes.js @@ -260,7 +260,9 @@ module.exports = function (Posts) { } await Promise.all([ updateTopicVoteCount(postData), - db.sortedSetAdd('posts:votes', postData.votes, postData.pid), + utils.isNumber(postData.pid) ? + db.sortedSetAdd('posts:votes', postData.votes, postData.pid) : + null, Posts.setPostFields(postData.pid, { upvotes: postData.upvotes, downvotes: postData.downvotes, diff --git a/src/topics/delete.js b/src/topics/delete.js index caf12ce1eb..f86ef5a2b4 100644 --- a/src/topics/delete.js +++ b/src/topics/delete.js @@ -138,15 +138,15 @@ module.exports = function (Topics) { async function reduceCounters(tid) { const incr = -1; + const { cid, postcount } = await Topics.getTopicFields(tid, ['cid', 'postcount']); + const postCountChange = incr * postcount; + const categoryKey = `${utils.isNumber(cid) ? 'category' : 'categoryRemote'}:${cid}`; + const bulkIncr = [ + [categoryKey, { post_count: postCountChange, topic_count: incr }], + ]; if (utils.isNumber(tid)) { - await db.incrObjectFieldBy('global', 'topicCount', incr); + bulkIncr.push(['global', { postCount: postCountChange, topicCount: incr }]); } - const topicData = await Topics.getTopicFields(tid, ['cid', 'postcount']); - const postCountChange = incr * topicData.postcount; - await Promise.all([ - db.incrObjectFieldBy('global', 'postCount', postCountChange), - db.incrObjectFieldBy(`${utils.isNumber(topicData.cid) ? 'category' : 'categoryRemote'}:${topicData.cid}`, 'post_count', postCountChange), - db.incrObjectFieldBy(`${utils.isNumber(topicData.cid) ? 'category' : 'categoryRemote'}:${topicData.cid}`, 'topic_count', incr), - ]); + await db.incrObjectFieldByBulk(bulkIncr); } }; diff --git a/src/upgrades/4.9.0/postsRemote_zset.js b/src/upgrades/4.9.0/postsRemote_zset.js new file mode 100644 index 0000000000..54cf8edf03 --- /dev/null +++ b/src/upgrades/4.9.0/postsRemote_zset.js @@ -0,0 +1,37 @@ +'use strict'; + +const db = require('../../database'); +const batch = require('../../batch'); +const utils = require('../../utils'); + +module.exports = { + name: 'Move ap pids from posts:pid to postsRemote:pid', + timestamp: Date.UTC(2026, 1, 24), + method: async function () { + const { progress } = this; + progress.total = await db.sortedSetCard('posts:pid'); + const removePosts = []; + await batch.processSortedSet('posts:pid', async (postData) => { + const apPosts = postData.filter(post => !utils.isNumber(post.value)); + removePosts.push(...apPosts.map(post => post.value)); + await db.sortedSetAdd( + 'postsRemote:pid', + apPosts.map(p => p.score), + apPosts.map(p => p.value) + ); + progress.incr(postData.length); + }, { + batch: 500, + withScores: true, + }); + + await batch.processArray(removePosts, async (pids) => { + await db.sortedSetsRemove(['posts:pid', 'posts:votes'], pids); + }, { + batch: 500, + }); + + const postCount = await db.sortedSetCard('posts:pid'); + await db.setObjectField('global', 'postCount', postCount); + }, +}; diff --git a/src/upgrades/4.9.0/topicsRemote_zset.js b/src/upgrades/4.9.0/topicsRemote_zset.js index a74457c0e1..5e0bd59cae 100644 --- a/src/upgrades/4.9.0/topicsRemote_zset.js +++ b/src/upgrades/4.9.0/topicsRemote_zset.js @@ -30,5 +30,7 @@ module.exports = { }, { batch: 500, }); + const topicCount = await db.sortedSetCard('topics:tid'); + await db.setObjectField('global', 'topicCount', topicCount); }, }; diff --git a/test/topics.js b/test/topics.js index f2f1b985f7..6527c39cbc 100644 --- a/test/topics.js +++ b/test/topics.js @@ -782,6 +782,45 @@ describe('Topic\'s', () => { assert.strictEqual(false, isMember); }); + it('should update global & category topic/post counters when topic is purged', async () => { + const category = await categories.create({ + name: 'Category for purge count test', + }); + const { topicCount, postCount } = await db.getObject('global'); + + const cid = category.cid; + const topic1 = await topics.post({ + uid: adminUid, + title: 'topic for purge count test', + content: 'topic content', + cid, + }); + await topics.post({ + uid: adminUid, + title: 'topic for purge count test', + content: 'topic content', + cid, + }); + const tid1 = topic1.topicData.tid; + await topics.reply({ uid: adminUid, content: 'reply 1', tid: tid1 }); + await topics.reply({ uid: adminUid, content: 'reply 2', tid: tid1 }); + await topics.reply({ uid: adminUid, content: 'reply 3', tid: tid1 }); + let categoryData = await categories.getCategoriesFields([cid], ['topic_count', 'post_count']); + assert.strictEqual(categoryData[0].topic_count, 2); + assert.strictEqual(categoryData[0].post_count, 5); + + await apiTopics.purge({ uid: adminUid }, { tids: [tid1], cid: categoryObj.cid }); + + categoryData = await categories.getCategoriesFields([cid], ['topic_count', 'post_count']); + assert.strictEqual(categoryData[0].topic_count, 1); + assert.strictEqual(categoryData[0].post_count, 1); + + const afterPurge = await db.getObject('global'); + assert.strictEqual(parseInt(afterPurge.topicCount, 10), parseInt(topicCount, 10) + 1); + assert.strictEqual(parseInt(afterPurge.postCount, 10), parseInt(postCount, 10) + 1); + assert(false); + }); + it('should not allow user to restore their topic if it was deleted by an admin', async () => { const result = await topics.post({ uid: fooUid,