From 607c4623c7aabf55bb9a72c46c2fe5f37c0efb95 Mon Sep 17 00:00:00 2001 From: Julian Lam Date: Thu, 1 Feb 2024 15:59:29 -0500 Subject: [PATCH] feat: Like(Note) and Undo(Like); federating likes --- public/src/client/topic/events.js | 6 ++-- public/src/client/topic/votes.js | 2 +- src/activitypub/helpers.js | 16 ++++++++++- src/activitypub/inbox.js | 37 +++++++++++++++++------- src/api/activitypub.js | 43 ++++++++++++++++++++++++++++ src/api/helpers.js | 3 ++ src/controllers/activitypub/index.js | 5 ++++ src/posts/votes.js | 10 +++---- src/privileges/categories.js | 6 ++++ 9 files changed, 108 insertions(+), 20 deletions(-) diff --git a/public/src/client/topic/events.js b/public/src/client/topic/events.js index e091dd69c8..62ec136331 100644 --- a/public/src/client/topic/events.js +++ b/public/src/client/topic/events.js @@ -71,7 +71,7 @@ define('forum/topic/events', [ function updatePostVotesAndUserReputation(data) { const votes = $('[data-pid="' + data.post.pid + '"] [component="post/vote-count"]').filter(function (index, el) { - return parseInt($(el).closest('[data-pid]').attr('data-pid'), 10) === parseInt(data.post.pid, 10); + return $(el).closest('[data-pid]').attr('data-pid') === String(data.post.pid); }); const reputationElements = $('.reputation[data-uid="' + data.post.uid + '"]'); votes.html(data.post.votes).attr('data-votes', data.post.votes); @@ -225,10 +225,10 @@ define('forum/topic/events', [ function togglePostVote(data) { const post = $('[data-pid="' + data.post.pid + '"]'); post.find('[component="post/upvote"]').filter(function (index, el) { - return parseInt($(el).closest('[data-pid]').attr('data-pid'), 10) === parseInt(data.post.pid, 10); + return $(el).closest('[data-pid]').attr('data-pid') === String(data.post.pid); }).toggleClass('upvoted', data.upvote); post.find('[component="post/downvote"]').filter(function (index, el) { - return parseInt($(el).closest('[data-pid]').attr('data-pid'), 10) === parseInt(data.post.pid, 10); + return $(el).closest('[data-pid]').attr('data-pid') === String(data.post.pid); }).toggleClass('downvoted', data.downvote); } diff --git a/public/src/client/topic/votes.js b/public/src/client/topic/votes.js index 2ead12f804..2303af9184 100644 --- a/public/src/client/topic/votes.js +++ b/public/src/client/topic/votes.js @@ -77,7 +77,7 @@ define('forum/topic/votes', [ const method = currentState ? 'del' : 'put'; const pid = post.attr('data-pid'); - api[method](`/posts/${pid}/vote`, { + api[method](`/posts/${encodeURIComponent(pid)}/vote`, { delta: delta, }, function (err) { if (err) { diff --git a/src/activitypub/helpers.js b/src/activitypub/helpers.js index 4de3147adf..7cc91f97ad 100644 --- a/src/activitypub/helpers.js +++ b/src/activitypub/helpers.js @@ -94,7 +94,7 @@ Helpers.resolveLocalUid = async (input) => { const { host, pathname } = new URL(input); if (host === nconf.get('url_parsed').host) { - const [type, value] = pathname.replace(nconf.get('relative_path'), '').split('/').filter(Boolean)[1]; + const [type, value] = pathname.replace(nconf.get('relative_path'), '').split('/').filter(Boolean); if (type === 'uid') { return value; } @@ -111,3 +111,17 @@ Helpers.resolveLocalUid = async (input) => { return await user.getUidByUserslug(slug); }; + +Helpers.resolveLocalPid = async (uri) => { + const { host, pathname } = new URL(uri); + if (host === nconf.get('url_parsed').host) { + const [type, value] = pathname.replace(nconf.get('relative_path'), '').split('/').filter(Boolean); + if (type !== 'post') { + throw new Error('[[error:activitypub.invalid-id]]'); + } + + return value; + } + + throw new Error('[[error:activitypub.invalid-id]]'); +}; diff --git a/src/activitypub/inbox.js b/src/activitypub/inbox.js index 08d9c96081..a4928a1caa 100644 --- a/src/activitypub/inbox.js +++ b/src/activitypub/inbox.js @@ -4,6 +4,7 @@ const winston = require('winston'); const db = require('../database'); const user = require('../user'); +const posts = require('../posts'); const activitypub = require('.'); const helpers = require('./helpers'); @@ -53,6 +54,13 @@ inbox.update = async (req) => { } }; +inbox.like = async (req) => { + const { actor, object } = req.body; + const pid = await activitypub.helpers.resolveLocalPid(object); + + await posts.upvote(pid, actor); +}; + inbox.follow = async (req) => { // Sanity checks const localUid = await helpers.resolveLocalUid(req.body.object); @@ -119,20 +127,29 @@ inbox.undo = async (req) => { const { actor, object } = req.body; const { type } = object; - const uid = await helpers.resolveLocalUid(object.object); - if (!uid) { - throw new Error('[[error:invalid-uid]]'); - } - const assertion = await activitypub.actors.assert(actor); if (!assertion) { throw new Error('[[error:activitypub.invalid-id]]'); } - if (type === 'Follow') { - await Promise.all([ - db.sortedSetRemove(`followersRemote:${uid}`, actor), - db.decrObjectField(`user:${uid}`, 'followerRemoteCount'), - ]); + switch (type) { + case 'Follow': { + const uid = await helpers.resolveLocalUid(object.object); + if (!uid) { + throw new Error('[[error:invalid-uid]]'); + } + + await Promise.all([ + db.sortedSetRemove(`followersRemote:${uid}`, actor), + db.decrObjectField(`user:${uid}`, 'followerRemoteCount'), + ]); + break; + } + + case 'Like': { + const pid = await helpers.resolveLocalPid(object.object); + await posts.unvote(pid, actor); + break; + } } }; diff --git a/src/api/activitypub.js b/src/api/activitypub.js index 036396a00e..73f671da87 100644 --- a/src/api/activitypub.js +++ b/src/api/activitypub.js @@ -28,6 +28,7 @@ activitypubApi.follow = async (caller, { uid } = {}) => { }); }; +// should be .undo.follow activitypubApi.unfollow = async (caller, { uid }) => { const result = await activitypub.helpers.query(uid); if (!result) { @@ -112,3 +113,45 @@ activitypubApi.update.note = async (caller, { post }) => { await activitypub.send(caller.uid, Array.from(targets), payload); }; + +activitypubApi.like = {}; + +activitypubApi.like.note = async (caller, { pid }) => { + if (!activitypub.helpers.isUri(pid)) { + return; + } + + const uid = await posts.getPostField(pid, 'uid'); + if (!activitypub.helpers.isUri(uid)) { + return; + } + + await activitypub.send(caller.uid, [uid], { + type: 'Like', + object: pid, + }); +}; + +activitypubApi.undo = {}; + +// activitypubApi.undo.follow = + +activitypubApi.undo.like = async (caller, { pid }) => { + if (!activitypub.helpers.isUri(pid)) { + return; + } + + const uid = await posts.getPostField(pid, 'uid'); + if (!activitypub.helpers.isUri(uid)) { + return; + } + + await activitypub.send(caller.uid, [uid], { + type: 'Undo', + object: { + actor: `${nconf.get('url')}/uid/${caller.uid}`, + type: 'Like', + object: pid, + }, + }); +}; diff --git a/src/api/helpers.js b/src/api/helpers.js index ef7c062482..e0d3bbc0bb 100644 --- a/src/api/helpers.js +++ b/src/api/helpers.js @@ -129,6 +129,7 @@ exports.postCommand = async function (caller, command, eventName, notification, }; async function executeCommand(caller, command, eventName, notification, data) { + const api = require('.'); const result = await posts[command](data.pid, caller.uid); if (result && eventName) { websockets.in(`uid_${caller.uid}`).emit(`posts.${command}`, result); @@ -136,10 +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 }); } 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 }); } return result; } diff --git a/src/controllers/activitypub/index.js b/src/controllers/activitypub/index.js index d9a24742eb..5d1b7d8489 100644 --- a/src/controllers/activitypub/index.js +++ b/src/controllers/activitypub/index.js @@ -93,6 +93,11 @@ Controller.postInbox = async (req, res) => { break; } + case 'Like': { + await activitypub.inbox.like(req); + break; + } + case 'Follow': { await activitypub.inbox.follow(req); break; diff --git a/src/posts/votes.js b/src/posts/votes.js index bfe5e1e47f..46254028eb 100644 --- a/src/posts/votes.js +++ b/src/posts/votes.js @@ -8,6 +8,7 @@ const topics = require('../topics'); const plugins = require('../plugins'); const privileges = require('../privileges'); const translator = require('../translator'); +const utils = require('../utils'); module.exports = function (Posts) { const votesInProgress = {}; @@ -99,17 +100,17 @@ module.exports = function (Posts) { }; function voteInProgress(pid, uid) { - return Array.isArray(votesInProgress[uid]) && votesInProgress[uid].includes(parseInt(pid, 10)); + return Array.isArray(votesInProgress[uid]) && votesInProgress[uid].includes(String(pid)); } function putVoteInProgress(pid, uid) { votesInProgress[uid] = votesInProgress[uid] || []; - votesInProgress[uid].push(parseInt(pid, 10)); + votesInProgress[uid].push(String(pid)); } function clearVoteProgress(pid, uid) { if (Array.isArray(votesInProgress[uid])) { - const index = votesInProgress[uid].indexOf(parseInt(pid, 10)); + const index = votesInProgress[uid].indexOf(String(pid)); if (index !== -1) { votesInProgress[uid].splice(index, 1); } @@ -171,8 +172,7 @@ module.exports = function (Posts) { } async function vote(type, unvote, pid, uid, voteStatus) { - uid = parseInt(uid, 10); - if (uid <= 0) { + if (utils.isNumber(uid) && parseInt(uid, 10) <= 0) { throw new Error('[[error:not-logged-in]]'); } const now = Date.now(); diff --git a/src/privileges/categories.js b/src/privileges/categories.js index 7ccec5609d..bdfba7117d 100644 --- a/src/privileges/categories.js +++ b/src/privileges/categories.js @@ -153,6 +153,12 @@ privsCategories.can = async function (privilege, cid, uid) { if (!cid) { return false; } + + // temporary + if (cid === -1) { + return true; + } + const [disabled, isAdmin, isAllowed] = await Promise.all([ categories.getCategoryField(cid, 'disabled'), user.isAdministrator(uid),