Compare commits

...

8 Commits

Author SHA1 Message Date
Julian Lam
8a4f0def72 fix: parseAndTranslate bug 2025-08-27 13:52:45 -04:00
Julian Lam
e6c43e36f1 feat: send local posts out to established relays 2025-08-27 12:33:27 -04:00
Julian Lam
608c1a4df5 fix: internationalize relay states 2025-08-27 12:20:36 -04:00
Julian Lam
0311bba67e fix: minor fixes for yukimochi/Activity-Relay compatibility 2025-08-26 14:11:51 -04:00
Julian Lam
6a38f8f666 fix: inbox.announce to not reject activities from relays 2025-08-26 13:53:51 -04:00
Julian Lam
40040a4642 feat: relay handshake logic, handle Follow/Accept, send back Accept. 2025-08-26 13:48:26 -04:00
Julian Lam
3d4863d388 fix: handle webfinger responses with subject missing scheme 2025-08-26 11:53:27 -04:00
Julian Lam
cc8a338d9a feat: adding and removing relays from AP settings page in ACP 2025-08-26 10:50:27 -04:00
12 changed files with 305 additions and 18 deletions

View File

@@ -28,6 +28,17 @@
"rules.value": "Value",
"rules.cid": "Category",
"relays": "Relays",
"relays.intro": "A relay improves discovery of content to and from your NodeBB. Subscribing to a relay means content received by the relay is forwarded here, and content posted here is syndicated outward by the relay.",
"relays.warning": "Note: Relays can send larges amounts of traffic in, and may increase storage and processing costs.",
"relays.litepub": "NodeBB follows the LitePub-style relay standard. The URL you enter here should end with <code>/actor</code>.",
"relays.add": "Add New Relay",
"relays.relay": "Relay",
"relays.state": "State",
"relays.state-0": "Pending",
"relays.state-1": "Receiving only",
"relays.state-2": "Active",
"server-filtering": "Filtering",
"count": "This NodeBB is currently aware of <strong>%1</strong> server(s)",
"server.filter-help": "Specify servers you would like to bar from federating with your NodeBB. Alternatively, you may opt to selectively <em>allow</em> federation with specific servers, instead. Both options are supported, although they are mutually exclusive.",

View File

@@ -5,7 +5,8 @@ define('admin/settings/activitypub', [
'bootbox',
'categorySelector',
'api',
], function (Benchpress, bootbox, categorySelector, api) {
'alerts',
], function (Benchpress, bootbox, categorySelector, api, alerts) {
const Module = {};
Module.init = function () {
@@ -29,7 +30,34 @@ define('admin/settings/activitypub', [
if (tbodyEl) {
tbodyEl.innerHTML = html;
}
});
}).catch(alerts.error);
}
}
}
});
}
const relaysEl = document.getElementById('relays');
if (relaysEl) {
relaysEl.addEventListener('click', (e) => {
const subselector = e.target.closest('[data-action]');
if (subselector) {
const action = subselector.getAttribute('data-action');
switch (action) {
case 'relays.add': {
Module.throwRelaysModal();
break;
}
case 'relays.remove': {
const url = subselector.closest('tr').getAttribute('data-url');
api.del(`/admin/activitypub/relays/${encodeURIComponent(url)}`, {}).then(async (data) => {
const html = await app.parseAndTranslate('admin/settings/activitypub', 'relays', { relays: data });
const tbodyEl = document.querySelector('#relays tbody');
if (tbodyEl) {
$(tbodyEl).html(html);
}
}).catch(alerts.error);
}
}
}
@@ -49,7 +77,7 @@ define('admin/settings/activitypub', [
if (tbodyEl) {
tbodyEl.innerHTML = html;
}
});
}).catch(alerts.error);
};
const modal = bootbox.dialog({
title: '[[admin/settings/activitypub:rules.add]]',
@@ -63,6 +91,10 @@ define('admin/settings/activitypub', [
},
});
modal.on('shown.bs.modal', function () {
modal.find('input').focus();
});
// category switcher
categorySelector.init(modal.find('[component="category-selector"]'), {
onSelect: function (selectedCategory) {
@@ -75,5 +107,37 @@ define('admin/settings/activitypub', [
});
};
Module.throwRelaysModal = function () {
Benchpress.render('admin/partials/activitypub/relays', {}).then(function (html) {
const submit = function () {
const formEl = modal.find('form').get(0);
const payload = Object.fromEntries(new FormData(formEl));
api.post('/admin/activitypub/relays', payload).then(async (data) => {
const html = await app.parseAndTranslate('admin/settings/activitypub', 'relays', { relays: data });
const tbodyEl = document.querySelector('#relays tbody');
if (tbodyEl) {
$(tbodyEl).html(html);
}
}).catch(alerts.error);
};
const modal = bootbox.dialog({
title: '[[admin/settings/activitypub:relays.add]]',
message: html,
buttons: {
save: {
label: '[[global:save]]',
className: 'btn-primary',
callback: submit,
},
},
});
modal.on('shown.bs.modal', function () {
modal.find('input').focus();
});
});
};
return Module;
});

View File

@@ -4,6 +4,7 @@ const nconf = require('nconf');
const posts = require('../posts');
const utils = require('../utils');
const { default: PG } = require('pg');
const activitypub = module.parent.exports;
const Feps = module.exports;
@@ -18,21 +19,29 @@ Feps.announce = async function announce(id, activity) {
return;
}
let relays = await activitypub.relays.list();
relays = relays.reduce((memo, { state, url }) => {
if (state === 2) {
memo.push(url);
}
return memo;
}, []);
const followers = await activitypub.notes.getCategoryFollowers(cid);
if (!followers.length) {
const targets = relays.concat(followers);
if (!targets.length) {
return;
}
const { actor } = activity;
if (actor && !actor.startsWith(nconf.get('url'))) {
followers.unshift(actor);
targets.unshift(actor);
}
const now = Date.now();
if (activity.type === 'Create') {
const isMain = await posts.isMain(localId || id);
if (isMain) {
activitypub.helpers.log(`[activitypub/inbox.announce(1b12)] Announcing plain object (${activity.id}) to followers of cid ${cid}`);
await activitypub.send('cid', cid, followers, {
activitypub.helpers.log(`[activitypub/inbox.announce(1b12)] Announcing plain object (${activity.id}) to followers of cid ${cid} and ${relays.length} relays`);
await activitypub.send('cid', cid, targets, {
id: `${nconf.get('url')}/post/${encodeURIComponent(id)}#activity/announce/${now}`,
type: 'Announce',
actor: `${nconf.get('url')}/category/${cid}`,
@@ -43,8 +52,8 @@ Feps.announce = async function announce(id, activity) {
}
}
activitypub.helpers.log(`[activitypub/inbox.announce(1b12)] Announcing ${activity.type} (${activity.id}) to followers of cid ${cid}`);
await activitypub.send('cid', cid, followers, {
activitypub.helpers.log(`[activitypub/inbox.announce(1b12)] Announcing ${activity.type} (${activity.id}) to followers of cid ${cid} and ${relays.length} relays`);
await activitypub.send('cid', cid, targets, {
id: `${nconf.get('url')}/post/${encodeURIComponent(id)}#activity/announce/${now + 1}`,
type: 'Announce',
actor: `${nconf.get('url')}/category/${cid}`,

View File

@@ -38,7 +38,7 @@ Helpers._test = (method, args) => {
}, 2500);
};
// process.nextTick(() => {
// Helpers._test(activitypub.notes.assert, [1, `https://`]);
// Helpers._test(activitypub.relays.add, ['https://relay.publicsquare.global/actor']);
// });
let _lastLog;
Helpers.log = (message) => {
@@ -129,9 +129,12 @@ Helpers.query = async (id) => {
({ href: actorUri } = actorUri);
}
const { subject, publicKey } = body;
let { subject, publicKey } = body;
// Fix missing scheme
if (!subject.startsWith('acct:') && !subject.startsWith('did:')) {
subject = `acct:${subject}`;
}
const payload = { subject, username, hostname, actorUri, publicKey };
const claimedId = new URL(subject).pathname;
webfingerCache.set(claimedId, payload);
if (claimedId !== id) {
@@ -193,6 +196,9 @@ Helpers.resolveLocalId = async (input) => {
case 'message':
return { type: 'message', id: value, ...activityData };
case 'actor':
return { type: 'application', id: null };
}
return { type: null, id: null, ...activityData };

View File

@@ -288,6 +288,9 @@ inbox.announce = async (req) => {
cid = actor;
}
// Received via relay?
const fromRelay = await activitypub.relays.is(actor);
switch(true) {
case object.type === 'Like': {
const id = object.object.id || object.object;
@@ -333,7 +336,7 @@ inbox.announce = async (req) => {
socketHelpers.sendNotificationToPostOwner(pid, actor, 'announce', 'notifications:activitypub.announce');
} else { // Remote object
// Follower check
if (!cid) {
if (!fromRelay && !cid) {
const { followers } = await activitypub.actors.getLocalFollowCounts(actor);
if (!followers) {
winston.verbose(`[activitypub/inbox.announce] Rejecting ${object.id} via ${actor} due to no followers`);
@@ -367,9 +370,12 @@ inbox.announce = async (req) => {
inbox.follow = async (req) => {
const { actor, object, id: followId } = req.body;
// Sanity checks
const { type, id } = await helpers.resolveLocalId(object.id);
if (!['category', 'user'].includes(type)) {
if (type === 'application') {
return activitypub.relays.handshake(req.body);
} else if (!['category', 'user'].includes(type)) {
throw new Error('[[error:activitypub.invalid-id]]');
}
@@ -454,7 +460,9 @@ inbox.accept = async (req) => {
const { type } = object;
const { type: localType, id } = await helpers.resolveLocalId(object.actor);
if (!['user', 'category'].includes(localType)) {
if (object.id === `${nconf.get('url')}/actor`) {
return activitypub.relays.handshake(req.body);
} else if (!['user', 'category'].includes(localType)) {
throw new Error('[[error:invalid-data]]');
}

View File

@@ -46,7 +46,7 @@ ActivityPub._constants = Object.freeze({
],
acceptableActorTypes: new Set(['Application', 'Organization', 'Person', 'Service']),
acceptableGroupTypes: new Set(['Group']),
requiredActorProps: ['inbox', 'outbox'],
requiredActorProps: ['inbox'],
acceptedProtocols: ['https', ...(process.env.CI === 'true' ? ['http'] : [])],
acceptable: {
customFields: new Set(['PropertyValue', 'Link', 'Note']),
@@ -65,6 +65,7 @@ ActivityPub.actors = require('./actors');
ActivityPub.instances = require('./instances');
ActivityPub.feps = require('./feps');
ActivityPub.rules = require('./rules');
ActivityPub.relays = require('./relays');
ActivityPub.startJobs = () => {
ActivityPub.helpers.log('[activitypub/jobs] Registering jobs.');

126
src/activitypub/relays.js Normal file
View File

@@ -0,0 +1,126 @@
'use strict';
const nconf = require('nconf');
const db = require('../database');
const activitypub = module.parent.exports;
const Relays = module.exports;
Relays.is = async (actor) => {
return db.isSortedSetMember('relays:createtime', actor);
};
Relays.list = async () => {
let relays = await db.getSortedSetMembersWithScores('relays:state');
relays = relays.reduce((memo, { value, score }) => {
let label = '[[admin/settings/activitypub:relays.state-0]]';
switch(score) {
case 1: {
label = '[[admin/settings/activitypub:relays.state-1]]';
break;
}
case 2: {
label = '[[admin/settings/activitypub:relays.state-2]]';
break;
}
}
memo.push({
url: value,
state: score,
label,
});
return memo;
}, []);
return relays;
};
Relays.add = async (url) => {
const now = Date.now();
await activitypub.send('uid', 0, url, {
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://pleroma.example/schemas/litepub-0.1.jsonld',
],
id: `${nconf.get('url')}/actor#activity/follow/${encodeURIComponent(url)}/${now}`,
type: 'Follow',
to: [url],
object: url,
state: 'pending',
});
await Promise.all([
db.sortedSetAdd('relays:createtime', now, url),
db.sortedSetAdd('relays:state', 0, url),
]);
};
Relays.remove = async (url) => {
const now = new Date();
const createtime = await db.sortedSetScore('relays:createtime', url);
if (!createtime) {
throw new Error('[[error:invalid-data]]');
}
await activitypub.send('uid', 0, url, {
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://pleroma.example/schemas/litepub-0.1.jsonld',
],
id: `${nconf.get('url')}/actor#activity/undo:follow/${encodeURIComponent(url)}/${now.getTime()}`,
type: 'Undo',
to: [url],
published: now.toISOString(),
object: {
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://pleroma.example/schemas/litepub-0.1.jsonld',
],
id: `${nconf.get('url')}/actor#activity/follow/${encodeURIComponent(url)}/${createtime}`,
type: 'Follow',
actor: `${nconf.get('url')}/actor`,
to: [url],
object: url,
state: 'cancelled',
},
});
await Promise.all([
db.sortedSetRemove('relays:createtime', url),
db.sortedSetRemove('relays:state', url),
]);
};
Relays.handshake = async (object) => {
const now = new Date();
const { type, actor } = object;
// Confirm relay was added
const exists = await db.isSortedSetMember('relays:createtime', actor);
if (!exists) {
throw new Error('[[error:api.400]]');
}
if (type === 'Follow') {
await db.sortedSetIncrBy('relays:state', 1, actor);
await activitypub.send('uid', 0, actor, {
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://pleroma.example/schemas/litepub-0.1.jsonld',
],
id: `${nconf.get('url')}/actor#activity/accept/${encodeURIComponent(actor)}/${now.getTime()}`,
type: 'Accept',
to: [actor],
published: now.toISOString(),
object,
});
} else if (type === 'Accept') {
await db.sortedSetIncrBy('relays:state', 1, actor);
} else {
throw new Error('[[error:api.400]]');
}
};

View File

@@ -159,15 +159,17 @@ settingsController.api = async (req, res) => {
};
settingsController.activitypub = async (req, res) => {
const [instanceCount, rules] = await Promise.all([
const [instanceCount, rules, relays] = await Promise.all([
activitypub.instances.getCount(),
activitypub.rules.list(),
activitypub.relays.list(),
]);
res.render('admin/settings/activitypub', {
title: `[[admin/menu:settings/activitypub]]`,
instanceCount,
rules,
relays,
});
};

View File

@@ -91,7 +91,7 @@ Admin.activitypub.addRule = async (req, res) => {
const { type, value, cid } = req.body;
const exists = await categories.exists(cid);
if (!value || !exists) {
helpers.formatApiResponse(400, res);
return helpers.formatApiResponse(400, res);
}
await activitypub.rules.add(type, value, cid);
@@ -103,3 +103,17 @@ Admin.activitypub.deleteRule = async (req, res) => {
await activitypub.rules.delete(rid);
helpers.formatApiResponse(200, res, await activitypub.rules.list());
};
Admin.activitypub.addRelay = async (req, res) => {
const { url } = req.body;
await activitypub.relays.add(url);
helpers.formatApiResponse(200, res, await activitypub.relays.list());
};
Admin.activitypub.removeRelay = async (req, res) => {
const { url } = req.params;
await activitypub.relays.remove(url);
helpers.formatApiResponse(200, res, await activitypub.relays.list());
};

View File

@@ -27,6 +27,8 @@ module.exports = function () {
setupApiRoute(router, 'post', '/activitypub/rules', [...middlewares, middleware.checkRequired.bind(null, ['cid', 'value', 'type'])], controllers.write.admin.activitypub.addRule);
setupApiRoute(router, 'delete', '/activitypub/rules/:rid', [...middlewares], controllers.write.admin.activitypub.deleteRule);
setupApiRoute(router, 'post', '/activitypub/relays', [...middlewares, middleware.checkRequired.bind(null, ['url'])], controllers.write.admin.activitypub.addRelay);
setupApiRoute(router, 'delete', '/activitypub/relays/:url', [...middlewares], controllers.write.admin.activitypub.removeRelay);
return router;
};

View File

@@ -0,0 +1,11 @@
<p>[[admin/settings/activitypub:relays.warning]]</p>
<p>[[admin/settings/activitypub:relays.litepub]]</p>
<hr />
<form role="form">
<div class="mb-3">
<label class="form-label" for="url">Relay URL</label>
<input type="text" id="url" name="url" title="Relay URL" class="form-control" placeholder="https://example.org/actor">
</div>
</form>

View File

@@ -78,6 +78,39 @@
</div>
</div>
<div class="row settings m-0">
<div class="col-sm-2 col-12 settings-header">[[admin/settings/activitypub:relays]]</div>
<div class="col-sm-10 col-12">
<p>[[admin/settings/activitypub:relays.intro]]</p>
<p class="text-warning">[[admin/settings/activitypub:relays.warning]]</p>
<div class="mb-3">
<table class="table table-striped" id="relays">
<thead>
<th>[[admin/settings/activitypub:relays.relay]]</th>
<th>[[admin/settings/activitypub:relays.state]]</th>
<th></th>
</thead>
<tbody>
{{{ each relays }}}
<tr data-url="{./url}">
<td>{./url}</td>
<td>{./label}</td>
<td><a href="#" data-action="relays.remove"><i class="fa fa-trash link-danger"></i></a></td>
</tr>
{{{ end }}}
</tbody>
<tfoot>
<tr>
<td colspan="3">
<button class="btn btn-sm btn-primary" data-action="relays.add">[[admin/settings/activitypub:relays.add]]</button>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="row settings m-0">
<div class="col-sm-2 col-12 settings-header">[[admin/settings/activitypub:pruning]]</div>
<div class="col-sm-10 col-12">