From 8e60047e12ca3cddae623eb8f83ccd9164aba6dd Mon Sep 17 00:00:00 2001 From: Julian Lam Date: Wed, 23 Apr 2025 12:47:16 -0400 Subject: [PATCH] feat: #13255, proper handling of upvotes shared by group actors fixes #13320 --- src/activitypub/inbox.js | 106 +++++++++++++++------------ test/activitypub/helpers.js | 61 +++++++++++++--- test/activitypub/notes.js | 140 +++++++++++++++++++++++++++++++++++- 3 files changed, 252 insertions(+), 55 deletions(-) diff --git a/src/activitypub/inbox.js b/src/activitypub/inbox.js index cfd262985b..cdbb6f5131 100644 --- a/src/activitypub/inbox.js +++ b/src/activitypub/inbox.js @@ -240,11 +240,11 @@ inbox.like = async (req) => { const allowed = await privileges.posts.can('posts:upvote', id, activitypub._constants.uid); if (!allowed) { - winston.verbose(`[activitypub/inbox.like] ${id} not allowed to be upvoted.`); + activitypub.helpers.log(`[activitypub/inbox.like] ${id} not allowed to be upvoted.`); return reject('Like', object, actor); } - winston.verbose(`[activitypub/inbox/like] id ${id} via ${actor}`); + activitypub.helpers.log(`[activitypub/inbox/like] id ${id} via ${actor}`); const result = await posts.upvote(id, actor); activitypub.feps.announce(object.id, req.body); @@ -253,6 +253,7 @@ inbox.like = async (req) => { inbox.announce = async (req) => { const { actor, object, published, to, cc } = req.body; + activitypub.helpers.log(`[activitypub/inbox/announce] Parsing Announce(${object.type}) from ${actor}`); let timestamp = new Date(published); timestamp = timestamp.toString() !== 'Invalid Date' ? timestamp.getTime() : Date.now(); @@ -270,53 +271,70 @@ inbox.announce = async (req) => { cid = Array.from(cids)[0]; } - if (String(object.id).startsWith(nconf.get('url'))) { // Local object - const { type, id } = await activitypub.helpers.resolveLocalId(object.id); - if (type !== 'post' || !(await posts.exists(id))) { - throw new Error('[[error:activitypub.invalid-id]]'); + switch(true) { + case object.type === 'Like': { + const id = object.object.id || object.object; + const { id: localId } = await activitypub.helpers.resolveLocalId(id); + const exists = await posts.exists(localId || id); + if (exists) { + const result = await posts.upvote(localId || id, object.actor); + if (localId) { + socketHelpers.upvote(result, 'notifications:upvoted-your-post-in'); + } + } + + break; } - pid = id; - tid = await posts.getPostField(id, 'tid'); + case activitypub._constants.acceptedPostTypes.includes(object.type): { + if (String(object.id).startsWith(nconf.get('url'))) { // Local object + const { type, id } = await activitypub.helpers.resolveLocalId(object.id); + if (type !== 'post' || !(await posts.exists(id))) { + reject('Announce', object, actor); + return; + } - socketHelpers.sendNotificationToPostOwner(pid, actor, 'announce', 'notifications:activitypub.announce'); - } else { // Remote object - // Follower check - if (!cid) { - const { followers } = await activitypub.actors.getLocalFollowCounts(actor); - if (!followers) { - winston.verbose(`[activitypub/inbox.announce] Rejecting ${object.id} via ${actor} due to no followers`); - reject('Announce', object, actor); - return; + pid = id; + tid = await posts.getPostField(id, 'tid'); + + socketHelpers.sendNotificationToPostOwner(pid, actor, 'announce', 'notifications:activitypub.announce'); + } else { // Remote object + // Follower check + if (!cid) { + const { followers } = await activitypub.actors.getLocalFollowCounts(actor); + if (!followers) { + winston.verbose(`[activitypub/inbox.announce] Rejecting ${object.id} via ${actor} due to no followers`); + reject('Announce', object, actor); + return; + } + } + + // Handle case where Announce(Create(Note-ish)) is received + if (object.type === 'Create' && activitypub._constants.acceptedPostTypes.includes(object.object.type)) { + pid = object.object.id; + } else { + pid = object.id; + } + + pid = await activitypub.resolveId(0, pid); // in case wrong id is passed-in; unlikely, but still. + if (!pid) { + return; + } + + const assertion = await activitypub.notes.assert(0, pid, { cid, skipChecks: true }); + if (!assertion) { + return; + } + + ({ tid } = assertion); + await activitypub.notes.updateLocalRecipients(pid, { to, cc }); + await activitypub.notes.syncUserInboxes(tid); + } + + if (!cid) { // Topic events from actors followed by users only + await activitypub.notes.announce.add(pid, actor, timestamp); } } - - // Handle case where Announce(Create(Note-ish)) is received - if (object.type === 'Create' && activitypub._constants.acceptedPostTypes.includes(object.object.type)) { - pid = object.object.id; - } else { - pid = object.id; - } - - pid = await activitypub.resolveId(0, pid); // in case wrong id is passed-in; unlikely, but still. - if (!pid) { - return; - } - - const assertion = await activitypub.notes.assert(0, pid, { cid }); - if (!assertion) { - return; - } - - ({ tid } = assertion); - await activitypub.notes.updateLocalRecipients(pid, { to, cc }); - await activitypub.notes.syncUserInboxes(tid); - } - - winston.verbose(`[activitypub/inbox/announce] Parsing id ${pid}`); - - if (!cid) { // Topic events from actors followed by users only - await activitypub.notes.announce.add(pid, actor, timestamp); } }; diff --git a/test/activitypub/helpers.js b/test/activitypub/helpers.js index f5929ecb46..4d168d01c7 100644 --- a/test/activitypub/helpers.js +++ b/test/activitypub/helpers.js @@ -8,10 +8,11 @@ const Helpers = module.exports; Helpers.mocks = {}; +Helpers.mocks._baseUrl = 'https://example.org'; + Helpers.mocks.person = (override = {}) => { - const baseUrl = 'https://example.org'; const uuid = utils.generateUUID(); - let id = `${baseUrl}/${uuid}`; + let id = `${Helpers.mocks._baseUrl}/${uuid}`; if (override.hasOwnProperty('id')) { id = override.id; } @@ -66,9 +67,8 @@ Helpers.mocks.group = (override = {}) => { }; Helpers.mocks.note = (override = {}) => { - const baseUrl = 'https://example.org'; const uuid = utils.generateUUID(); - const id = `${baseUrl}/object/${uuid}`; + const id = `${Helpers.mocks._baseUrl}/object/${uuid}`; const note = { '@context': 'https://www.w3.org/ns/activitystreams', id, @@ -97,9 +97,8 @@ Helpers.mocks.note = (override = {}) => { Helpers.mocks.create = (object) => { // object is optional, will generate a public note if undefined - const baseUrl = 'https://example.org'; const uuid = utils.generateUUID(); - const id = `${baseUrl}/activity/${uuid}`; + const id = `${Helpers.mocks._baseUrl}/activity/${uuid}`; object = object || Helpers.mocks.note().note; const activity = { @@ -118,9 +117,8 @@ Helpers.mocks.create = (object) => { }; Helpers.mocks.accept = (actor, object) => { - const baseUrl = 'https://example.org'; const uuid = utils.generateUUID(); - const id = `${baseUrl}/activity/${uuid}`; + const id = `${Helpers.mocks._baseUrl}/activity/${uuid}`; const activity = { '@context': 'https://www.w3.org/ns/activitystreams', @@ -132,4 +130,49 @@ Helpers.mocks.accept = (actor, object) => { }; return { activity }; -} +}; + +Helpers.mocks.like = (override = {}) => { + let actor = override.actor; + let object = override.object; + if (!actor) { + ({ id: actor } = Helpers.mocks.person()); + } + if (!object) { + ({ id: object } = Helpers.mocks.note()); + } + + const activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + id: `${Helpers.mocks._baseUrl}/like/${encodeURIComponent(object)}`, + type: 'Like', + actor, + object, + }; + + return { activity }; +}; + +Helpers.mocks.announce = (override = {}) => { + let actor = override.actor; + let object = override.object; + if (!actor) { + ({ id: actor } = Helpers.mocks.person()); + } + if (!object) { + ({ id: object } = Helpers.mocks.note()); + } + + const activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + id: `${Helpers.mocks._baseUrl}/announce/${encodeURIComponent(object.id || object)}`, + type: 'Announce', + to: [activitypub._constants.publicAddress], + cc: [`${actor}/followers`], + actor, + object, + }; + + return { activity }; +}; + diff --git a/test/activitypub/notes.js b/test/activitypub/notes.js index ab3aa75271..0e2abaafb7 100644 --- a/test/activitypub/notes.js +++ b/test/activitypub/notes.js @@ -208,7 +208,7 @@ describe('Notes', () => { const unread = await topics.getTotalUnread(uid); assert.strictEqual(unread, 0); - }) + }); }); }); @@ -328,7 +328,7 @@ describe('Notes', () => { it('should federate out an activity with object of type "Note"', () => { assert(activity.object && activity.object.type); assert.strictEqual(activity.object.type, 'Note'); - }) + }); }); }); @@ -411,6 +411,142 @@ describe('Notes', () => { }); }); + describe.only('Inbox handling', () => { + describe('helper self-check', () => { + it('should generate a Like activity', () => { + const object = utils.generateUUID(); + const { id: actor } = helpers.mocks.person(); + const { activity } = helpers.mocks.like({ + object, + actor, + }); + + assert.deepStrictEqual(activity, { + '@context': 'https://www.w3.org/ns/activitystreams', + id: `${helpers.mocks._baseUrl}/like/${encodeURIComponent(object)}`, + type: 'Like', + actor, + object, + }); + }); + + it('should generate an Announce activity wrapping a Like activity', () => { + const object = utils.generateUUID(); + const { id: actor } = helpers.mocks.person(); + const { activity: like } = helpers.mocks.like({ + object, + actor, + }); + const { id: gActor } = helpers.mocks.group(); + const { activity } = helpers.mocks.announce({ + actor: gActor, + object: like, + }); + + assert.deepStrictEqual(activity, { + '@context': 'https://www.w3.org/ns/activitystreams', + id: `${helpers.mocks._baseUrl}/announce/${encodeURIComponent(like.id)}`, + type: 'Announce', + to: [ 'https://www.w3.org/ns/activitystreams#Public' ], + cc: [ + `${gActor}/followers`, + ], + actor: gActor, + object: like, + }); + }); + }); + + describe('Announce', () => { + let cid; + + before(async () => { + ({ cid } = await categories.create({ name: utils.generateUUID().slice(0, 8) })); + }); + + describe('(Note)', () => { + it('should create a new topic in cid -1 if category not addressed', async () => { + const { note } = helpers.mocks.note(); + await activitypub.actors.assert([note.attributedTo]); + const { activity } = helpers.mocks.announce({ + object: note, + }); + const uid = await user.create({ username: utils.generateUUID().slice(0, 10) }); + await db.sortedSetAdd(`followersRemote:${activity.actor}`, Date.now(), uid); + + const beforeCount = await db.sortedSetCard(`cid:-1:tids`); + await activitypub.inbox.announce({ body: activity }); + const count = await db.sortedSetCard(`cid:-1:tids`); + + assert.strictEqual(count, beforeCount + 1); + }); + + it('should create a new topic in local category', async () => { + const { note } = helpers.mocks.note({ + cc: [`${nconf.get('url')}/category/${cid}`], + }); + await activitypub.actors.assert([note.attributedTo]); + const { activity } = helpers.mocks.announce({ + object: note, + }); + const uid = await user.create({ username: utils.generateUUID().slice(0, 10) }); + await db.sortedSetAdd(`followersRemote:${activity.actor}`, Date.now(), uid); + + const beforeCount = await db.sortedSetCard(`cid:${cid}:tids`); + await activitypub.inbox.announce({ body: activity }); + const count = await db.sortedSetCard(`cid:${cid}:tids`); + + assert.strictEqual(count, beforeCount + 1); + }); + }); + + describe('(Like)', () => { + it('should upvote a local post', async () => { + const uid = await user.create({ username: utils.generateUUID().slice(0, 10) }); + const { postData } = await topics.post({ + cid, + uid, + title: utils.generateUUID(), + content: utils.generateUUID(), + }); + + const { activity: like } = helpers.mocks.like({ + object: `${nconf.get('url')}/post/${postData.pid}`, + }); + const { activity } = helpers.mocks.announce({ + object: like, + }); + + let { upvotes } = await posts.getPostFields(postData.pid, 'upvotes'); + assert.strictEqual(upvotes, 0); + + await activitypub.inbox.announce({ body: activity }); + ({ upvotes } = await posts.getPostFields(postData.pid, 'upvotes')); + assert.strictEqual(upvotes, 1); + }); + + it('should upvote an asserted remote post', async () => { + const { id } = helpers.mocks.note(); + await activitypub.notes.assert(0, [id], { skipChecks: true }); + const { activity: like } = helpers.mocks.like({ + object: id, + }); + const { activity } = helpers.mocks.announce({ + object: like, + }); + + let { upvotes } = await posts.getPostFields(id, 'upvotes'); + assert.strictEqual(upvotes, 0); + + await activitypub.inbox.announce({ body: activity }); + + ({ upvotes } = await posts.getPostFields(id, 'upvotes')); + assert.strictEqual(upvotes, 1); + }); + }); + }); + }); + describe('Inbox Synchronization', () => { let cid; let uid;