From 59a9dd8436f4801f581937a764ef964d74281d1a Mon Sep 17 00:00:00 2001 From: Julian Lam Date: Mon, 15 Apr 2024 14:40:26 -0400 Subject: [PATCH] refactor: stub routes for category synchronization, refactor remote follow logic to allow categories to conduct follows as well --- .../en-GB/admin/manage/categories.json | 13 ++++ .../src/admin/manage/category-federation.js | 48 +++++++++++++ src/activitypub/helpers.js | 2 + src/activitypub/inbox.js | 40 +++++++---- src/api/activitypub.js | 65 ++++++++++++------ src/controllers/admin/categories.js | 24 +++++++ src/controllers/write/categories.js | 26 +++++++ src/controllers/write/users.js | 23 +++++-- src/routes/admin.js | 1 + src/routes/write/categories.js | 7 +- .../admin/manage/category-federation.tpl | 67 +++++++++++++++++++ src/views/admin/manage/category.tpl | 2 + .../partials/categories/category-rows.tpl | 1 + 13 files changed, 277 insertions(+), 42 deletions(-) create mode 100644 public/src/admin/manage/category-federation.js create mode 100644 src/views/admin/manage/category-federation.tpl diff --git a/public/language/en-GB/admin/manage/categories.json b/public/language/en-GB/admin/manage/categories.json index 124396eb88..77e39a2d0d 100644 --- a/public/language/en-GB/admin/manage/categories.json +++ b/public/language/en-GB/admin/manage/categories.json @@ -39,6 +39,7 @@ "disable": "Disable", "edit": "Edit", "analytics": "Analytics", + "federation": "Federation", "view-category": "View category", "set-order": "Set order", @@ -78,6 +79,18 @@ "analytics.topics-daily": "Figure 3 – Daily topics created in this category", "analytics.posts-daily": "Figure 4 – Daily posts made in this category", + "federation.title": "Federation settings for \"%1\" category", + "federation.disabled": "Federation is disabled site-wide, so category federation settings are currently unavailable.", + "federation.disabled-cta": "Federation Settings →", + "federation.syncing-header": "Synchronization", + "federation.syncing-intro": "A category can follow a \"Group Actor\" via the ActivityPub protocol. If content is received from one of the actors listed below, it will be automatically added to this category.", + "federation.syncing-caveat": "N.B. Setting up syncing here establishes a one-way synchronization. NodeBB attempts to subscribe/follow the actor, but the reverse cannot be assumed.", + "federation.syncing-none": "This category is not currently following anybody.", + "federation.syncing-add": "Synchronize with...", + "federation.syncing-actorUri": "Actor", + "federation.syncing-follow": "Follow", + "federation.syncing-unfollow": "Unfollow", + "alert.created": "Created", "alert.create-success": "Category successfully created!", "alert.none-active": "You have no active categories.", diff --git a/public/src/admin/manage/category-federation.js b/public/src/admin/manage/category-federation.js new file mode 100644 index 0000000000..8c646647b4 --- /dev/null +++ b/public/src/admin/manage/category-federation.js @@ -0,0 +1,48 @@ +import { put, del } from '../../modules/api'; +import { error } from '../../modules/alerts'; + +import * as categorySelector from '../../modules/categorySelector'; + +// eslint-disable-next-line import/prefer-default-export +export function init() { + categorySelector.init($('[component="category-selector"]'), { + onSelect: function (selectedCategory) { + ajaxify.go('admin/manage/categories/' + selectedCategory.cid + '/federation'); + }, + showLinks: true, + template: 'admin/partials/category/selector-dropdown-right', + }); + + document.getElementById('site-settings').addEventListener('click', async (e) => { + const subselector = e.target.closest('[data-action]'); + if (!subselector) { + return; + } + + const action = subselector.getAttribute('data-action'); + + switch (action) { + case 'follow': { + const inputEl = document.getElementById('syncing.add'); + const actor = inputEl.value; + + put(`/categories/${ajaxify.data.cid}/follow`, { actor }) + .then(ajaxify.refresh) + .catch(error); + + break; + } + + case 'unfollow': { + const actor = subselector.getAttribute('data-actor'); + + del(`/categories/${ajaxify.data.cid}/follow`, { actor }) + .then(ajaxify.refresh) + .catch(error); + + break; + } + } + }); +} + diff --git a/src/activitypub/helpers.js b/src/activitypub/helpers.js index 1dec3302b8..2a586d733a 100644 --- a/src/activitypub/helpers.js +++ b/src/activitypub/helpers.js @@ -112,6 +112,7 @@ Helpers.resolveLocalId = async (input) => { activityData = { activity, data }; } + // https://bb.devnull.land/cid/2#activity/follow/activitypub@community.nodebb.org│ switch (prefix) { case 'uid': return { type: 'user', id: value, ...activityData }; @@ -119,6 +120,7 @@ Helpers.resolveLocalId = async (input) => { case 'post': return { type: 'post', id: value, ...activityData }; + case 'cid': case 'category': return { type: 'category', id: value, ...activityData }; diff --git a/src/activitypub/inbox.js b/src/activitypub/inbox.js index 1217a49511..78656ef053 100644 --- a/src/activitypub/inbox.js +++ b/src/activitypub/inbox.js @@ -250,9 +250,9 @@ inbox.accept = async (req) => { const { actor, object } = req.body; const { type } = object; - const { type: localType, id: uid } = await helpers.resolveLocalId(object.actor); - if (localType !== 'user' || !uid) { - throw new Error('[[error:invalid-uid]]'); + const { type: localType, id } = await helpers.resolveLocalId(object.actor); + if (!['user', 'category'].includes(localType)) { + throw new Error('[[error:invalid-data]]'); } const assertion = await activitypub.actors.assert(actor); @@ -261,18 +261,30 @@ inbox.accept = async (req) => { } if (type === 'Follow') { - if (!await db.isSortedSetMember(`followRequests:${uid}`, actor)) { - if (await db.isSortedSetMember(`followingRemote:${uid}`, actor)) return; // already following - return reject('Accept', req.body, actor); // not following, not requested, so reject to hopefully stop retries + if (localType === 'user') { + if (!await db.isSortedSetMember(`followRequests:uid.${id}`, actor)) { + if (await db.isSortedSetMember(`followingRemote:${id}`, actor)) return; // already following + return reject('Accept', req.body, actor); // not following, not requested, so reject to hopefully stop retries + } + const now = Date.now(); + await Promise.all([ + db.sortedSetRemove(`followRequests:uid.${id}`, actor), + db.sortedSetAdd(`followingRemote:${id}`, now, actor), + db.sortedSetAdd(`followersRemote:${actor}`, now, id), // for followers backreference and notes assertion checking + ]); + const followingRemoteCount = await db.sortedSetCard(`followingRemote:${id}`); + await user.setUserField(id, 'followingRemoteCount', followingRemoteCount); + } else if (localType === 'category') { + if (!await db.isSortedSetMember(`followRequests:cid.${id}`, actor)) { + if (await db.isSortedSetMember(`cid:${id}:following`, actor)) return; // already following + return reject('Accept', req.body, actor); // not following, not requested, so reject to hopefully stop retries + } + const now = Date.now(); + await Promise.all([ + db.sortedSetRemove(`followRequests:cid.${id}`, actor), + db.sortedSetAdd(`cid:${id}:following`, now, actor), + ]); } - const now = Date.now(); - await Promise.all([ - db.sortedSetRemove(`followRequests:${uid}`, actor), - db.sortedSetAdd(`followingRemote:${uid}`, now, actor), - db.sortedSetAdd(`followersRemote:${actor}`, now, uid), // for followers backreference and notes assertion checking - ]); - const followingRemoteCount = await db.sortedSetCard(`followingRemote:${uid}`); - await user.setUserField(uid, 'followingRemoteCount', followingRemoteCount); } }; diff --git a/src/api/activitypub.js b/src/api/activitypub.js index 463c57a4bb..cf8e0d4d3c 100644 --- a/src/api/activitypub.js +++ b/src/api/activitypub.js @@ -12,6 +12,7 @@ const nconf = require('nconf'); const winston = require('winston'); const db = require('../database'); +const user = require('../user'); const meta = require('../meta'); const privileges = require('../privileges'); const activitypub = require('../activitypub'); @@ -31,43 +32,63 @@ function enabledCheck(next) { }; } -activitypubApi.follow = enabledCheck(async (caller, { uid } = {}) => { - const result = await activitypub.helpers.query(uid); - if (!result) { +activitypubApi.follow = enabledCheck(async (caller, { type, id, actor } = {}) => { + // Privilege checks should be done upstream + const assertion = await activitypub.actors.assert(actor); + if (!assertion) { throw new Error('[[error:activitypub.invalid-id]]'); } - await activitypub.send('uid', caller.uid, [result.actorUri], { - id: `${nconf.get('url')}/uid/${caller.uid}#activity/follow/${result.username}@${result.hostname}`, + actor = actor.includes('@') ? await user.getUidByUserslug(actor) : actor; + const handle = await user.getUserField(actor, 'username'); + + await activitypub.send(type, id, [actor], { + id: `${nconf.get('url')}/${type}/${id}#activity/follow/${handle}`, type: 'Follow', - object: result.actorUri, + object: actor, }); - await db.sortedSetAdd(`followRequests:${caller.uid}`, Date.now(), result.actorUri); + await db.sortedSetAdd(`followRequests:${type}.${id}`, Date.now(), actor); }); // should be .undo.follow -activitypubApi.unfollow = enabledCheck(async (caller, { uid }) => { - const result = await activitypub.helpers.query(uid); - if (!result) { +activitypubApi.unfollow = enabledCheck(async (caller, { type, id, actor }) => { + const assertion = await activitypub.actors.assert(actor); + if (!assertion) { throw new Error('[[error:activitypub.invalid-id]]'); } - await activitypub.send('uid', caller.uid, [result.actorUri], { - id: `${nconf.get('url')}/uid/${caller.uid}#activity/undo:follow/${result.username}@${result.hostname}`, + actor = actor.includes('@') ? await user.getUidByUserslug(actor) : actor; + const handle = await user.getUserField(actor, 'username'); + + const object = { + id: `${nconf.get('url')}/${type}/${id}#activity/follow/${handle}`, + type: 'Follow', + object: actor, + }; + if (type === 'uid') { + object.actor = `${nconf.get('url')}/uid/${id}`; + } else if (type === 'cid') { + object.actor = `${nconf.get('url')}/category/${id}`; + } + + await activitypub.send(type, id, [actor], { + id: `${nconf.get('url')}/${type}/${id}#activity/undo:follow/${handle}`, type: 'Undo', - object: { - id: `${nconf.get('url')}/uid/${caller.uid}#activity/follow/${result.username}@${result.hostname}`, - type: 'Follow', - actor: `${nconf.get('url')}/uid/${caller.uid}`, - object: result.actorUri, - }, + object, }); - await Promise.all([ - db.sortedSetRemove(`followingRemote:${caller.uid}`, result.actorUri), - db.decrObjectField(`user:${caller.uid}`, 'followingRemoteCount'), - ]); + if (type === 'uid') { + await Promise.all([ + db.sortedSetRemove(`followingRemote:${id}`, actor), + db.decrObjectField(`user:${id}`, 'followingRemoteCount'), + ]); + } else if (type === 'cid') { + await Promise.all([ + db.sortedSetRemove(`cid:${id}:following`, actor), + db.sortedSetRemove(`followRequests:cid.${id}`, actor), + ]); + } }); activitypubApi.create = {}; diff --git a/src/controllers/admin/categories.js b/src/controllers/admin/categories.js index 75e85e0983..99bc2cffd2 100644 --- a/src/controllers/admin/categories.js +++ b/src/controllers/admin/categories.js @@ -2,6 +2,7 @@ const _ = require('lodash'); const nconf = require('nconf'); +const db = require('../../database'); const categories = require('../../categories'); const analytics = require('../../analytics'); const plugins = require('../../plugins'); @@ -145,3 +146,26 @@ categoriesController.getAnalytics = async function (req, res) { selectedCategory: selectedData.selectedCategory, }); }; + +categoriesController.getFederation = async function (req, res) { + const cid = req.params.category_id; + const [_following, pending, name, { selectedCategory }] = await Promise.all([ + db.getSortedSetMembers(`cid:${cid}:following`), + db.getSortedSetMembers(`followRequests:cid.${cid}`), + categories.getCategoryField(cid, 'name'), + helpers.getSelectedCategory(cid), + ]); + + const following = [..._following, ...pending].map(entry => ({ + id: entry, + approved: !pending.includes(entry), + })); + + res.render('admin/manage/category-federation', { + cid: cid, + enabled: meta.config.activitypubEnabled, + name, + selectedCategory, + following, + }); +}; diff --git a/src/controllers/write/categories.js b/src/controllers/write/categories.js index bb4ec84090..3a0b6ae0f7 100644 --- a/src/controllers/write/categories.js +++ b/src/controllers/write/categories.js @@ -105,3 +105,29 @@ Categories.setModerator = async (req, res) => { const privilegeSet = await api.categories.getPrivileges(req, { cid: req.params.cid }); helpers.formatApiResponse(200, res, privilegeSet); }; + +Categories.follow = async (req, res) => { + const { actor } = req.body; + const id = req.params.cid; + + await api.activitypub.follow(req, { + type: 'cid', + id, + actor, + }); + + res.sendStatus(200); +}; + +Categories.unfollow = async (req, res) => { + const { actor } = req.body; + const id = req.params.cid; + + await api.activitypub.unfollow(req, { + type: 'cid', + id, + actor, + }); + + res.sendStatus(200); +}; diff --git a/src/controllers/write/users.js b/src/controllers/write/users.js index 4c47c116a6..b884ef93fb 100644 --- a/src/controllers/write/users.js +++ b/src/controllers/write/users.js @@ -93,15 +93,30 @@ Users.changePassword = async (req, res) => { Users.follow = async (req, res) => { const remote = String(req.params.uid).includes('@'); - const controller = remote ? api.activitypub.follow : api.users.follow; - await controller(req, req.params); + if (remote) { + await api.activitypub.follow(req, { + type: 'uid', + id: req.uid, + actor: req.params.uid, + }); + } else { + await api.users.follow(req, req.params); + } + helpers.formatApiResponse(200, res); }; Users.unfollow = async (req, res) => { const remote = String(req.params.uid).includes('@'); - const controller = remote ? api.activitypub.unfollow : api.users.unfollow; - await controller(req, req.params); + if (remote) { + await api.activitypub.unfollow(req, { + type: 'uid', + id: req.uid, + actor: req.params.uid, + }); + } else { + await api.users.unfollow(req, req.params); + } helpers.formatApiResponse(200, res); }; diff --git a/src/routes/admin.js b/src/routes/admin.js index 01e228dabe..c4b2d92e3e 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -16,6 +16,7 @@ module.exports = function (app, name, middleware, controllers) { helpers.setupAdminPageRoute(app, `/${name}/manage/categories`, middlewares, controllers.admin.categories.getAll); helpers.setupAdminPageRoute(app, `/${name}/manage/categories/:category_id`, middlewares, controllers.admin.categories.get); helpers.setupAdminPageRoute(app, `/${name}/manage/categories/:category_id/analytics`, middlewares, controllers.admin.categories.getAnalytics); + helpers.setupAdminPageRoute(app, `/${name}/manage/categories/:category_id/federation`, middlewares, controllers.admin.categories.getFederation); helpers.setupAdminPageRoute(app, `/${name}/manage/privileges/:cid?`, middlewares, controllers.admin.privileges.get); helpers.setupAdminPageRoute(app, `/${name}/manage/tags`, middlewares, controllers.admin.tags.get); diff --git a/src/routes/write/categories.js b/src/routes/write/categories.js index 0f7aa1c473..0ce47a5073 100644 --- a/src/routes/write/categories.js +++ b/src/routes/write/categories.js @@ -28,8 +28,11 @@ module.exports = function () { setupApiRoute(router, 'put', '/:cid/privileges/:privilege', [...middlewares, middleware.checkRequired.bind(null, ['member'])], controllers.write.categories.setPrivilege); setupApiRoute(router, 'delete', '/:cid/privileges/:privilege', [...middlewares, middleware.checkRequired.bind(null, ['member'])], controllers.write.categories.setPrivilege); - setupApiRoute(router, 'put', '/:cid/moderator/:uid', [...middlewares], controllers.write.categories.setModerator); - setupApiRoute(router, 'delete', '/:cid/moderator/:uid', [...middlewares], controllers.write.categories.setModerator); + setupApiRoute(router, 'put', '/:cid/moderator/:uid', [...middlewares, middleware.assert.category], controllers.write.categories.setModerator); + setupApiRoute(router, 'delete', '/:cid/moderator/:uid', [...middlewares, middleware.assert.category], controllers.write.categories.setModerator); + + setupApiRoute(router, 'put', '/:cid/follow', [...middlewares, middleware.admin.checkPrivileges, middleware.assert.category], controllers.write.categories.follow); + setupApiRoute(router, 'delete', '/:cid/follow', [...middlewares, middleware.admin.checkPrivileges, middleware.assert.category], controllers.write.categories.unfollow); return router; }; diff --git a/src/views/admin/manage/category-federation.tpl b/src/views/admin/manage/category-federation.tpl new file mode 100644 index 0000000000..961dda532c --- /dev/null +++ b/src/views/admin/manage/category-federation.tpl @@ -0,0 +1,67 @@ + +
+ +
+
+

[[admin/manage/categories:federation.title, {name}]]

+ +
+
+ + {{{ if !enabled }}} +
+

[[admin/manage/categories:federation.disabled]]

+ [[admin/manage/categories:federation.disabled-cta]] +
+ {{{ else }}} +
+
+
+
+
+
[[admin/manage/categories:federation.syncing-header]]
+

[[admin/manage/categories:federation.syncing-intro]]

+

[[admin/manage/categories:federation.syncing-caveat]]

+ + {{{ if !following.length }}} +
[[admin/manage/categories:federation.syncing-none]]
+ {{{ else }}} + + + + + + + + + {{{ each following }}} + + + + + {{{ end }}} + +
[[admin/manage/categories:federation.syncing-actorUri]]
+
{./id}
+ {{{ if !./approved }}} + Pending + {{{ end }}} +
+ +
+ {{{ end }}} + +
+ +
+ + +
+
+
+
+
+
+
+ {{{ end }}} +
\ No newline at end of file diff --git a/src/views/admin/manage/category.tpl b/src/views/admin/manage/category.tpl index 5d35b69c5a..bf8272d390 100644 --- a/src/views/admin/manage/category.tpl +++ b/src/views/admin/manage/category.tpl @@ -190,6 +190,8 @@ [[admin/manage/categories:privileges]] + [[admin/manage/categories:federation]] + [[admin/manage/categories:view-category]] diff --git a/src/views/admin/partials/categories/category-rows.tpl b/src/views/admin/partials/categories/category-rows.tpl index 7a56bdf16c..57b3676093 100644 --- a/src/views/admin/partials/categories/category-rows.tpl +++ b/src/views/admin/partials/categories/category-rows.tpl @@ -39,6 +39,7 @@
  • [[admin/manage/categories:analytics]]
  • [[admin/manage/categories:privileges]]
  • +
  • [[admin/manage/categories:federation]]
  • [[admin/manage/categories:set-order]]