From ccd187e0002873a4fb6e30359126b12f8341161d Mon Sep 17 00:00:00 2001 From: Julian Lam Date: Thu, 9 May 2024 15:48:58 -0400 Subject: [PATCH] feat: Note deletion logic and refactoring, #12551 --- src/activitypub/inbox.js | 45 ++++++++++++++ src/activitypub/notes.js | 23 +++++++- src/api/activitypub.js | 34 ++++++++++- src/api/posts.js | 17 ++++-- src/middleware/activitypub.js | 4 +- src/posts/attachments.js | 13 ++++- src/posts/delete.js | 3 + test/activitypub.js | 4 +- test/activitypub/notes.js | 107 ++++++++++++++++++++++++++++++++++ 9 files changed, 238 insertions(+), 12 deletions(-) create mode 100644 test/activitypub/notes.js diff --git a/src/activitypub/inbox.js b/src/activitypub/inbox.js index 50ceebc78d..6978e9541b 100644 --- a/src/activitypub/inbox.js +++ b/src/activitypub/inbox.js @@ -114,6 +114,51 @@ inbox.update = async (req) => { } }; +inbox.delete = async (req) => { + const { actor, object } = req.body; + + // Deletes don't have their objects resolved automatically + let method = 'purge'; + try { + const { type } = await activitypub.get('uid', 0, object); + if (type === 'Tombstone') { + method = 'delete'; + } + } catch (e) { + // probably 410/404 + } + + // Origin checking + const actorHostname = new URL(actor).hostname; + const objectHostname = new URL(object).hostname; + if (actorHostname !== objectHostname) { + throw new Error('[[error:activitypub.origin-mismatch]]'); + } + + const [isNote/* , isActor */] = await Promise.all([ + posts.exists(object), + // db.isSortedSetMember('usersRemote:lastCrawled', object.id), + ]); + + switch (true) { + case isNote: { + const uid = await posts.getPostField(object, 'uid'); + await api.posts[method]({ uid }, { pid: object }); + break; + } + + // case isActor: { + // console.log('actor'); + // break; + // } + + default: { + winston.verbose(`[activitypub/inbox.delete] Object (${object}) does not exist locally. Doing nothing.`); + break; + } + } +}; + inbox.like = async (req) => { const { actor, object } = req.body; const { type, id } = await activitypub.helpers.resolveLocalId(object.id); diff --git a/src/activitypub/notes.js b/src/activitypub/notes.js index 5a70c9cf3a..8a405a05ee 100644 --- a/src/activitypub/notes.js +++ b/src/activitypub/notes.js @@ -299,7 +299,10 @@ Notes.syncUserInboxes = async function (tid, uid) { const score = await db.sortedSetScore(`cid:${cid}:tids`, tid); winston.verbose(`[activitypub/syncUserInboxes] Syncing tid ${tid} with ${uids.size} inboxes`); - await db.sortedSetsAdd(keys, keys.map(() => score || Date.now()), tid); + await Promise.all([ + db.sortedSetsAdd(keys, keys.map(() => score || Date.now()), tid), + db.setAdd(`tid:${tid}:recipients`, Array.from(uids)), + ]); }; Notes.getCategoryFollowers = async (cid) => { @@ -355,3 +358,21 @@ Notes.announce.remove = async (pid, actor) => { Notes.announce.removeAll = async (pid) => { await db.delete(`pid:${pid}:announces`); }; + +Notes.delete = async (pids) => { + if (!Array.isArray(pids)) { + pids = [pids]; + } + + // Valid and remote content only + pids = pids.filter(pid => !utils.isNumber(pid)); + const exists = await posts.exists(pids); + pids = pids.filter((_, idx) => exists[idx]); + + let tids = await posts.getPostsFields(pids, ['tid']); + tids = new Set(tids.map(obj => obj.tid)); + + const recipientSets = pids.map(id => `post:${id}:recipients`); + await db.deleteAll(recipientSets); + await Promise.all(Array.from(tids).map(async tid => Notes.syncUserInboxes(tid))); +}; diff --git a/src/api/activitypub.js b/src/api/activitypub.js index aba3b2f3c4..bb6221ab0c 100644 --- a/src/api/activitypub.js +++ b/src/api/activitypub.js @@ -103,7 +103,7 @@ async function buildRecipients(object, { pid, uid }) { // Directly address user if inReplyTo const parentId = await posts.getPostField(object.inReplyTo, 'uid'); - if (activitypub.helpers.isUri(parentId) && to.has(parentId)) { + if (activitypub.helpers.isUri(parentId)) { to.add(parentId); } @@ -209,6 +209,38 @@ activitypubApi.update.note = enabledCheck(async (caller, { post }) => { await activitypub.send('uid', caller.uid, Array.from(targets), payload); }); +activitypubApi.delete = {}; + +activitypubApi.delete.note = enabledCheck(async (caller, { pid }) => { + // Only applies to local posts + if (!utils.isNumber(pid)) { + return; + } + + const id = `${nconf.get('url')}/post/${pid}`; + const object = { id }; + const { tid, uid } = await posts.getPostFields(pid, ['tid', 'uid']); + const origin = `${nconf.get('url')}/topic/${tid}`; + const { targets } = await buildRecipients(object, { pid, uid }); + + const allowed = await privileges.posts.can('topics:read', pid, activitypub._constants.uid); + if (!allowed) { + winston.verbose(`[activitypub/api] Not federating update of pid ${pid} to the fediverse due to privileges.`); + return; + } + + const payload = { + id: `${id}#activity/delete/${Date.now()}`, + type: 'Delete', + to: [], + cc: [], + object: id, + origin, + }; + + await activitypub.send('uid', caller.uid, Array.from(targets), payload); +}); + activitypubApi.like = {}; activitypubApi.like.note = enabledCheck(async (caller, { pid }) => { diff --git a/src/api/posts.js b/src/api/posts.js index bd165d3c7d..ae2cbecbac 100644 --- a/src/api/posts.js +++ b/src/api/posts.js @@ -220,12 +220,18 @@ postsAPI.purge = async function (caller, data) { throw new Error('[[error:invalid-data]]'); } - const results = await isMainAndLastPost(data.pid); - if (results.isMain && !results.isLast) { + const [exists, { isMain, isLast }] = await Promise.all([ + posts.exists(data.pid), + isMainAndLastPost(data.pid), + ]); + if (!exists) { + throw new Error('[[error:no-post]]'); + } + if (isMain && !isLast) { throw new Error('[[error:cant-purge-main-post]]'); } - const isMainAndLast = results.isMain && results.isLast; + const isMainAndLast = isMain && isLast; const postData = await posts.getPostFields(data.pid, ['toPid', 'tid']); postData.pid = data.pid; @@ -234,7 +240,10 @@ postsAPI.purge = async function (caller, data) { throw new Error('[[error:no-privileges]]'); } require('../posts/cache').del(data.pid); - await posts.purge(data.pid, caller.uid); + await Promise.all([ + posts.purge(data.pid, caller.uid), + require('.').activitypub.delete.note(caller, { pid: data.pid }), + ]); websockets.in(`topic_${postData.tid}`).emit('event:post_purged', postData); const topicData = await topics.getTopicFields(postData.tid, ['title', 'cid']); diff --git a/src/middleware/activitypub.js b/src/middleware/activitypub.js index 247726f38e..d2bda3a57a 100644 --- a/src/middleware/activitypub.js +++ b/src/middleware/activitypub.js @@ -90,8 +90,8 @@ middleware.validate = async function (req, res, next) { }; middleware.resolveObjects = async function (req, res, next) { - const { object } = req.body; - if (typeof object === 'string' || (Array.isArray(object) && object.every(o => typeof o === 'string'))) { + const { type, object } = req.body; + if (type !== 'Delete' && (typeof object === 'string' || (Array.isArray(object) && object.every(o => typeof o === 'string')))) { winston.verbose('[middleware/activitypub] Resolving object(s)...'); try { req.body.object = await activitypub.helpers.resolveObjects(object); diff --git a/src/posts/attachments.js b/src/posts/attachments.js index b94590109d..a0fa77a2d5 100644 --- a/src/posts/attachments.js +++ b/src/posts/attachments.js @@ -1,5 +1,6 @@ 'use strict'; +const winston = require('winston'); const crypto = require('crypto'); const db = require('../database'); @@ -50,5 +51,13 @@ Attachments.update = async (pid, attachments) => { ]); }; -// todo -// Attachments.remove = async (pid) => { ... } +Attachments.empty = async (pids) => { + winston.verbose(`[posts/attachments] Emptying attachments for ids ${pids.join(', ')}.`); + const zsets = pids.map(pid => `post:${pid}:attachments`); + const hashes = await db.getSortedSetsMembers(zsets); + const keys = hashes + .reduce((memo, hashes) => new Set([...memo, ...hashes]), new Set()) + .map(hash => `attachment:${hash}`); + + await db.deleteAll(keys.concat(zsets)); +}; diff --git a/src/posts/delete.js b/src/posts/delete.js index 94f73cf494..e4e4e59438 100644 --- a/src/posts/delete.js +++ b/src/posts/delete.js @@ -9,6 +9,7 @@ const user = require('../user'); const notifications = require('../notifications'); const plugins = require('../plugins'); const flags = require('../flags'); +const activitypub = require('../activitypub'); module.exports = function (Posts) { Posts.delete = async function (pid, uid) { @@ -81,6 +82,8 @@ module.exports = function (Posts) { deleteDiffs(pids), deleteFromUploads(pids), db.sortedSetsRemove(['posts:pid', 'posts:votes', 'posts:flagged'], pids), + Posts.attachments.empty(pids), + activitypub.notes.delete(pids), ]); await resolveFlags(postData, uid); diff --git a/test/activitypub.js b/test/activitypub.js index 57712b2341..34ec335e66 100644 --- a/test/activitypub.js +++ b/test/activitypub.js @@ -362,7 +362,7 @@ describe('ActivityPub integration', () => { }); describe('Serving of local assets to remote clients', () => { - describe.only('Note', () => { + describe('Note', () => { let cid; let uid; @@ -532,7 +532,7 @@ describe('ActivityPub integration', () => { }); }); - describe('ActivityPub', async () => { + describe.only('ActivityPub', async () => { let files; before(async () => { diff --git a/test/activitypub/notes.js b/test/activitypub/notes.js new file mode 100644 index 0000000000..90b95a9aa9 --- /dev/null +++ b/test/activitypub/notes.js @@ -0,0 +1,107 @@ +'use strict'; + +const assert = require('assert'); + +const db = require('../../src/database'); +const user = require('../../src/user'); +const categories = require('../../src/categories'); +const topics = require('../../src/topics'); +const activitypub = require('../../src/activitypub'); +const utils = require('../../src/utils'); + +describe('Notes', () => { + describe('Inbox Synchronization', () => { + let cid; + let uid; + let topicData; + + before(async () => { + ({ cid } = await categories.create({ name: utils.generateUUID().slice(0, 8) })); + }); + + beforeEach(async () => { + uid = await user.create({ username: utils.generateUUID().slice(0, 10) }); + ({ topicData } = await topics.post({ + cid, + uid, + title: utils.generateUUID(), + content: utils.generateUUID(), + })); + }); + + it('should add a topic to a user\'s inbox if user is a recipient in OP', async () => { + await db.setAdd(`post:${topicData.mainPid}:recipients`, [uid]); + await activitypub.notes.syncUserInboxes(topicData.tid); + const inboxed = await db.isSortedSetMember(`uid:${uid}:inbox`, topicData.tid); + + assert.strictEqual(inboxed, true); + }); + + it('should add a topic to a user\'s inbox if a user is a recipient in a reply', async () => { + const uid = await user.create({ username: utils.generateUUID().slice(0, 10) }); + const { pid } = await topics.reply({ + tid: topicData.tid, + uid, + content: utils.generateUUID(), + }); + await db.setAdd(`post:${pid}:recipients`, [uid]); + await activitypub.notes.syncUserInboxes(topicData.tid); + const inboxed = await db.isSortedSetMember(`uid:${uid}:inbox`, topicData.tid); + + assert.strictEqual(inboxed, true); + }); + + it('should maintain a list of recipients at the topic level', async () => { + await db.setAdd(`post:${topicData.mainPid}:recipients`, [uid]); + await activitypub.notes.syncUserInboxes(topicData.tid); + const [isRecipient, count] = await Promise.all([ + db.isSetMember(`tid:${topicData.tid}:recipients`, uid), + db.setCount(`tid:${topicData.tid}:recipients`), + ]); + + assert(isRecipient); + assert.strictEqual(count, 1); + }); + + it('should add topic to a user\'s inbox if it is explicitly passed in as an argument', async () => { + await activitypub.notes.syncUserInboxes(topicData.tid, uid); + const inboxed = await db.isSortedSetMember(`uid:${uid}:inbox`, topicData.tid); + + assert.strictEqual(inboxed, true); + }); + }); + + describe('Deletion', () => { + let cid; + let uid; + let topicData; + + before(async () => { + ({ cid } = await categories.create({ name: utils.generateUUID().slice(0, 8) })); + }); + + beforeEach(async () => { + uid = await user.create({ username: utils.generateUUID().slice(0, 10) }); + ({ topicData } = await topics.post({ + cid, + uid, + title: utils.generateUUID(), + content: utils.generateUUID(), + })); + }); + + it('should clean up recipient sets for the post', async () => { + const { pid } = await topics.reply({ + pid: `https://example.org/${utils.generateUUID().slice(0, 8)}`, + tid: topicData.tid, + uid, + content: utils.generateUUID(), + }); + await db.setAdd(`post:${pid}:recipients`, [uid]); + await activitypub.notes.delete([pid]); + + const inboxed = await db.isSetMember(`post:${pid}:recipients`, uid); + assert(!inboxed); + }); + }); +});