From c02271c7afd7fa0699d405b9ead36419489f0d50 Mon Sep 17 00:00:00 2001 From: Julian Lam Date: Wed, 28 Jun 2023 14:59:39 -0400 Subject: [PATCH] feat: follow/unfollow logic and receipt --- src/activitypub/helpers.js | 12 +++++ src/activitypub/inbox.js | 61 +++++++++++++++++++++++ src/activitypub/index.js | 2 + src/activitypub/outbox.js | 13 +++++ src/controllers/activitypub/index.js | 66 ++++++++++++++++++++----- src/controllers/activitypub/profiles.js | 6 ++- src/user/follow.js | 8 +-- 7 files changed, 153 insertions(+), 15 deletions(-) create mode 100644 src/activitypub/inbox.js create mode 100644 src/activitypub/outbox.js diff --git a/src/activitypub/helpers.js b/src/activitypub/helpers.js index 74ce768a74..959c8a5419 100644 --- a/src/activitypub/helpers.js +++ b/src/activitypub/helpers.js @@ -3,9 +3,11 @@ const request = require('request-promise-native'); const { generateKeyPairSync } = require('crypto'); const winston = require('winston'); +const nconf = require('nconf'); const db = require('../database'); const ttl = require('../cache/ttl'); +const user = require('../user'); const webfingerCache = ttl({ ttl: 1000 * 60 * 60 * 24 }); // 24 hours @@ -65,3 +67,13 @@ Helpers.generateKeys = async (uid) => { await db.setObject(`uid:${uid}:keys`, { publicKey, privateKey }); return { publicKey, privateKey }; }; + +Helpers.resolveLocalUid = async (id) => { + const [slug, host] = id.split('@'); + + if (id.indexOf('@') === -1 || host !== nconf.get('url_parsed').host) { + throw new Error('[[activitypub:invalid-id]]'); + } + + return await user.getUidByUserslug(slug); +}; diff --git a/src/activitypub/inbox.js b/src/activitypub/inbox.js new file mode 100644 index 0000000000..6bbf25e748 --- /dev/null +++ b/src/activitypub/inbox.js @@ -0,0 +1,61 @@ +'use strict'; + +const db = require('../database'); +const user = require('../user'); + +const helpers = require('./helpers'); + +const inbox = module.exports; + +inbox.follow = async (actorId, objectId) => { + await handleFollow('follow', actorId, objectId); +}; + +inbox.unfollow = async (actorId, objectId) => { + await handleFollow('unfollow', actorId, objectId); +}; + +inbox.isFollowed = async (actorId, uid) => { + if (actorId.indexOf('@') === -1 || parseInt(uid, 10) <= 0) { + return false; + } + return await db.isSortedSetMember(`followersRemote:${uid}`, actorId); +}; + +async function handleFollow(type, actorId, objectId) { + // Sanity checks + const actorExists = await helpers.query(actorId); + if (!actorId || !actorExists) { + throw new Error('[[error:invalid-uid]]'); // should probably be AP specific + } + + if (!objectId) { + throw new Error('[[error:invalid-uid]]'); // should probably be AP specific + } + + const localUid = await helpers.resolveLocalUid(objectId); + if (!localUid) { + throw new Error('[[error:invalid-uid]]'); + } + + // matches toggleFollow() in src/user/follow.js + const isFollowed = await inbox.isFollowed(actorId, localUid); + if (type === 'follow') { + if (isFollowed) { + throw new Error('[[error:already-following]]'); + } + const now = Date.now(); + await db.sortedSetAdd(`followersRemote:${localUid}`, now, actorId); + } else { + if (!isFollowed) { + throw new Error('[[error:not-following]]'); + } + await db.sortedSetRemove(`followersRemote:${localUid}`, actorId); + } + + const [followerCount, followerRemoteCount] = await Promise.all([ + db.sortedSetCard(`followers:${localUid}`), + db.sortedSetCard(`followersRemote:${localUid}`), + ]); + await user.setUserField(localUid, 'followerCount', followerCount + followerRemoteCount); +} diff --git a/src/activitypub/index.js b/src/activitypub/index.js index 3bb2177a3c..95f1e98024 100644 --- a/src/activitypub/index.js +++ b/src/activitypub/index.js @@ -12,6 +12,8 @@ const actorCache = ttl({ ttl: 1000 * 60 * 60 * 24 }); // 24 hours const ActivityPub = module.exports; ActivityPub.helpers = require('./helpers'); +ActivityPub.inbox = require('./inbox'); +ActivityPub.outbox = require('./outbox'); ActivityPub.getActor = async (id) => { if (actorCache.has(id)) { diff --git a/src/activitypub/outbox.js b/src/activitypub/outbox.js new file mode 100644 index 0000000000..a21ef567fd --- /dev/null +++ b/src/activitypub/outbox.js @@ -0,0 +1,13 @@ +'use strict'; + +const db = require('../database'); + +const outbox = module.exports; + +outbox.isFollowing = async (uid, actorId) => { + if (parseInt(uid, 10) <= 0 || actorId.indexOf('@') === -1) { + return false; + } + return await db.isSortedSetMember(`followingRemote:${uid}`, actorId); +}; + diff --git a/src/controllers/activitypub/index.js b/src/controllers/activitypub/index.js index f7e7dc460a..1ecb86a697 100644 --- a/src/controllers/activitypub/index.js +++ b/src/controllers/activitypub/index.js @@ -2,8 +2,10 @@ const nconf = require('nconf'); +const db = require('../../database'); const user = require('../../user'); const activitypub = require('../../activitypub'); +const helpers = require('../helpers'); const Controller = module.exports; @@ -99,7 +101,17 @@ Controller.getInbox = async (req, res) => { }; Controller.postInbox = async (req, res) => { - console.log('received', req.body); + switch (req.body.type) { + case 'Follow': { + await activitypub.inbox.follow(req.body.actor.name, req.body.object.name); + break; + } + + case 'Unfollow': { + await activitypub.inbox.unfollow(req.body.actor.name, req.body.object.name); + break; + } + } res.sendStatus(201); }; @@ -109,18 +121,50 @@ Controller.postInbox = async (req, res) => { */ Controller.follow = async (req, res) => { - await activitypub.send(req.uid, req.params.uid, { - type: 'Follow', - object: { - type: 'Person', - name: req.params.uid, - }, - }); + try { + const { uid: objectId } = req.params; + await activitypub.send(req.uid, objectId, { + type: 'Follow', + object: { + type: 'Person', + name: objectId, + }, + }); - res.sendStatus(201); + const now = Date.now(); + await db.sortedSetAdd(`followingRemote:${req.uid}`, now, objectId); + await recountFollowing(req.uid); + + helpers.formatApiResponse(200, res); + } catch (e) { + helpers.formatApiResponse(400, res, e); + } }; Controller.unfollow = async (req, res) => { - console.log('got here'); - res.sendStatus(201); + try { + const { uid: objectId } = req.params; + await activitypub.send(req.uid, objectId, { + type: 'Unfollow', + object: { + type: 'Person', + name: objectId, + }, + }); + + await db.sortedSetRemove(`followingRemote:${req.uid}`, objectId); + await recountFollowing(req.uid); + + helpers.formatApiResponse(200, res); + } catch (e) { + helpers.formatApiResponse(400, res, e); + } }; + +async function recountFollowing(uid) { + const [followingCount, followingRemoteCount] = await Promise.all([ + db.sortedSetCard(`following:${uid}`), + db.sortedSetCard(`followingRemote:${uid}`), + ]); + await user.setUserField(uid, 'followingCount', followingCount + followingRemoteCount); +} diff --git a/src/controllers/activitypub/profiles.js b/src/controllers/activitypub/profiles.js index bc466048b6..3d05ce2c6f 100644 --- a/src/controllers/activitypub/profiles.js +++ b/src/controllers/activitypub/profiles.js @@ -1,6 +1,6 @@ 'use strict'; -const { getActor } = require('../../activitypub'); +const { getActor, outbox } = require('../../activitypub'); const controller = module.exports; @@ -11,6 +11,8 @@ controller.get = async function (req, res, next) { return next(); } const { preferredUsername, published, icon, image, name, summary, hostname } = actor; + const isFollowing = await outbox.isFollowing(req.uid, uid); + const payload = { uid, username: `${preferredUsername}@${hostname}`, @@ -23,6 +25,8 @@ controller.get = async function (req, res, next) { 'cover:position': '50% 50%', aboutme: summary, aboutmeParsed: summary, + + isFollowing, }; res.render('account/profile', payload); diff --git a/src/user/follow.js b/src/user/follow.js index f3b031a582..149e6d1151 100644 --- a/src/user/follow.js +++ b/src/user/follow.js @@ -49,13 +49,15 @@ module.exports = function (User) { ]); } - const [followingCount, followerCount] = await Promise.all([ + const [followingCount, followingRemoteCount, followerCount, followerRemoteCount] = await Promise.all([ db.sortedSetCard(`following:${uid}`), + db.sortedSetCard(`followingRemote:${uid}`), db.sortedSetCard(`followers:${theiruid}`), + db.sortedSetCard(`followersRemote:${theiruid}`), ]); await Promise.all([ - User.setUserField(uid, 'followingCount', followingCount), - User.setUserField(theiruid, 'followerCount', followerCount), + User.setUserField(uid, 'followingCount', followingCount + followingRemoteCount), + User.setUserField(theiruid, 'followerCount', followerCount + followerRemoteCount), ]); }