refactor: stub routes for category synchronization, refactor remote follow logic to allow categories to conduct follows as well

This commit is contained in:
Julian Lam
2024-04-15 14:40:26 -04:00
parent b7ff7be28f
commit 59a9dd8436
13 changed files with 277 additions and 42 deletions

View File

@@ -39,6 +39,7 @@
"disable": "Disable", "disable": "Disable",
"edit": "Edit", "edit": "Edit",
"analytics": "Analytics", "analytics": "Analytics",
"federation": "Federation",
"view-category": "View category", "view-category": "View category",
"set-order": "Set order", "set-order": "Set order",
@@ -78,6 +79,18 @@
"analytics.topics-daily": "<strong>Figure 3</strong> &ndash; Daily topics created in this category</small>", "analytics.topics-daily": "<strong>Figure 3</strong> &ndash; Daily topics created in this category</small>",
"analytics.posts-daily": "<strong>Figure 4</strong> &ndash; Daily posts made in this category</small>", "analytics.posts-daily": "<strong>Figure 4</strong> &ndash; Daily posts made in this category</small>",
"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 &rarr;",
"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.created": "Created",
"alert.create-success": "Category successfully created!", "alert.create-success": "Category successfully created!",
"alert.none-active": "You have no active categories.", "alert.none-active": "You have no active categories.",

View File

@@ -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;
}
}
});
}

View File

@@ -112,6 +112,7 @@ Helpers.resolveLocalId = async (input) => {
activityData = { activity, data }; activityData = { activity, data };
} }
// https://bb.devnull.land/cid/2#activity/follow/activitypub@community.nodebb.org│
switch (prefix) { switch (prefix) {
case 'uid': case 'uid':
return { type: 'user', id: value, ...activityData }; return { type: 'user', id: value, ...activityData };
@@ -119,6 +120,7 @@ Helpers.resolveLocalId = async (input) => {
case 'post': case 'post':
return { type: 'post', id: value, ...activityData }; return { type: 'post', id: value, ...activityData };
case 'cid':
case 'category': case 'category':
return { type: 'category', id: value, ...activityData }; return { type: 'category', id: value, ...activityData };

View File

@@ -250,9 +250,9 @@ inbox.accept = async (req) => {
const { actor, object } = req.body; const { actor, object } = req.body;
const { type } = object; const { type } = object;
const { type: localType, id: uid } = await helpers.resolveLocalId(object.actor); const { type: localType, id } = await helpers.resolveLocalId(object.actor);
if (localType !== 'user' || !uid) { if (!['user', 'category'].includes(localType)) {
throw new Error('[[error:invalid-uid]]'); throw new Error('[[error:invalid-data]]');
} }
const assertion = await activitypub.actors.assert(actor); const assertion = await activitypub.actors.assert(actor);
@@ -261,18 +261,30 @@ inbox.accept = async (req) => {
} }
if (type === 'Follow') { if (type === 'Follow') {
if (!await db.isSortedSetMember(`followRequests:${uid}`, actor)) { if (localType === 'user') {
if (await db.isSortedSetMember(`followingRemote:${uid}`, actor)) return; // already following if (!await db.isSortedSetMember(`followRequests:uid.${id}`, actor)) {
return reject('Accept', req.body, actor); // not following, not requested, so reject to hopefully stop retries 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);
} }
}; };

View File

@@ -12,6 +12,7 @@ const nconf = require('nconf');
const winston = require('winston'); const winston = require('winston');
const db = require('../database'); const db = require('../database');
const user = require('../user');
const meta = require('../meta'); const meta = require('../meta');
const privileges = require('../privileges'); const privileges = require('../privileges');
const activitypub = require('../activitypub'); const activitypub = require('../activitypub');
@@ -31,43 +32,63 @@ function enabledCheck(next) {
}; };
} }
activitypubApi.follow = enabledCheck(async (caller, { uid } = {}) => { activitypubApi.follow = enabledCheck(async (caller, { type, id, actor } = {}) => {
const result = await activitypub.helpers.query(uid); // Privilege checks should be done upstream
if (!result) { const assertion = await activitypub.actors.assert(actor);
if (!assertion) {
throw new Error('[[error:activitypub.invalid-id]]'); throw new Error('[[error:activitypub.invalid-id]]');
} }
await activitypub.send('uid', caller.uid, [result.actorUri], { actor = actor.includes('@') ? await user.getUidByUserslug(actor) : actor;
id: `${nconf.get('url')}/uid/${caller.uid}#activity/follow/${result.username}@${result.hostname}`, const handle = await user.getUserField(actor, 'username');
await activitypub.send(type, id, [actor], {
id: `${nconf.get('url')}/${type}/${id}#activity/follow/${handle}`,
type: 'Follow', 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 // should be .undo.follow
activitypubApi.unfollow = enabledCheck(async (caller, { uid }) => { activitypubApi.unfollow = enabledCheck(async (caller, { type, id, actor }) => {
const result = await activitypub.helpers.query(uid); const assertion = await activitypub.actors.assert(actor);
if (!result) { if (!assertion) {
throw new Error('[[error:activitypub.invalid-id]]'); throw new Error('[[error:activitypub.invalid-id]]');
} }
await activitypub.send('uid', caller.uid, [result.actorUri], { actor = actor.includes('@') ? await user.getUidByUserslug(actor) : actor;
id: `${nconf.get('url')}/uid/${caller.uid}#activity/undo:follow/${result.username}@${result.hostname}`, 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', type: 'Undo',
object: { 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,
},
}); });
await Promise.all([ if (type === 'uid') {
db.sortedSetRemove(`followingRemote:${caller.uid}`, result.actorUri), await Promise.all([
db.decrObjectField(`user:${caller.uid}`, 'followingRemoteCount'), 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 = {}; activitypubApi.create = {};

View File

@@ -2,6 +2,7 @@
const _ = require('lodash'); const _ = require('lodash');
const nconf = require('nconf'); const nconf = require('nconf');
const db = require('../../database');
const categories = require('../../categories'); const categories = require('../../categories');
const analytics = require('../../analytics'); const analytics = require('../../analytics');
const plugins = require('../../plugins'); const plugins = require('../../plugins');
@@ -145,3 +146,26 @@ categoriesController.getAnalytics = async function (req, res) {
selectedCategory: selectedData.selectedCategory, 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,
});
};

View File

@@ -105,3 +105,29 @@ Categories.setModerator = async (req, res) => {
const privilegeSet = await api.categories.getPrivileges(req, { cid: req.params.cid }); const privilegeSet = await api.categories.getPrivileges(req, { cid: req.params.cid });
helpers.formatApiResponse(200, res, privilegeSet); 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);
};

View File

@@ -93,15 +93,30 @@ Users.changePassword = async (req, res) => {
Users.follow = async (req, res) => { Users.follow = async (req, res) => {
const remote = String(req.params.uid).includes('@'); const remote = String(req.params.uid).includes('@');
const controller = remote ? api.activitypub.follow : api.users.follow; if (remote) {
await controller(req, req.params); 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); helpers.formatApiResponse(200, res);
}; };
Users.unfollow = async (req, res) => { Users.unfollow = async (req, res) => {
const remote = String(req.params.uid).includes('@'); const remote = String(req.params.uid).includes('@');
const controller = remote ? api.activitypub.unfollow : api.users.unfollow; if (remote) {
await controller(req, req.params); 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); helpers.formatApiResponse(200, res);
}; };

View File

@@ -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`, 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`, 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/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/privileges/:cid?`, middlewares, controllers.admin.privileges.get);
helpers.setupAdminPageRoute(app, `/${name}/manage/tags`, middlewares, controllers.admin.tags.get); helpers.setupAdminPageRoute(app, `/${name}/manage/tags`, middlewares, controllers.admin.tags.get);

View File

@@ -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, '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, '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, 'put', '/:cid/moderator/:uid', [...middlewares, middleware.assert.category], controllers.write.categories.setModerator);
setupApiRoute(router, 'delete', '/:cid/moderator/:uid', [...middlewares], 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; return router;
}; };

View File

@@ -0,0 +1,67 @@
<div class="px-lg-4">
<div class="row border-bottom py-2 m-0 mb-3 sticky-top acp-page-main-header align-items-center">
<div class="col-12 px-0 mb-1 mb-md-0 d-flex justify-content-between align-items-center">
<h4 class="fw-bold tracking-tight mb-0">[[admin/manage/categories:federation.title, {name}]]</h4>
<!-- IMPORT admin/partials/category/selector-dropdown-right.tpl -->
</div>
</div>
{{{ if !enabled }}}
<div class="alert alert-warning">
<p>[[admin/manage/categories:federation.disabled]]</p>
<a class="btn btn-primary" href="{config.relative_path}/admin/settings/activitypub">[[admin/manage/categories:federation.disabled-cta]]</a>
</div>
{{{ else }}}
<div class="acp-page-container">
<div class="row settings m-0">
<div class="col-12 col-md-8 px-0 mb-4" tabindex="0">
<div id="site-settings" class="mb-4">
<form role="form">
<h5 class="fw-bold settings-header">[[admin/manage/categories:federation.syncing-header]]</h5>
<p>[[admin/manage/categories:federation.syncing-intro]]</p>
<p class="form-text">[[admin/manage/categories:federation.syncing-caveat]]</p>
{{{ if !following.length }}}
<div class="alert alert-info">[[admin/manage/categories:federation.syncing-none]]</div>
{{{ else }}}
<table class="table">
<thead>
<tr>
<th>[[admin/manage/categories:federation.syncing-actorUri]]</th>
<th></th>
</tr>
</thead>
<tbody>
{{{ each following }}}
<tr>
<td>
<pre class="mb-0 mt-1">{./id}</pre>
{{{ if !./approved }}}
<span class="form-text text-warning">Pending</span>
{{{ end }}}
</td>
<td>
<button type="button" data-action="unfollow" data-actor="{./id}" class="btn btn-sm btn-danger">[[admin/manage/categories:federation.syncing-unfollow]]</button>
</td>
</tr>
{{{ end }}}
</tbody>
</table>
{{{ end }}}
<div class="mb-3">
<label class="form-label" for="syncing.add">[[admin/manage/categories:federation.syncing-add]]</label>
<div class="input-group">
<input id="syncing.add" type="url" class="form-control" />
<button data-action="follow" type="button" class="btn btn-primary">[[admin/manage/categories:federation.syncing-follow]]</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
{{{ end }}}
</div>

View File

@@ -190,6 +190,8 @@
<i class="fa fa-fw fa-lock text-primary"></i> [[admin/manage/categories:privileges]] <i class="fa fa-fw fa-lock text-primary"></i> [[admin/manage/categories:privileges]]
</a> </a>
<a class="btn-ghost-sm justify-content-start" href="{config.relative_path}/admin/manage/categories/{category.cid}/federation"><i class="fa fa-fw fa-globe text-primary"></i> [[admin/manage/categories:federation]]</a>
<a href="{config.relative_path}/category/{category.cid}" class="btn-ghost-sm justify-content-start"> <a href="{config.relative_path}/category/{category.cid}" class="btn-ghost-sm justify-content-start">
<i class="fa fa-fw fa-eye text-primary"></i> [[admin/manage/categories:view-category]] <i class="fa fa-fw fa-eye text-primary"></i> [[admin/manage/categories:view-category]]
</a> </a>

View File

@@ -39,6 +39,7 @@
<li><a class="dropdown-item rounded-1" href="./categories/{categories.cid}/analytics" role="menuitem">[[admin/manage/categories:analytics]]</a></li> <li><a class="dropdown-item rounded-1" href="./categories/{categories.cid}/analytics" role="menuitem">[[admin/manage/categories:analytics]]</a></li>
<li><a class="dropdown-item rounded-1" href="{config.relative_path}/admin/manage/privileges/{categories.cid}" role="menuitem">[[admin/manage/categories:privileges]]</a></li> <li><a class="dropdown-item rounded-1" href="{config.relative_path}/admin/manage/privileges/{categories.cid}" role="menuitem">[[admin/manage/categories:privileges]]</a></li>
<li><a class="dropdown-item rounded-1" href="./categories/{categories.cid}/federation" role="menuitem">[[admin/manage/categories:federation]]</a></li>
<li><a href="#" class="set-order dropdown-item rounded-1" data-cid="{categories.cid}" data-order="{categories.order}" role="menuitem">[[admin/manage/categories:set-order]]</a></li> <li><a href="#" class="set-order dropdown-item rounded-1" data-cid="{categories.cid}" data-order="{categories.order}" role="menuitem">[[admin/manage/categories:set-order]]</a></li>
<li class="dropdown-divider"></li> <li class="dropdown-divider"></li>
<li> <li>