diff --git a/src/activitypub/feps.js b/src/activitypub/feps.js index 1e2d96441e..7b7816516d 100644 --- a/src/activitypub/feps.js +++ b/src/activitypub/feps.js @@ -3,6 +3,7 @@ const nconf = require('nconf'); const posts = require('../posts'); +const utils = require('../utils'); const activitypub = module.parent.exports; const Feps = module.exports; @@ -13,7 +14,7 @@ Feps.announce = async function announce(id, activity) { ({ id: localId } = await activitypub.helpers.resolveLocalId(id)); } const cid = await posts.getCidByPid(localId || id); - if (cid === -1) { + if (cid === -1 || !utils.isNumber(cid)) { // local cids only return; } diff --git a/src/activitypub/inbox.js b/src/activitypub/inbox.js index 19f149caec..10656a8d73 100644 --- a/src/activitypub/inbox.js +++ b/src/activitypub/inbox.js @@ -49,7 +49,7 @@ inbox.create = async (req) => { const asserted = await activitypub.notes.assert(0, object, { cid }); if (asserted) { - activitypub.feps.announce(object.id, req.body); + await activitypub.feps.announce(object.id, req.body); // api.activitypub.add(req, { pid: object.id }); } }; @@ -244,7 +244,7 @@ inbox.like = async (req) => { activitypub.helpers.log(`[activitypub/inbox/like] id ${id} via ${actor}`); const result = await posts.upvote(id, actor); - activitypub.feps.announce(object.id, req.body); + await activitypub.feps.announce(object.id, req.body); socketHelpers.upvote(result, 'notifications:upvoted-your-post-in'); }; diff --git a/src/api/activitypub.js b/src/api/activitypub.js index 43dad015b1..ce78f12d23 100644 --- a/src/api/activitypub.js +++ b/src/api/activitypub.js @@ -310,7 +310,15 @@ activitypubApi.delete.note = enabledCheck(async (caller, { pid }) => { activitypubApi.like = {}; activitypubApi.like.note = enabledCheck(async (caller, { pid }) => { - if (!activitypub.helpers.isUri(pid)) { // remote only + const payload = { + id: `${nconf.get('url')}/uid/${caller.uid}#activity/like/${encodeURIComponent(pid)}`, + type: 'Like', + actor: `${nconf.get('url')}/uid/${caller.uid}`, + object: utils.isNumber(pid) ? `${nconf.get('url')}/post/${pid}` : pid, + }; + + if (!activitypub.helpers.isUri(pid)) { // only 1b12 announce for local likes + await activitypub.feps.announce(pid, payload); return; } @@ -319,13 +327,6 @@ activitypubApi.like.note = enabledCheck(async (caller, { pid }) => { return; } - const payload = { - id: `${nconf.get('url')}/uid/${caller.uid}#activity/like/${encodeURIComponent(pid)}`, - type: 'Like', - actor: `${nconf.get('url')}/uid/${caller.uid}`, - object: pid, - }; - await Promise.all([ activitypub.send('uid', caller.uid, [uid], payload), activitypub.feps.announce(pid, payload), diff --git a/src/api/helpers.js b/src/api/helpers.js index e0d3bbc0bb..168e5539b6 100644 --- a/src/api/helpers.js +++ b/src/api/helpers.js @@ -137,12 +137,12 @@ async function executeCommand(caller, command, eventName, notification, data) { } if (result && command === 'upvote') { socketHelpers.upvote(result, notification); - api.activitypub.like.note(caller, { pid: data.pid }); + await api.activitypub.like.note(caller, { pid: data.pid }); } else if (result && notification) { socketHelpers.sendNotificationToPostOwner(data.pid, caller.uid, command, notification); } else if (result && command === 'unvote') { socketHelpers.rescindUpvoteNotification(data.pid, caller.uid); - api.activitypub.undo.like(caller, { pid: data.pid }); + await api.activitypub.undo.like(caller, { pid: data.pid }); } return result; } diff --git a/test/activitypub/feps.js b/test/activitypub/feps.js index a9b42644d8..65520f0f4e 100644 --- a/test/activitypub/feps.js +++ b/test/activitypub/feps.js @@ -12,6 +12,7 @@ const user = require('../../src/user'); const groups = require('../../src/groups'); const categories = require('../../src/categories'); const topics = require('../../src/topics'); +const posts = require('../../src/posts'); const api = require('../../src/api'); const helpers = require('./helpers'); @@ -47,84 +48,258 @@ describe('FEPs', () => { activitypub._sent.clear(); }); - it('should be called when a topic is moved from uncategorized to another category', async () => { - const { topicData, postData } = await topics.post({ - uid, - cid: -1, - title: utils.generateUUID(), - content: utils.generateUUID(), - }); + describe('local actions (create, reply, vote)', () => { + let topicData; - assert(topicData); - - await api.topics.move({ uid: adminUid }, { - tid: topicData.tid, - cid, - }); - - assert.strictEqual(activitypub._sent.size, 2); - - const key = Array.from(activitypub._sent.keys())[0]; - const activity = activitypub._sent.get(key); - - assert(activity && activity.object && typeof activity.object === 'object'); - assert.strictEqual(activity.object.id, `${nconf.get('url')}/post/${postData.pid}`); - }); - - it('should be called for a newly forked topic', async () => { - const { topicData } = await topics.post({ - uid, - cid: -1, - title: utils.generateUUID(), - content: utils.generateUUID(), - }); - const { tid } = topicData; - const { pid: reply1Pid } = await topics.reply({ uid, tid, content: utils.generateUUID() }); - const { pid: reply2Pid } = await topics.reply({ uid, tid, content: utils.generateUUID() }); - await topics.createTopicFromPosts( - adminUid, utils.generateUUID(), [reply1Pid, reply2Pid], tid, cid - ); - - assert.strictEqual(activitypub._sent.size, 2, activitypub._sent.keys()); - - const key = Array.from(activitypub._sent.keys())[0]; - const activity = activitypub._sent.get(key); - - assert(activity && activity.object && typeof activity.object === 'object'); - assert.strictEqual(activity.object.id, `${nconf.get('url')}/post/${reply1Pid}`); - }); - - it('should be called when a post is moved to another topic', async () => { - const [{ topicData: topic1 }, { topicData: topic2 }] = await Promise.all([ - topics.post({ - uid, + before(async () => { + topicData = await api.topics.create({ uid }, { cid, title: utils.generateUUID(), content: utils.generateUUID(), - }), - topics.post({ - uid, + }); + }); + + afterEach(() => { + activitypub._sent.clear(); + }); + + it('should have federated out both Announce(Create(Article)) and Announce(Article)', () => { + const activities = Array.from(activitypub._sent); + + const test1 = activities.some((activity) => { + [, activity] = activity; + return activity.type === 'Announce' && + activity.object && activity.object.type === 'Create' && + activity.object.object && activity.object.object.type === 'Article'; + }); + + const test2 = activities.some((activity) => { + [, activity] = activity; + return activity.type === 'Announce' && + activity.object && activity.object.type === 'Article'; + }); + + assert(test1 && test2); + }); + + it('should federate out Announce(Create(Note)) on local reply', async () => { + await api.topics.reply({ uid }, { + tid: topicData.tid, + content: utils.generateUUID(), + }); + + const activities = Array.from(activitypub._sent); + + assert(activities.some((activity) => { + [, activity] = activity; + return activity.type === 'Announce' && + activity.object && activity.object.type === 'Create' && + activity.object.object && activity.object.object.type === 'Note'; + })); + }); + + it('should NOT federate out Announce(Note) on local reply', async () => { + await api.topics.reply({ uid }, { + tid: topicData.tid, + content: utils.generateUUID(), + }); + + const activities = Array.from(activitypub._sent); + + assert(activities.every((activity) => { + [, activity] = activity; + if (activity.type === 'Announce' && activity.object && activity.object.type === 'Note') { + return false; + } + + return true; + })); + }); + + it('should federate out Announce(Like) on local vote', async () => { + activitypub._sent.clear(); + await api.posts.upvote({ uid: adminUid }, { pid: topicData.mainPid, room_id: `topic_${topicData.tid}` }); + const activities = Array.from(activitypub._sent); + + assert(activities.some((activity) => { + [, activity] = activity; + return activity.type === 'Announce' && + activity.object && activity.object.type === 'Like'; + })); + }); + }); + + describe('remote actions (create, reply, vote)', () => { + let activity; + let pid; + let topicData; + + before(async () => { + topicData = await api.topics.create({ uid }, { cid, title: utils.generateUUID(), content: utils.generateUUID(), - }), - ]); + }); + }); - assert(topic1 && topic2); + afterEach(() => { + activitypub._sent.clear(); + }); - // Create new reply and move it to topic 2 - const { pid } = await topics.reply({ uid, tid: topic1.tid, content: utils.generateUUID() }); - await api.posts.move({ uid: adminUid }, { pid, tid: topic2.tid }); + it('should have slotted the note into the test category', async () => { + const { id, note } = await helpers.mocks.note({ + cc: [`${nconf.get('url')}/category/${cid}`], + }); + pid = id; + ({ activity } = await helpers.mocks.create(note)); + await activitypub.inbox.create({ body: activity }); - assert.strictEqual(activitypub._sent.size, 1); - const activities = Array.from(activitypub._sent.keys()).map(key => activitypub._sent.get(key)); + const noteCid = await posts.getCidByPid(pid); + assert.strictEqual(noteCid, cid); + }); - const activity = activities.pop(); - assert.strictEqual(activity.type, 'Announce'); - assert(activity.object && activity.object.type); - assert.strictEqual(activity.object.type, 'Create'); - assert(activity.object.object && activity.object.object.type); - assert.strictEqual(activity.object.object.type, 'Note'); + it('should federate out an Announce(Create(Note)) and Announce(Note) on new topic', async () => { + const { id, note } = await helpers.mocks.note({ + cc: [`${nconf.get('url')}/category/${cid}`], + }); + pid = id; + ({ activity } = await helpers.mocks.create(note)); + await activitypub.inbox.create({ body: activity }); + + const activities = Array.from(activitypub._sent); + + const test1 = activities.some((activity) => { + [, activity] = activity; + return activity.type === 'Announce' && + activity.object && activity.object.type === 'Create' && + activity.object.object && activity.object.object.type === 'Note'; + }); + + const test2 = activities.some((activity) => { + [, activity] = activity; + return activity.type === 'Announce' && + activity.object && activity.object.type === 'Note'; + }); + + assert(test1 && test2); + }); + + it('should federate out an Announce(Create(Note)) on reply', async () => { + const { id, note } = await helpers.mocks.note({ + cc: [`${nconf.get('url')}/category/${cid}`], + inReplyTo: `${nconf.get('url')}/post/${topicData.mainPid}`, + }); + pid = id; + ({ activity } = await helpers.mocks.create(note)); + await activitypub.inbox.create({ body: activity }); + + const activities = Array.from(activitypub._sent); + + assert(activities.some((activity) => { + [, activity] = activity; + return activity.type === 'Announce' && + activity.object && activity.object.type === 'Create' && + activity.object.object && activity.object.object.type === 'Note'; + })); + }); + + it('should federate out an Announce(Like) on vote', async () => { + const { activity } = await helpers.mocks.like({ + object: { + id: `${nconf.get('url')}/post/${topicData.mainPid}`, + }, + }); + await activitypub.inbox.like({ body: activity }); + + const activities = Array.from(activitypub._sent); + assert(activities.some((activity) => { + [, activity] = activity; + return activity.type === 'Announce' && + activity.object && activity.object.type === 'Like'; + })); + }); + }); + + describe('extended actions not explicitly specified in 1b12', () => { + it('should be called when a topic is moved from uncategorized to another category', async () => { + const { topicData, postData } = await topics.post({ + uid, + cid: -1, + title: utils.generateUUID(), + content: utils.generateUUID(), + }); + + assert(topicData); + + await api.topics.move({ uid: adminUid }, { + tid: topicData.tid, + cid, + }); + + assert.strictEqual(activitypub._sent.size, 2); + + const key = Array.from(activitypub._sent.keys())[0]; + const activity = activitypub._sent.get(key); + + assert(activity && activity.object && typeof activity.object === 'object'); + assert.strictEqual(activity.object.id, `${nconf.get('url')}/post/${postData.pid}`); + }); + + it('should be called for a newly forked topic', async () => { + const { topicData } = await topics.post({ + uid, + cid: -1, + title: utils.generateUUID(), + content: utils.generateUUID(), + }); + const { tid } = topicData; + const { pid: reply1Pid } = await topics.reply({ uid, tid, content: utils.generateUUID() }); + const { pid: reply2Pid } = await topics.reply({ uid, tid, content: utils.generateUUID() }); + await topics.createTopicFromPosts( + adminUid, utils.generateUUID(), [reply1Pid, reply2Pid], tid, cid + ); + + assert.strictEqual(activitypub._sent.size, 2, activitypub._sent.keys()); + + const key = Array.from(activitypub._sent.keys())[0]; + const activity = activitypub._sent.get(key); + + assert(activity && activity.object && typeof activity.object === 'object'); + assert.strictEqual(activity.object.id, `${nconf.get('url')}/post/${reply1Pid}`); + }); + + it('should be called when a post is moved to another topic', async () => { + const [{ topicData: topic1 }, { topicData: topic2 }] = await Promise.all([ + topics.post({ + uid, + cid, + title: utils.generateUUID(), + content: utils.generateUUID(), + }), + topics.post({ + uid, + cid, + title: utils.generateUUID(), + content: utils.generateUUID(), + }), + ]); + + assert(topic1 && topic2); + + // Create new reply and move it to topic 2 + const { pid } = await topics.reply({ uid, tid: topic1.tid, content: utils.generateUUID() }); + await api.posts.move({ uid: adminUid }, { pid, tid: topic2.tid }); + + assert.strictEqual(activitypub._sent.size, 1); + const activities = Array.from(activitypub._sent.keys()).map(key => activitypub._sent.get(key)); + + const activity = activities.pop(); + assert.strictEqual(activity.type, 'Announce'); + assert(activity.object && activity.object.type); + assert.strictEqual(activity.object.type, 'Create'); + assert(activity.object.object && activity.object.object.type); + assert.strictEqual(activity.object.object.type, 'Note'); + }); }); }); }); diff --git a/test/activitypub/helpers.js b/test/activitypub/helpers.js index e4c3a4d689..57d1d1826f 100644 --- a/test/activitypub/helpers.js +++ b/test/activitypub/helpers.js @@ -144,7 +144,7 @@ Helpers.mocks.like = (override = {}) => { const activity = { '@context': 'https://www.w3.org/ns/activitystreams', - id: `${Helpers.mocks._baseUrl}/like/${encodeURIComponent(object)}`, + id: `${Helpers.mocks._baseUrl}/like/${encodeURIComponent(object.id)}`, type: 'Like', actor, object,