Compare commits

...

61 Commits

Author SHA1 Message Date
Julian Lam
779188d88a fix: bump theme versions to fix no-followers notice accidentally showing up on local categories 2025-04-09 15:06:04 -04:00
Julian Lam
6b960d68c4 chore: v4.3.0-beta.1 2025-04-09 14:25:15 -04:00
Julian Lam
7040e41ba8 feat: show/hide categories on world page, #13255 2025-04-09 11:50:24 -04:00
Julian Lam
573ca11943 fix: add back localCategories to categorySearch when defaultCategories is supplied 2025-04-09 10:55:32 -04:00
Julian Lam
15e44fad86 fix: bump themes for #13255 2025-04-09 10:44:17 -04:00
Julian Lam
ee2820767b feat: notice on remote categories that have no local followers, #13255 2025-04-09 10:44:17 -04:00
Julian Lam
99c961bd56 fix: remote bare hash for remote users on prune as well 2025-04-09 10:44:17 -04:00
Julian Lam
b8ffc99c79 feat: add new option to categorySearch module, defaultCategories, use to populate the category list when you don't want to poll backend for the main category list 2025-04-09 10:44:17 -04:00
Julian Lam
d68d92f61a fix: missing teasers for remote categories on /world 2025-04-09 10:44:17 -04:00
Julian Lam
e2a01e5d08 fix: remove superfluous privilege filter in markAllRead 2025-04-09 10:44:17 -04:00
Julian Lam
c02a7b3c77 chore: cut 4.3.0-alpha.3 2025-04-09 10:44:17 -04:00
Julian Lam
a1385dc7f9 feat: category quick search on world page, theme version updates, #13255 2025-04-09 10:44:02 -04:00
Julian Lam
8c26dfad9c fix: reversed image and icon for remote categories, omit fa icon if remote category has icon property set, #13255 2025-04-09 10:43:18 -04:00
Julian Lam
a58f0f981b feat: show tracked/watched remote categories in world page, #13255 2025-04-09 10:43:18 -04:00
Barış Soner Uşaklı
48012ae13d fix: closes #13289, id can be null 2025-04-09 10:43:18 -04:00
Julian Lam
f3205e24e8 fix: marking remote category topics as read 2025-04-09 10:43:18 -04:00
Julian Lam
7fbc152c6f fix: markAllRead to get tids based on same logic as unread page, instead of marking all recent posts read 2025-04-09 10:43:18 -04:00
Julian Lam
6034c27adf test: additional test for ensuring handle:uid is continually set even after re-assertion 2025-04-09 10:43:18 -04:00
Julian Lam
71e1e90703 chore: cut v4.3.0-alpha.2 2025-04-09 10:43:18 -04:00
Julian Lam
023b6b7c38 fix: key ownership cross-check to also work with remote categories, #13255 2025-04-09 10:43:18 -04:00
Julian Lam
855bed54c0 chore: cut v4.3.0-alpha 2025-04-09 10:43:18 -04:00
Julian Lam
2fb1e728fd fix: #13255, assert all recipients of the main post when asserting a note, so that remote categories can be discovered 2025-04-09 10:43:18 -04:00
Julian Lam
63d6192878 fix: remote categories should not show up in a user's follow lists 2025-04-09 10:43:18 -04:00
Julian Lam
72560ee986 fix: #13255, remote user-to-category migration should not move shares that are already in an existing cid 2025-04-09 10:43:18 -04:00
Julian Lam
1f18f1548a fix: proper handling of actors.qualify response 2025-04-09 10:43:18 -04:00
Julian Lam
903d6102aa fix: missing dep 2025-04-09 10:43:18 -04:00
Julian Lam
0f313a5294 test: additional test for remote category topic assertion when ignoring category 2025-04-09 10:43:18 -04:00
Julian Lam
8e70726ef6 fix: topics in remote categories showing up in /recent 2025-04-09 10:43:18 -04:00
Julian Lam
f91c3a7e0b fix: regression that caused resolveInboxes to always return empty, added tests for resolveInboxes 2025-04-09 10:43:18 -04:00
Barış Soner Uşaklı
0704988218 dont make db call if ap disabled 2025-04-09 10:43:18 -04:00
Barış Soner Uşaklı
fb993c8a55 refactor: use promise.all 2025-04-09 10:43:18 -04:00
Barış Soner Uşaklı
223d70550c fix: spread fail, @julianlam
add ap check
2025-04-09 10:43:18 -04:00
Julian Lam
8d6d1856ae feat: remote user to category migration should also migrate local user follows into category watches 2025-04-09 10:43:18 -04:00
Julian Lam
93eb31c98d fix: filter out non-asserted targets when sending ap messages, diff. getter method when passed-in ID is a remote category 2025-04-09 10:43:18 -04:00
Julian Lam
e86a75219b fix: tag whitelist check socket call for remote categories 2025-04-09 10:43:18 -04:00
Julian Lam
59ccb2cc76 feat: allowing manual group assertion via category search input 2025-04-09 10:43:18 -04:00
Julian Lam
540af998a4 fix: migrate topics as system user instead of uid 0 2025-04-09 10:43:18 -04:00
Julian Lam
edc6708c68 send ap follow/undo-follow if remote category watch state changes 2025-04-09 10:43:18 -04:00
Julian Lam
dba791df6f feat: remote group actors migrated to categories if they were previous asserted as remote users 2025-04-09 10:43:18 -04:00
Julian Lam
c6cacdbd49 fix: do not send out ap (undo:)follow if local user or category is (not)already following 2025-04-09 10:43:18 -04:00
Julian Lam
9c74683b33 test: #13255, reply to topic in remote category addresses remote category 2025-04-09 10:43:18 -04:00
Julian Lam
3ff3403593 feat: #13255 new topics in remote category addresses remote category, tests, fixes to tests 2025-04-09 10:43:18 -04:00
Julian Lam
ef0c48bcda fix: allow category controller to respond also by remote category id 2025-04-09 10:43:17 -04:00
Julian Lam
e06ddaddf5 feat: #13255, deliver asserted topics to remote category followers 2025-04-09 10:43:17 -04:00
Julian Lam
40ac3df69f fix: #13255, update category search logic to allow for remote categories 2025-04-09 10:43:17 -04:00
Julian Lam
6f630d76c2 feat: #13255, add category name and handle to category search zset 2025-04-09 10:43:17 -04:00
Julian Lam
b278e8a4ea refactor: categories.sortTidsBySet to not take cid, retrieve from tids themselves
re: ##13255, this fixes the issue with topics outside of cid -1 in /world being sorted incorrectly
2025-04-09 10:43:17 -04:00
Julian Lam
87d2534a4b test: remote user pruning tests 2025-04-09 10:43:17 -04:00
Julian Lam
d3a846e877 feat: integrate remote category pruning into actor pruning logic 2025-04-09 10:43:17 -04:00
Julian Lam
d0e0ce2931 feat: migration of group-as-user to group-as-category, remote category purging, more tests 2025-04-09 10:43:17 -04:00
Julian Lam
05d28c02c2 fix: delete shares zset on account deletion 2025-04-09 10:43:17 -04:00
Julian Lam
37b60f0aaa test: introduce overrides into person and group mocks 2025-04-09 10:43:17 -04:00
Julian Lam
ea1df8850d test: have ap helper mocks for person and group auto-save to ap cache 2025-04-09 10:43:17 -04:00
Julian Lam
df233345a7 test: add failing tests for actor/group assertion via wrong method, remote user to category migration 2025-04-09 10:43:17 -04:00
Julian Lam
ff839213e4 feat: asserted topics and posts to remote categories will notify and add to unread based on remote category watch state 2025-04-09 10:43:17 -04:00
Julian Lam
de16003336 test: add tests for topics slotting into remote categories if addressed 2025-04-09 10:43:17 -04:00
Julian Lam
24114d52ba test: group actor assertion tests 2025-04-09 10:43:17 -04:00
Julian Lam
91cebe651a refactor: allow topics to be asserted directly into a remote category, or -1 otherwise 2025-04-09 10:43:17 -04:00
Julian Lam
1b3673da7a feat: also include category in to field when mocking post for federation 2025-04-09 10:43:17 -04:00
Julian Lam
244ce8d70e refactor: ability to browse to remote categories, group actor assertion logic, etc. -- no logic to assign topics to remote categories yet 2025-04-09 10:43:17 -04:00
Julian Lam
3f7c8678a3 revert: use of vanity domains, needs rethinking. Originally added in 709a02d97a 2025-04-09 10:43:17 -04:00
42 changed files with 1631 additions and 178 deletions

View File

@@ -2,7 +2,7 @@
"name": "nodebb",
"license": "GPL-3.0",
"description": "NodeBB Forum",
"version": "4.2.0",
"version": "4.3.0-beta.1",
"homepage": "https://www.nodebb.org",
"repository": {
"type": "git",
@@ -107,10 +107,10 @@
"nodebb-plugin-spam-be-gone": "2.3.1",
"nodebb-plugin-web-push": "0.7.3",
"nodebb-rewards-essentials": "1.0.1",
"nodebb-theme-harmony": "2.1.3",
"nodebb-theme-harmony": "2.1.6",
"nodebb-theme-lavender": "7.1.18",
"nodebb-theme-peace": "2.2.39",
"nodebb-theme-persona": "14.1.2",
"nodebb-theme-persona": "14.1.5",
"nodebb-widget-essentials": "7.0.36",
"nodemailer": "6.10.0",
"nprogress": "0.2.0",

View File

@@ -7,6 +7,7 @@
"new-topic-button": "New Topic",
"guest-login-post": "Log in to post",
"no-topics": "<strong>There are no topics in this category.</strong><br />Why don't you try posting one?",
"no-followers": "Nobody on this website is tracking or watching this category. Track or watch this category in order to begin receiving updates.",
"browsing": "browsing",
"no-replies": "No one has replied",

View File

@@ -14,5 +14,8 @@
"onboard.title": "Your window to the fediverse...",
"onboard.what": "This is your personalized category made up of only content found outside of this forum. Whether something shows up in this page depends on whether you follow them, or whether that post was shared by someone you follow.",
"onboard.why": "There's a lot that goes on outside of this forum, and not all of it is relevant to your interests. That's why following people is the best way to signal that you want to see more from someone.",
"onboard.how": "In the meantime, you can click on the shortcut buttons at the top to see what else this forum knows about, and start discovering some new content!"
"onboard.how": "In the meantime, you can click on the shortcut buttons at the top to see what else this forum knows about, and start discovering some new content!",
"show-categories": "Show categories",
"hide-categories": "Hide categories"
}

View File

@@ -70,7 +70,7 @@ define('forum/category', [
const $this = $(this);
const state = $this.attr('data-state');
api.put(`/categories/${cid}/watch`, { state }, (err) => {
api.put(`/categories/${encodeURIComponent(cid)}/watch`, { state }, (err) => {
if (err) {
return alerts.error(err);
}
@@ -118,7 +118,7 @@ define('forum/category', [
};
Category.toBottom = async () => {
const { count } = await api.get(`/categories/${ajaxify.data.category.cid}/count`);
const { count } = await api.get(`/categories/${encodeURIComponent(ajaxify.data.category.cid)}/count`);
navigator.scrollBottom(count - 1);
};
@@ -127,7 +127,7 @@ define('forum/category', [
hooks.fire('action:topics.loading');
const params = utils.params();
infinitescroll.loadMore(`/categories/${ajaxify.data.cid}/topics`, {
infinitescroll.loadMore(`/categories/${encodeURIComponent(ajaxify.data.cid)}/topics`, {
after: after,
direction: direction,
query: params,

View File

@@ -61,6 +61,17 @@ define('forum/unread', [
doneRemovingTids(tids);
});
}
// Generate list of default categories based on topic list
let defaultCategories = ajaxify.data.topics.reduce((map, topic) => {
const { category }= topic;
let { cid } = category;
cid = utils.isNumber(cid) ? parseInt(cid, 10) : cid;
map.set(cid, category);
return map;
}, new Map());
defaultCategories = Array.from(defaultCategories.values());
const selector = categorySelector.init($('[component="category-selector"]'), {
onSelect: function (category) {
selector.selectCategory(0);
@@ -68,7 +79,7 @@ define('forum/unread', [
markAllRead();
} else if (category.cid === 'selected') {
markSelectedRead();
} else if (parseInt(category.cid, 10) > 0) {
} else if (category.cid) {
markCategoryRead(category.cid);
}
},
@@ -85,6 +96,7 @@ define('forum/unread', [
icon: '',
},
],
defaultCategories,
});
}

View File

@@ -1,6 +1,6 @@
'use strict';
define('forum/world', ['topicList', 'sort', 'hooks', 'alerts', 'api', 'bootbox'], function (topicList, sort, hooks, alerts, api, bootbox) {
define('forum/world', ['topicList', 'search', 'sort', 'hooks', 'alerts', 'api', 'bootbox'], function (topicList, search, sort, hooks, alerts, api, bootbox) {
const World = {};
World.init = function () {
@@ -11,6 +11,22 @@ define('forum/world', ['topicList', 'sort', 'hooks', 'alerts', 'api', 'bootbox']
handleIgnoreWatch(-1);
handleHelp();
handleCategories();
search.enableQuickSearch({
searchElements: {
inputEl: $('[component="category-search"]'),
resultEl: $('.world .quick-search-container'),
},
searchOptions: {
in: 'categories',
},
dropdown: {
maxWidth: '400px',
maxHeight: '350px',
},
hideOnNoMatches: false,
});
hooks.fire('action:topics.loaded', { topics: ajaxify.data.topics });
hooks.fire('action:category.loaded', { cid: ajaxify.data.cid });
@@ -66,5 +82,38 @@ define('forum/world', ['topicList', 'sort', 'hooks', 'alerts', 'api', 'bootbox']
});
}
function handleCategories() {
// const optionsEl = document.getElementById('category-options');
// const dropdownEl = optionsEl.querySelector('ul');
const showEl = document.getElementById('show-categories');
const hideEl = document.getElementById('hide-categories');
const categoriesEl = document.querySelector('.categories-list');
if (![showEl, hideEl, categoriesEl].every(Boolean)) {
return;
}
const update = () => {
showEl.classList.toggle('hidden', visibility);
hideEl.classList.toggle('hidden', !visibility);
categoriesEl.classList.toggle('hidden', !visibility);
localStorage.setItem('world:show-categories', visibility);
}
let visibility = localStorage.getItem('world:show-categories');
console.log('got value', visibility);
visibility = visibility ? visibility === 'true' : true; // localStorage values are strings
update();
showEl.addEventListener('click', () => {
visibility = true;
update();
});
hideEl.addEventListener('click', () => {
visibility = false;
update();
});
}
return World;
});

View File

@@ -4,7 +4,7 @@ define('categorySearch', ['alerts', 'bootstrap', 'api'], function (alerts, boots
const categorySearch = {};
categorySearch.init = function (el, options) {
let categoriesList = null;
let categoriesList = options.defaultCategories || null;
options = options || {};
options.privilege = options.privilege || 'topics:read';
options.states = options.states || ['watching', 'tracking', 'notwatching', 'ignoring'];
@@ -13,6 +13,9 @@ define('categorySearch', ['alerts', 'bootstrap', 'api'], function (alerts, boots
let localCategories = [];
if (Array.isArray(options.localCategories)) {
localCategories = options.localCategories.map(c => ({ ...c }));
if (categoriesList) {
categoriesList = [...localCategories, ...categoriesList];
}
}
options.selectedCids = options.selectedCids || ajaxify.data.selectedCids || [];

View File

@@ -132,34 +132,56 @@ define('search', [
options.searchOptions.searchOnly = 1;
Search.api(options.searchOptions, function (data) {
quickSearchResults.find('.loading-indicator').addClass('hidden');
if (!data.posts || (options.hideOnNoMatches && !data.posts.length)) {
return quickSearchResults.addClass('hidden').find('.quick-search-results-container').html('');
}
data.posts.forEach(function (p) {
const text = $('<div>' + p.content + '</div>').text();
const query = inputEl.val().toLowerCase().replace(/^in:topic-\d+/, '');
const start = Math.max(0, text.toLowerCase().indexOf(query) - 40);
p.snippet = utils.escapeHTML((start > 0 ? '...' : '') +
text.slice(start, start + 80) +
(text.length - start > 80 ? '...' : ''));
});
data.dropdown = { maxWidth: '400px', maxHeight: '500px', ...options.dropdown };
app.parseAndTranslate('partials/quick-search-results', data, function (html) {
if (html.length) {
html.find('.timeago').timeago();
if (options.searchOptions.in === 'categories') {
if (!data.categories || (options.hideOnNoMatches && !data.categories.length)) {
return quickSearchResults.addClass('hidden').find('.quick-search-results-container').html('');
}
quickSearchResults.toggleClass('hidden', !html.length || !inputEl.is(':focus'))
.find('.quick-search-results-container')
.html(html.length ? html : '');
const highlightEls = quickSearchResults.find(
'.quick-search-results .quick-search-title, .quick-search-results .snippet'
);
Search.highlightMatches(options.searchOptions.term, highlightEls);
hooks.fire('action:search.quick.complete', {
data: data,
options: options,
data.dropdown = { maxWidth: '400px', maxHeight: '500px', ...options.dropdown };
app.parseAndTranslate('partials/quick-category-search-results', data, (html) => {
if (html.length) {
html.find('.timeago').timeago();
}
quickSearchResults.toggleClass('hidden', !html.length || !inputEl.is(':focus'))
.find('.quick-search-results-container')
.html(html.length ? html : '');
hooks.fire('action:search.quick.complete', {
data: data,
options: options,
});
});
});
} else {
if (!data.posts || (options.hideOnNoMatches && !data.posts.length)) {
return quickSearchResults.addClass('hidden').find('.quick-search-results-container').html('');
}
data.posts.forEach(function (p) {
const text = $('<div>' + p.content + '</div>').text();
const query = inputEl.val().toLowerCase().replace(/^in:topic-\d+/, '');
const start = Math.max(0, text.toLowerCase().indexOf(query) - 40);
p.snippet = utils.escapeHTML((start > 0 ? '...' : '') +
text.slice(start, start + 80) +
(text.length - start > 80 ? '...' : ''));
});
data.dropdown = { maxWidth: '400px', maxHeight: '500px', ...options.dropdown };
app.parseAndTranslate('partials/quick-search-results', data, function (html) {
if (html.length) {
html.find('.timeago').timeago();
}
quickSearchResults.toggleClass('hidden', !html.length || !inputEl.is(':focus'))
.find('.quick-search-results-container')
.html(html.length ? html : '');
const highlightEls = quickSearchResults.find(
'.quick-search-results .quick-search-title, .quick-search-results .snippet'
);
Search.highlightMatches(options.searchOptions.term, highlightEls);
hooks.fire('action:search.quick.complete', {
data: data,
options: options,
});
});
}
});
}

View File

@@ -7,7 +7,9 @@ const _ = require('lodash');
const db = require('../database');
const meta = require('../meta');
const batch = require('../batch');
const categories = require('../categories');
const user = require('../user');
const topics = require('../topics');
const utils = require('../utils');
const TTLCache = require('../cache/ttl');
@@ -20,15 +22,12 @@ const activitypub = module.parent.exports;
const Actors = module.exports;
Actors.assert = async (ids, options = {}) => {
Actors.qualify = async (ids, options = {}) => {
/**
* Ensures that the passed in ids or webfinger handles are stored in database.
* Options:
* - update: boolean, forces re-fetch/process of the resolved id
* Return one of:
* - An array of newly processed ids
* - false: if input incorrect (or webfinger handle cannot resolve)
* - true: no new IDs processed; all passed-in IDs present.
* Sanity-checks, cache handling, webfinger translations, so that only
* an array of actor uris are handled by assert/assertGroup.
*
* This method is only called by assert/assertGroup (at least in core.)
*/
// Handle single values
@@ -47,7 +46,6 @@ Actors.assert = async (ids, options = {}) => {
ids = ids.filter(id => !utils.isNumber(id));
// Translate webfinger handles to uris
const hostMap = new Map();
ids = (await Promise.all(ids.map(async (id) => {
const originalId = id;
if (activitypub.helpers.isWebfinger(id)) {
@@ -57,7 +55,6 @@ Actors.assert = async (ids, options = {}) => {
}
({ actorUri: id } = await activitypub.helpers.query(id));
hostMap.set(id, host);
}
// ensure the final id is a valid URI
if (!id || !activitypub.helpers.isUri(id)) {
@@ -77,18 +74,44 @@ Actors.assert = async (ids, options = {}) => {
ids = ids.filter(uri => uri !== 'loopback' && new URL(uri).host !== nconf.get('url_parsed').host);
}
// Separate those who need migration from user to category
const migrate = new Set();
if (options.qualifyGroup) {
const exists = await db.exists(ids.map(id => `userRemote:${id}`));
ids.forEach((id, idx) => {
if (exists[idx]) {
migrate.add(id);
}
});
}
// Only assert those who haven't been seen recently (configurable), unless update flag passed in (force refresh)
if (!options.update) {
const upperBound = Date.now() - (1000 * 60 * 60 * 24 * meta.config.activitypubUserPruneDays);
const lastCrawled = await db.sortedSetScores('usersRemote:lastCrawled', ids.map(id => ((typeof id === 'object' && id.hasOwnProperty('id')) ? id.id : id)));
ids = ids.filter((id, idx) => {
const timestamp = lastCrawled[idx];
return !timestamp || timestamp < upperBound;
return migrate.has(id) || !timestamp || timestamp < upperBound;
});
}
if (!ids.length) {
return true;
return ids;
};
Actors.assert = async (ids, options = {}) => {
/**
* Ensures that the passed in ids or webfinger handles are stored in database.
* Options:
* - update: boolean, forces re-fetch/process of the resolved id
* Return one of:
* - An array of newly processed ids
* - false: if input incorrect (or webfinger handle cannot resolve)
* - true: no new IDs processed; all passed-in IDs present.
*/
ids = await Actors.qualify(ids, options);
if (!ids || !ids.length) {
return ids;
}
activitypub.helpers.log(`[activitypub/actors] Asserting ${ids.length} actor(s)`);
@@ -98,6 +121,7 @@ Actors.assert = async (ids, options = {}) => {
const urlMap = new Map();
const followersUrlMap = new Map();
const pubKeysMap = new Map();
const categories = new Set();
let actors = await Promise.all(ids.map(async (id) => {
try {
activitypub.helpers.log(`[activitypub/actors] Processing ${id}`);
@@ -106,8 +130,14 @@ Actors.assert = async (ids, options = {}) => {
let typeOk = false;
if (Array.isArray(actor.type)) {
typeOk = actor.type.some(type => activitypub._constants.acceptableActorTypes.has(type));
if (!typeOk && actor.type.some(type => activitypub._constants.acceptableGroupTypes.has(type))) {
categories.add(actor.id);
}
} else {
typeOk = activitypub._constants.acceptableActorTypes.has(actor.type);
if (!typeOk && activitypub._constants.acceptableGroupTypes.has(actor.type)) {
categories.add(actor.id);
}
}
if (
@@ -161,9 +191,12 @@ Actors.assert = async (ids, options = {}) => {
}
}));
actors = actors.filter(Boolean); // remove unresolvable actors
if (!actors.length && !categories.size) {
return [];
}
// Build userData object for storage
const profiles = (await activitypub.mocks.profile(actors, hostMap)).filter(Boolean);
const profiles = (await activitypub.mocks.profile(actors)).filter(Boolean);
const now = Date.now();
const bulkSet = profiles.reduce((memo, profile) => {
@@ -219,10 +252,188 @@ Actors.assert = async (ids, options = {}) => {
db.setObject('handle:uid', queries.handleAdd),
]);
// Handle any actors that should be asserted as a group instead
if (categories.size) {
const assertion = await Actors.assertGroup(Array.from(categories), options);
if (assertion === false) {
return false;
} else if (Array.isArray(assertion)) {
return [...actors, ...assertion];
}
// otherwise, assertGroup returned true and output can be safely ignored.
}
return actors;
};
Actors.assertGroup = async (ids, options = {}) => {
/**
* Ensures that the passed in ids or webfinger handles are stored in database.
* Options:
* - update: boolean, forces re-fetch/process of the resolved id
* Return one of:
* - An array of newly processed ids
* - false: if input incorrect (or webfinger handle cannot resolve)
* - true: no new IDs processed; all passed-in IDs present.
*/
ids = await Actors.qualify(ids, {
qualifyGroup: true,
...options,
});
if (!ids) {
return ids;
}
activitypub.helpers.log(`[activitypub/actors] Asserting ${ids.length} group(s)`);
// NOTE: MAKE SURE EVERY DB ADDITION HAS A CORRESPONDING REMOVAL IN ACTORS.REMOVEGROUP!
const urlMap = new Map();
const followersUrlMap = new Map();
const pubKeysMap = new Map();
let groups = await Promise.all(ids.map(async (id) => {
try {
activitypub.helpers.log(`[activitypub/actors] Processing group ${id}`);
const actor = (typeof id === 'object' && id.hasOwnProperty('id')) ? id : await activitypub.get('uid', 0, id, { cache: process.env.CI === 'true' });
const typeOk = Array.isArray(actor.type) ?
actor.type.some(type => activitypub._constants.acceptableGroupTypes.has(type)) :
activitypub._constants.acceptableGroupTypes.has(actor.type);
if (
!typeOk ||
!activitypub._constants.requiredActorProps.every(prop => actor.hasOwnProperty(prop))
) {
return null;
}
// Save url for backreference
const url = Array.isArray(actor.url) ? actor.url.shift() : actor.url;
if (url && url !== actor.id) {
urlMap.set(url, actor.id);
}
// Save followers url for backreference
if (actor.hasOwnProperty('followers') && activitypub.helpers.isUri(actor.followers)) {
followersUrlMap.set(actor.followers, actor.id);
}
// Public keys
pubKeysMap.set(actor.id, actor.publicKey);
return actor;
} catch (e) {
if (e.code === 'ap_get_410') {
const exists = await categories.exists(id);
if (exists) {
await categories.purge(id, 0);
}
}
return null;
}
}));
groups = groups.filter(Boolean); // remove unresolvable actors
// Build userData object for storage
const categoryObjs = (await activitypub.mocks.category(groups)).filter(Boolean);
const now = Date.now();
const bulkSet = categoryObjs.reduce((memo, category) => {
const key = `categoryRemote:${category.cid}`;
memo.push([key, category], [`${key}:keys`, pubKeysMap.get(category.cid)]);
return memo;
}, []);
if (urlMap.size) {
bulkSet.push(['remoteUrl:cid', Object.fromEntries(urlMap)]);
}
if (followersUrlMap.size) {
bulkSet.push(['followersUrl:cid', Object.fromEntries(followersUrlMap)]);
}
const exists = await db.isSortedSetMembers('usersRemote:lastCrawled', categoryObjs.map(p => p.cid));
const cidsForCurrent = categoryObjs.map((p, idx) => (exists[idx] ? p.cid : 0));
const current = await categories.getCategoriesFields(cidsForCurrent, ['slug']);
const queries = categoryObjs.reduce((memo, profile, idx) => {
const { slug, name } = current[idx];
if (options.update || slug !== profile.slug) {
if (cidsForCurrent[idx] !== 0 && slug) {
// memo.searchRemove.push(['ap.preferredUsername:sorted', `${slug.toLowerCase()}:${profile.uid}`]);
memo.handleRemove.push(slug.toLowerCase());
}
memo.searchAdd.push(['categories:name', 0, `${profile.slug.slice(0, 200).toLowerCase()}:${profile.cid}`]);
memo.handleAdd[profile.slug.toLowerCase()] = profile.cid;
}
if (options.update || (profile.name && name !== profile.name)) {
if (name && cidsForCurrent[idx] !== 0) {
memo.searchRemove.push(['categories:name', `${name.toLowerCase()}:${profile.cid}`]);
}
memo.searchAdd.push(['categories:name', 0, `${profile.name.toLowerCase()}:${profile.cid}`]);
}
return memo;
}, { searchRemove: [], searchAdd: [], handleRemove: [], handleAdd: {} });
// Removals
await Promise.all([
db.sortedSetRemoveBulk(queries.searchRemove),
db.deleteObjectFields('handle:cid', queries.handleRemove),
]);
await Promise.all([
db.setObjectBulk(bulkSet),
db.sortedSetAdd('usersRemote:lastCrawled', groups.map(() => now), groups.map(p => p.id)),
db.sortedSetAddBulk(queries.searchAdd),
db.setObject('handle:cid', queries.handleAdd),
_migratePersonToGroup(categoryObjs),
]);
return categoryObjs;
};
async function _migratePersonToGroup(categoryObjs) {
// 4.0.0-4.1.x asserted as:Group as users. This moves relevant stuff over and deletes the now-duplicate user.
let ids = categoryObjs.map(category => category.cid);
const slugs = categoryObjs.map(category => category.slug);
const isUser = await db.isObjectFields('handle:uid', slugs);
ids = ids.filter((id, idx) => isUser[idx]);
if (!ids.length) {
return;
}
await Promise.all(ids.map(async (id) => {
const shares = await db.getSortedSetMembers(`uid:${id}:shares`);
let cids = await topics.getTopicsFields(shares, ['cid']);
cids = cids.map(o => o.cid);
await Promise.all(shares.map(async (share, idx) => {
const cid = cids[idx];
if (cid === -1) {
await topics.tools.move(share, {
cid: id,
uid: 'system',
});
}
}));
const followers = await db.getSortedSetMembersWithScores(`followersRemote:${id}`);
await db.sortedSetAdd(
`cid:${id}:uid:watch:state`,
followers.map(() => categories.watchStates.tracking),
followers.map(({ value }) => value),
);
await user.deleteAccount(id);
}));
await categories.onTopicsMoved(ids);
}
Actors.getLocalFollowers = async (id) => {
// Returns local uids and cids that follow a remote actor (by id)
const response = {
uids: new Set(),
cids: new Set(),
@@ -232,15 +443,27 @@ Actors.getLocalFollowers = async (id) => {
return response;
}
const members = await db.getSortedSetMembers(`followersRemote:${id}`);
const [isUser, isCategory] = await Promise.all([
user.exists(id),
categories.exists(id),
]);
members.forEach((id) => {
if (utils.isNumber(id)) {
response.uids.add(parseInt(id, 10));
} else if (id.startsWith('cid|') && utils.isNumber(id.slice(4))) {
response.cids.add(parseInt(id.slice(4), 10));
}
});
if (isUser) {
const members = await db.getSortedSetMembers(`followersRemote:${id}`);
members.forEach((id) => {
if (utils.isNumber(id)) {
response.uids.add(parseInt(id, 10));
} else if (id.startsWith('cid|') && utils.isNumber(id.slice(4))) {
response.cids.add(parseInt(id.slice(4), 10));
}
});
} else if (isCategory) {
const members = await db.getSortedSetRangeByScore(`cid:${id}:uid:watch:state`, 0, -1, categories.watchStates.tracking, categories.watchStates.watching);
members.forEach((uid) => {
response.uids.add(uid);
});
}
return response;
};
@@ -310,38 +533,105 @@ Actors.remove = async (id) => {
]);
};
Actors.removeGroup = async (id) => {
/**
* Remove ActivityPub related metadata pertaining to a remote id
*
* Note: don't call this directly! It is called as part of categories.purge
*/
const exists = await db.isSortedSetMember('usersRemote:lastCrawled', id);
if (!exists) {
return false;
}
let { slug, name, url, followersUrl } = await categories.getCategoryFields(id, ['slug', 'name', 'url', 'followersUrl']);
slug = slug.toLowerCase();
const bulkRemove = [
['categories:name', `${slug}:${id}`],
];
if (name) {
bulkRemove.push(['categories:name', `${name.toLowerCase()}:${id}`]);
}
await Promise.all([
db.sortedSetRemoveBulk(bulkRemove),
db.deleteObjectField('handle:cid', slug),
db.deleteObjectField('followersUrl:cid', followersUrl),
db.deleteObjectField('remoteUrl:cid', url),
db.delete(`categoryRemote:${id}:keys`),
]);
await Promise.all([
db.delete(`categoryRemote:${id}`),
db.sortedSetRemove('usersRemote:lastCrawled', id),
]);
};
Actors.prune = async () => {
/**
* Clear out remote user accounts that do not have content on the forum anywhere
*/
winston.info('[actors/prune] Started scheduled pruning of remote user accounts');
activitypub.helpers.log('[actors/prune] Started scheduled pruning of remote user accounts and categories');
const days = parseInt(meta.config.activitypubUserPruneDays, 10);
const timestamp = Date.now() - (1000 * 60 * 60 * 24 * days);
const uids = await db.getSortedSetRangeByScore('usersRemote:lastCrawled', 0, 500, '-inf', timestamp);
if (!uids.length) {
winston.info('[actors/prune] No remote users to prune, all done.');
return;
const ids = await db.getSortedSetRangeByScore('usersRemote:lastCrawled', 0, 500, '-inf', timestamp);
if (!ids.length) {
activitypub.helpers.log('[actors/prune] No remote actors to prune, all done.');
return {
counts: {
deleted: 0,
missing: 0,
preserved: 0,
},
preserved: new Set(),
};
}
winston.info(`[actors/prune] Found ${uids.length} remote users last crawled more than ${days} days ago`);
activitypub.helpers.log(`[actors/prune] Found ${ids.length} remote actors last crawled more than ${days} days ago`);
let deletionCount = 0;
let deletionCountNonExisting = 0;
let notDeletedDueToLocalContent = 0;
const notDeletedUids = [];
await batch.processArray(uids, async (uids) => {
const exists = await db.exists(uids.map(uid => `userRemote:${uid}`));
const uidsThatExist = uids.filter((uid, idx) => exists[idx]);
const uidsThatDontExist = uids.filter((uid, idx) => !exists[idx]);
const [postCounts, roomCounts, followCounts] = await Promise.all([
db.sortedSetsCard(uidsThatExist.map(uid => `uid:${uid}:posts`)),
db.sortedSetsCard(uidsThatExist.map(uid => `uid:${uid}:chat:rooms`)),
Actors.getLocalFollowCounts(uidsThatExist),
const preservedIds = [];
await batch.processArray(ids, async (ids) => {
const exists = await Promise.all([
db.exists(ids.map(id => `userRemote:${id}`)),
db.exists(ids.map(id => `categoryRemote:${id}`)),
]);
await Promise.all(uidsThatExist.map(async (uid, idx) => {
let uids = new Set();
let cids = new Set();
const missing = new Set();
ids.forEach((id, idx) => {
switch (true) {
case exists[0][idx]: {
uids.add(id);
break;
}
case exists[1][idx]: {
cids.add(id);
break;
}
default: {
missing.add(id);
break;
}
}
});
uids = Array.from(uids);
cids = Array.from(cids);
// Remote users
const [postCounts, roomCounts, followCounts] = await Promise.all([
db.sortedSetsCard(uids.map(uid => `uid:${uid}:posts`)),
db.sortedSetsCard(uids.map(uid => `uid:${uid}:chat:rooms`)),
Actors.getLocalFollowCounts(uids),
]);
await Promise.all(uids.map(async (uid, idx) => {
const { followers, following } = followCounts[idx];
const postCount = postCounts[idx];
const roomCount = roomCounts[idx];
@@ -354,20 +644,46 @@ Actors.prune = async () => {
}
} else {
notDeletedDueToLocalContent += 1;
notDeletedUids.push(uid);
preservedIds.push(uid);
}
}));
deletionCountNonExisting += uidsThatDontExist.length;
await db.sortedSetRemove('usersRemote:lastCrawled', uidsThatDontExist);
// Remote categories
let counts = await categories.getCategoriesFields(cids, ['topic_count']);
counts = counts.map(count => count.topic_count);
await Promise.all(cids.map(async (cid, idx) => {
const topicCount = counts[idx];
if (topicCount === 0) {
try {
await categories.purge(cid, 0);
deletionCount += 1;
} catch (err) {
winston.error(err.stack);
}
} else {
notDeletedDueToLocalContent += 1;
preservedIds.push(cid);
}
}));
deletionCountNonExisting += missing.size;
await db.sortedSetRemove('usersRemote:lastCrawled', Array.from(missing));
// update timestamp in usersRemote:lastCrawled so we don't try to delete users
// with content over and over
const now = Date.now();
await db.sortedSetAdd('usersRemote:lastCrawled', notDeletedUids.map(() => now), notDeletedUids);
await db.sortedSetAdd('usersRemote:lastCrawled', preservedIds.map(() => now), preservedIds);
}, {
batch: 50,
interval: 1000,
});
winston.info(`[actors/prune] ${deletionCount} remote users pruned. ${deletionCountNonExisting} does not exist. ${notDeletedDueToLocalContent} not deleted due to local content`);
activitypub.helpers.log(`[actors/prune] ${deletionCount} remote users pruned. ${deletionCountNonExisting} did not exist. ${notDeletedDueToLocalContent} not deleted due to local content`);
return {
counts: {
deleted: deletionCount,
missing: deletionCountNonExisting,
preserved: notDeletedDueToLocalContent,
},
preserved: new Set(preservedIds),
};
};

View File

@@ -8,6 +8,7 @@ const { CronJob } = require('cron');
const request = require('../request');
const db = require('../database');
const meta = require('../meta');
const categories = require('../categories');
const posts = require('../posts');
const messaging = require('../messaging');
const user = require('../user');
@@ -39,7 +40,8 @@ ActivityPub._constants = Object.freeze({
acceptedPostTypes: [
'Note', 'Page', 'Article', 'Question', 'Video',
],
acceptableActorTypes: new Set(['Application', 'Group', 'Organization', 'Person', 'Service']),
acceptableActorTypes: new Set(['Application', 'Organization', 'Person', 'Service']),
acceptableGroupTypes: new Set(['Group']),
requiredActorProps: ['inbox', 'outbox'],
acceptedProtocols: ['https', ...(process.env.CI === 'true' ? ['http'] : [])],
acceptable: {
@@ -113,11 +115,28 @@ ActivityPub.resolveInboxes = async (ids) => {
}
await ActivityPub.actors.assert(ids);
// Remove non-asserted targets
const exists = await db.isSortedSetMembers('usersRemote:lastCrawled', ids);
ids = ids.filter((_, idx) => exists[idx]);
await batch.processArray(ids, async (currentIds) => {
const usersData = await user.getUsersFields(currentIds, ['inbox', 'sharedInbox']);
usersData.forEach((u) => {
if (u && (u.sharedInbox || u.inbox)) {
inboxes.add(u.sharedInbox || u.inbox);
const isCategory = await db.exists(currentIds.map(id => `categoryRemote:${id}`));
const [cids, uids] = currentIds.reduce(([cids, uids], id, idx) => {
const array = isCategory[idx] ? cids : uids;
array.push(id);
return [cids, uids];
}, [[], []]);
const categoryData = await categories.getCategoriesFields(cids, ['inbox', 'sharedInbox']);
const userData = await user.getUsersFields(uids, ['inbox', 'sharedInbox']);
currentIds.forEach((id) => {
if (cids.includes(id)) {
const data = categoryData[cids.indexOf(id)];
inboxes.add(data.sharedInbox || data.inbox);
} else if (uids.includes(id)) {
const data = userData[uids.indexOf(id)];
inboxes.add(data.sharedInbox || data.inbox);
}
});
}, {

View File

@@ -129,7 +129,7 @@ Mocks._normalize = async (object) => {
};
};
Mocks.profile = async (actors, hostMap) => {
Mocks.profile = async (actors) => {
// Should only ever be called by activitypub.actors.assert
const profiles = await Promise.all(actors.map(async (actor) => {
if (!actor) {
@@ -137,7 +137,7 @@ Mocks.profile = async (actors, hostMap) => {
}
const uid = actor.id;
let hostname = hostMap.get(uid);
let hostname;
let {
url, preferredUsername, published, icon, image,
name, summary, followers, inbox, endpoints, tag,
@@ -145,12 +145,10 @@ Mocks.profile = async (actors, hostMap) => {
preferredUsername = slugify(preferredUsername || name);
const { followers: followerCount, following: followingCount } = await activitypub.actors.getLocalFollowCounts(uid);
if (!hostname) { // if not available via webfinger, infer from id
try {
({ hostname } = new URL(actor.id));
} catch (e) {
return null;
}
try {
({ hostname } = new URL(actor.id));
} catch (e) {
return null;
}
let picture;
@@ -218,7 +216,7 @@ Mocks.profile = async (actors, hostMap) => {
uploadedpicture: undefined,
'cover:url': !image || typeof image === 'string' ? image : image.url,
'cover:position': '50% 50%',
aboutme: summary,
aboutme: posts.sanitize(summary),
followerCount,
followingCount,
@@ -235,6 +233,77 @@ Mocks.profile = async (actors, hostMap) => {
return profiles;
};
Mocks.category = async (actors) => {
const categories = await Promise.all(actors.map(async (actor) => {
if (!actor) {
return null;
}
const cid = actor.id;
let hostname;
let {
url, preferredUsername, icon, /* image, */
name, summary, followers, inbox, endpoints, tag,
} = actor;
preferredUsername = slugify(preferredUsername || name);
// const { followers: followerCount, following: followingCount } = await activitypub.actors.getLocalFollowCounts(uid);
try {
({ hostname } = new URL(actor.id));
} catch (e) {
return null;
}
// No support for category avatars yet ;(
// let picture;
// if (image) {
// picture = typeof image === 'string' ? image : image.url;
// }
const iconBackgrounds = await user.getIconBackgrounds();
let bgColor = Array.prototype.reduce.call(preferredUsername, (cur, next) => cur + next.charCodeAt(), 0);
bgColor = iconBackgrounds[bgColor % iconBackgrounds.length];
const backgroundImage = !icon || typeof icon === 'string' ? icon : icon.url;
// Replace emoji in summary
if (tag && Array.isArray(tag)) {
tag
.filter(tag => tag.type === 'Emoji' &&
isEmojiShortcode.test(tag.name) &&
tag.icon && tag.icon.mediaType && tag.icon.mediaType.startsWith('image/'))
.forEach((tag) => {
summary = summary.replace(new RegExp(tag.name, 'g'), `<img class="not-responsive emoji" src="${tag.icon.url}" title="${tag.name}" />`);
});
}
const payload = {
cid,
name,
handle: preferredUsername,
slug: `${preferredUsername}@${hostname}`,
description: summary,
descriptionParsed: posts.sanitize(summary),
icon: backgroundImage ? 'fa-none' : 'fa-comments',
color: '#fff',
bgColor,
backgroundImage,
imageClass: 'cover',
numRecentReplies: 1,
// followerCount,
// followingCount,
url,
inbox,
sharedInbox: endpoints ? endpoints.sharedInbox : null,
followersUrl: followers,
};
return payload;
}));
return categories;
};
Mocks.post = async (objects) => {
let single = false;
if (!Array.isArray(objects)) {
@@ -492,7 +561,6 @@ Mocks.notes.public = async (post) => {
const published = post.timestampISO;
const updated = post.edited ? post.editedISO : null;
// todo: post visibility
const to = new Set([activitypub._constants.publicAddress]);
const cc = new Set([`${nconf.get('url')}/uid/${post.user.uid}/followers`]);
@@ -637,13 +705,15 @@ Mocks.notes.public = async (post) => {
* audience is exposed as part of 1b12 but is now ignored by Lemmy.
* Remove this and most references to audience in 2026.
*/
let audience = `${nconf.get('url')}/category/${post.category.cid}`; // default
let audience = utils.isNumber(post.category.cid) ? // default
`${nconf.get('url')}/category/${post.category.cid}` : post.category.cid;
if (inReplyTo) {
const chain = await activitypub.notes.getParentChain(post.uid, inReplyTo);
chain.forEach((post) => {
audience = post.audience || audience;
});
}
to.add(audience);
let object = {
'@context': 'https://www.w3.org/ns/activitystreams',

View File

@@ -79,6 +79,7 @@ Notes.assert = async (uid, input, options = { skipChecks: false }) => {
const hasTid = !!tid;
const cid = hasTid ? await topics.getTopicField(tid, 'cid') : options.cid || -1;
if (options.cid && cid === -1) {
// Move topic if currently uncategorized
await topics.tools.move(tid, { cid: options.cid, uid: 'system' });
@@ -97,16 +98,24 @@ Notes.assert = async (uid, input, options = { skipChecks: false }) => {
if (hasTid) {
mainPid = await topics.getTopicField(tid, 'mainPid');
} else {
// Check recipients/audience for local category
// Check recipients/audience for category (local or remote)
const set = activitypub.helpers.makeSet(_activitypub, ['to', 'cc', 'audience']);
await activitypub.actors.assert(Array.from(set));
// Local
const resolved = await Promise.all(Array.from(set).map(async id => await activitypub.helpers.resolveLocalId(id)));
const recipientCids = resolved
.filter(Boolean)
.filter(({ type }) => type === 'category')
.map(obj => obj.id);
if (recipientCids.length) {
// Remote
const assertedGroups = await db.exists(Array.from(set).map(id => `categoryRemote:${id}`));
const remoteCid = Array.from(set).filter((_, idx) => assertedGroups[idx]).shift();
if (remoteCid || recipientCids.length) {
// Overrides passed-in value, respect addressing from main post over booster
options.cid = recipientCids.shift();
options.cid = remoteCid || recipientCids.shift();
}
// mainPid ok to leave as-is
@@ -130,7 +139,7 @@ Notes.assert = async (uid, input, options = { skipChecks: false }) => {
options.skipChecks || options.cid ||
await assertRelation(chain[inputIndex !== -1 ? inputIndex : 0]);
const privilege = `topics:${tid ? 'reply' : 'create'}`;
const allowed = await privileges.categories.can(privilege, cid, activitypub._constants.uid);
const allowed = await privileges.categories.can(privilege, options.cid || cid, activitypub._constants.uid);
if (!hasRelation || !allowed) {
if (!hasRelation) {
activitypub.helpers.log(`[activitypub/notes.assert] Not asserting ${id} as it has no relation to existing tracked content.`);
@@ -454,6 +463,12 @@ Notes.syncUserInboxes = async function (tid, uid) {
uids.add(uid);
});
// Category followers
const categoryFollowers = await activitypub.actors.getLocalFollowers(cid);
categoryFollowers.uids.forEach((uid) => {
uids.add(uid);
});
const keys = Array.from(uids).map(uid => `uid:${uid}:inbox`);
const score = await db.sortedSetScore(`cid:${cid}:tids`, tid);

View File

@@ -37,13 +37,22 @@ function enabledCheck(next) {
activitypubApi.follow = enabledCheck(async (caller, { type, id, actor } = {}) => {
// Privilege checks should be done upstream
const acceptedTypes = ['uid', 'cid'];
const assertion = await activitypub.actors.assert(actor);
if (!assertion || (Array.isArray(assertion) && assertion.length)) {
if (!acceptedTypes.includes(type) || !assertion || (Array.isArray(assertion) && assertion.length)) {
throw new Error('[[error:activitypub.invalid-id]]');
}
actor = actor.includes('@') ? await user.getUidByUserslug(actor) : actor;
const handle = await user.getUserField(actor, 'username');
const [handle, isFollowing] = await Promise.all([
user.getUserField(actor, 'username'),
db.isSortedSetMember(type === 'uid' ? `followingRemote:${id}` : `cid:${id}:following`, actor),
]);
if (isFollowing) { // already following
return;
}
const timestamp = Date.now();
await db.sortedSetAdd(`followRequests:${type}.${id}`, timestamp, actor);
@@ -61,13 +70,22 @@ activitypubApi.follow = enabledCheck(async (caller, { type, id, actor } = {}) =>
// should be .undo.follow
activitypubApi.unfollow = enabledCheck(async (caller, { type, id, actor }) => {
const acceptedTypes = ['uid', 'cid'];
const assertion = await activitypub.actors.assert(actor);
if (!assertion) {
if (!acceptedTypes.includes(type) || !assertion) {
throw new Error('[[error:activitypub.invalid-id]]');
}
actor = actor.includes('@') ? await user.getUidByUserslug(actor) : actor;
const handle = await user.getUserField(actor, 'username');
const [handle, isFollowing] = await Promise.all([
user.getUserField(actor, 'username'),
db.isSortedSetMember(type === 'uid' ? `followingRemote:${id}` : `cid:${id}:following`, actor),
]);
if (!isFollowing) { // already not following
return;
}
const timestamps = await db.sortedSetsScore([
`followRequests:${type}.${id}`,
type === 'uid' ? `followingRemote:${id}` : `cid:${id}:following`,
@@ -129,7 +147,7 @@ activitypubApi.create.note = enabledCheck(async (caller, { pid, post }) => {
await Promise.all([
activitypub.send('uid', caller.uid, Array.from(targets), activity),
activitypub.feps.announce(pid, activity),
// activitypubApi.add(caller, { pid }),
// utils.isNumber(post.cid) ? activitypubApi.add(caller, { pid }) : undefined,
]);
});

View File

@@ -7,6 +7,7 @@ const events = require('../events');
const user = require('../user');
const groups = require('../groups');
const privileges = require('../privileges');
const utils = require('../utils');
const activitypubApi = require('./activitypub');
@@ -157,7 +158,9 @@ categoriesAPI.getTopics = async (caller, data) => {
categoriesAPI.setWatchState = async (caller, { cid, state, uid }) => {
let targetUid = caller.uid;
const cids = Array.isArray(cid) ? cid.map(cid => parseInt(cid, 10)) : [parseInt(cid, 10)];
let cids = Array.isArray(cid) ? cid : [cid];
cids = cids.map(cid => (utils.isNumber(cid) ? parseInt(cid, 10) : cid));
if (uid) {
targetUid = uid;
}

View File

@@ -87,9 +87,7 @@ topicsAPI.create = async function (caller, data) {
socketHelpers.notifyNew(caller.uid, 'newTopic', { posts: [result.postData], topic: result.topicData });
if (!isScheduling) {
setTimeout(() => {
activitypubApi.create.note(caller, { pid: result.postData.pid });
}, 5000);
await activitypubApi.create.note(caller, { pid: result.postData.pid });
}
return result.topicData;
@@ -125,7 +123,7 @@ topicsAPI.reply = async function (caller, data) {
}
socketHelpers.notifyNew(caller.uid, 'newPost', result);
activitypubApi.create.note(caller, { post: postData });
await activitypubApi.create.note(caller, { post: postData });
return postData;
};

View File

@@ -36,8 +36,8 @@ module.exports = function (Categories) {
return [];
}
cids = cids.map(cid => parseInt(cid, 10));
const keys = cids.map(cid => `category:${cid}`);
cids = cids.map(cid => (utils.isNumber(cid) ? parseInt(cid, 10) : cid));
const keys = cids.map(cid => (utils.isNumber(cid) ? `category:${cid}` : `categoryRemote:${cid}`));
const categories = await db.getObjects(keys, fields);
// Handle cid -1
@@ -87,11 +87,11 @@ module.exports = function (Categories) {
};
Categories.setCategoryField = async function (cid, field, value) {
await db.setObjectField(`category:${cid}`, field, value);
await db.setObjectField(`${utils.isNumber(cid) ? 'category' : 'categoryRemote'}:${cid}`, field, value);
};
Categories.incrementCategoryFieldBy = async function (cid, field, value) {
await db.incrObjectFieldBy(`category:${cid}`, field, value);
await db.incrObjectFieldBy(`${utils.isNumber(cid) ? 'category' : 'categoryRemote'}:${cid}`, field, value);
};
};

View File

@@ -7,7 +7,9 @@ const plugins = require('../plugins');
const topics = require('../topics');
const groups = require('../groups');
const privileges = require('../privileges');
const activitypub = require('../activitypub');
const cache = require('../cache');
const utils = require('../utils');
module.exports = function (Categories) {
Categories.purge = async function (cid, uid) {
@@ -38,6 +40,7 @@ module.exports = function (Categories) {
await removeFromParent(cid);
await deleteTags(cid);
await activitypub.actors.removeGroup(cid);
await db.deleteAll([
`cid:${cid}:tids`,
`cid:${cid}:tids:pinned`,
@@ -51,7 +54,7 @@ module.exports = function (Categories) {
`cid:${cid}:uid:watch:state`,
`cid:${cid}:children`,
`cid:${cid}:tag:whitelist`,
`category:${cid}`,
`${utils.isNumber(cid) ? 'category' : 'categoryRemote'}:${cid}`,
]);
const privilegeList = await privileges.categories.getPrivilegeList();
await groups.destroy(privilegeList.map(privilege => `cid:${cid}:privileges:${privilege}`));

View File

@@ -8,8 +8,10 @@ const user = require('../user');
const topics = require('../topics');
const plugins = require('../plugins');
const privileges = require('../privileges');
const activitypub = require('../activitypub');
const cache = require('../cache');
const meta = require('../meta');
const utils = require('../utils');
const Categories = module.exports;
@@ -26,9 +28,14 @@ require('./search')(Categories);
Categories.icons = require('./icon');
Categories.exists = async function (cids) {
return await db.exists(
Array.isArray(cids) ? cids.map(cid => `category:${cid}`) : `category:${cids}`
);
let keys;
if (Array.isArray(cids)) {
keys = cids.map(cid => (utils.isNumber(cid) ? `category:${cid}` : `categoryRemote:${cid}`));
} else {
keys = utils.isNumber(cids) ? `category:${cids}` : `categoryRemote:${cids}`;
}
return await db.exists(keys);
};
Categories.existsByHandle = async function (handle) {
@@ -51,12 +58,13 @@ Categories.getCategoryById = async function (data) {
Categories.getTopicCount(data),
Categories.getWatchState([data.cid], data.uid),
getChildrenTree(category, data.uid),
!utils.isNumber(data.cid) ? activitypub.actors.getLocalFollowers(data.cid) : null,
];
if (category.parentCid) {
promises.push(Categories.getCategoryData(category.parentCid));
}
const [topics, topicCount, watchState, , parent] = await Promise.all(promises);
const [topics, topicCount, watchState, , localFollowers, parent] = await Promise.all(promises);
category.topics = topics.topics;
category.nextStart = topics.nextStart;
@@ -65,6 +73,7 @@ Categories.getCategoryById = async function (data) {
category.isTracked = watchState[0] === Categories.watchStates.tracking;
category.isNotWatched = watchState[0] === Categories.watchStates.notwatching;
category.isIgnored = watchState[0] === Categories.watchStates.ignoring;
category.hasFollowers = localFollowers ? (localFollowers.uids.size + localFollowers.cids.size) > 0 : localFollowers;
category.parent = parent;
calculateTopicPostCount(category);

View File

@@ -10,6 +10,7 @@ const topics = require('../topics');
const privileges = require('../privileges');
const plugins = require('../plugins');
const batch = require('../batch');
const utils = require('../utils');
module.exports = function (Categories) {
Categories.getRecentReplies = async function (cid, uid, start, stop) {
@@ -27,7 +28,7 @@ module.exports = function (Categories) {
Categories.updateRecentTid = async function (cid, tid) {
const [count, numRecentReplies] = await Promise.all([
db.sortedSetCard(`cid:${cid}:recent_tids`),
db.getObjectField(`category:${cid}`, 'numRecentReplies'),
db.getObjectField(`${utils.isNumber(cid) ? 'category' : 'categoryRemote'}:${cid}`, 'numRecentReplies'),
]);
if (count >= numRecentReplies) {
@@ -131,7 +132,10 @@ module.exports = function (Categories) {
function assignTopicsToCategories(categories, topics) {
categories.forEach((category) => {
if (category) {
category.posts = topics.filter(t => t.cid && (t.cid === category.cid || t.parentCids.includes(category.cid)))
category.posts = topics.filter(
t => t.cid &&
(t.cid === category.cid || (t.parentCids && t.parentCids.includes(category.cid)))
)
.sort((a, b) => b.timestamp - a.timestamp)
.slice(0, parseInt(category.numRecentReplies, 10));
}

View File

@@ -3,7 +3,9 @@
const _ = require('lodash');
const privileges = require('../privileges');
const activitypub = require('../activitypub');
const plugins = require('../plugins');
const utils = require('../utils');
const db = require('../database');
module.exports = function (Categories) {
@@ -15,6 +17,10 @@ module.exports = function (Categories) {
const startTime = process.hrtime();
if (activitypub.helpers.isWebfinger(query)) {
await activitypub.actors.assertGroup([query]);
}
let cids = await findCids(query, data.hardCap);
const result = await plugins.hooks.fire('filter:categories.search', {
@@ -71,7 +77,12 @@ module.exports = function (Categories) {
match: `*${String(query).toLowerCase()}*`,
limit: hardCap || 500,
});
return data.map(data => parseInt(data.split(':').pop(), 10));
return data.map((data) => {
const split = data.split(':');
split.shift();
const cid = split.join(':');
return utils.isNumber(cid) ? parseInt(cid, 10) : cid;
});
}
async function getChildrenCids(cids, uid) {

View File

@@ -9,6 +9,7 @@ const user = require('../user');
const notifications = require('../notifications');
const translator = require('../translator');
const batch = require('../batch');
const utils = require('../utils');
module.exports = function (Categories) {
Categories.getCategoryTopics = async function (data) {
@@ -186,7 +187,7 @@ module.exports = function (Categories) {
}
const promises = [
db.sortedSetAdd(`cid:${cid}:pids`, postData.timestamp, postData.pid),
db.incrObjectField(`category:${cid}`, 'post_count'),
db.incrObjectField(`${utils.isNumber(cid) ? 'category' : 'categoryRemote'}:${cid}`, 'post_count'),
];
if (!pinned) {
promises.push(db.sortedSetIncrBy(`cid:${cid}:tids:posts`, 1, postData.tid));
@@ -254,18 +255,29 @@ module.exports = function (Categories) {
notifications.push(notification, followers);
};
Categories.sortTidsBySet = async (tids, cid, sort) => {
sort = sort || meta.config.categoryTopicSort || 'recently_replied';
const sortToSet = {
recently_replied: `cid:${cid}:tids`,
recently_created: `cid:${cid}:tids:create`,
most_posts: `cid:${cid}:tids:posts`,
most_votes: `cid:${cid}:tids:votes`,
most_views: `cid:${cid}:tids:views`,
};
Categories.sortTidsBySet = async (tids, sort) => {
let cids = await topics.getTopicsFields(tids, ['cid']);
cids = cids.map(({ cid }) => cid);
function getSet(cid, sort) {
sort = sort || meta.config.categoryTopicSort || 'recently_replied';
const sortToSet = {
recently_replied: `cid:${cid}:tids`,
recently_created: `cid:${cid}:tids:create`,
most_posts: `cid:${cid}:tids:posts`,
most_votes: `cid:${cid}:tids:votes`,
most_views: `cid:${cid}:tids:views`,
};
return sortToSet[sort];
}
const scores = await Promise.all(tids.map(async (tid, idx) => {
const cid = cids[idx];
const orderBy = getSet(cid, sort);
return await db.sortedSetScore(orderBy, tid);
}));
const orderBy = sortToSet[sort];
const scores = await db.sortedSetScores(orderBy, tids);
const sorted = tids
.map((tid, idx) => [tid, scores[idx]])
.sort(([, a], [, b]) => b - a)

View File

@@ -3,6 +3,7 @@
const db = require('../database');
const user = require('../user');
const activitypub = require('../activitypub');
const utils = require('../utils');
module.exports = function (Categories) {
Categories.watchStates = {
@@ -32,7 +33,11 @@ module.exports = function (Categories) {
user.getSettings(uid),
db.sortedSetsScore(keys, uid),
]);
return states.map(state => state || Categories.watchStates[userSettings.categoryWatchState]);
const fallbacks = cids.map(cid => (utils.isNumber(cid) ?
Categories.watchStates[userSettings.categoryWatchState] : Categories.watchStates.notwatching));
return states.map((state, idx) => state || fallbacks[idx]);
};
Categories.getIgnorers = async function (cid, start, stop) {

View File

@@ -1,17 +1,17 @@
'use strict';
const nconf = require('nconf');
const _ = require('lodash');
const user = require('../../user');
const topics = require('../../topics');
const pagination = require('../../pagination');
const helpers = require('../helpers');
const categories = require('../../categories');
const privileges = require('../../privileges');
const translator = require('../../translator');
const meta = require('../../meta');
const pagination = require('../../pagination');
const utils = require('../../utils');
const helpers = require('../helpers');
const controller = module.exports;
@@ -52,11 +52,29 @@ controller.list = async function (req, res) {
delete data.children;
let tids = await categories.getTopicIds(cidQuery);
tids = await categories.sortTidsBySet(tids, -1, sort); // sorting not handled if cid is -1
tids = await categories.sortTidsBySet(tids, sort); // sorting not handled if cid is -1
data.topicCount = tids.length;
data.topics = await topics.getTopicsByTids(tids, { uid: req.uid });
topics.calculateTopicIndices(data.topics, start);
// Tracked/watched categories
let cids = await user.getCategoriesByStates(req.uid, [
categories.watchStates.tracking, categories.watchStates.watching,
]);
cids = cids.filter(cid => !utils.isNumber(cid));
const categoryData = await categories.getCategories(cids);
data.categories = categories.getTree(categoryData, 0);
await Promise.all([
categories.getRecentTopicReplies(categoryData, req.uid, req.query),
categories.setUnread(data.categories, cids, req.uid),
]);
data.categories.forEach((category) => {
if (category) {
helpers.trimChildren(category);
helpers.setCategoryTeaser(category);
}
});
data.title = translator.escape(data.name);
data.privileges = userPrivileges;
data.selectedTag = tagData.selectedTag;

View File

@@ -26,14 +26,25 @@ const validSorts = [
];
categoryController.get = async function (req, res, next) {
const cid = req.params.category_id;
let cid = req.params.category_id;
if (cid === '-1') {
return helpers.redirect(res, `${res.locals.isAPI ? '/api' : ''}/world?${qs.stringify(req.query)}`);
}
if (!utils.isNumber(cid)) {
const assertion = await activitypub.actors.assertGroup([cid]);
if (!activitypub.helpers.isUri(cid)) {
cid = await db.getObjectField('handle:cid', cid);
}
if (!assertion || !cid) {
return next();
}
}
let currentPage = parseInt(req.query.page, 10) || 1;
let topicIndex = utils.isNumber(req.params.topic_index) ? parseInt(req.params.topic_index, 10) - 1 : 0;
if ((req.params.topic_index && !utils.isNumber(req.params.topic_index)) || !utils.isNumber(cid)) {
if ((req.params.topic_index && !utils.isNumber(req.params.topic_index))) {
return next();
}
@@ -58,7 +69,7 @@ categoryController.get = async function (req, res, next) {
return helpers.notAllowed(req, res);
}
if (!res.locals.isAPI && !req.params.slug && (categoryFields.slug && categoryFields.slug !== `${cid}/`)) {
if (utils.isNumber(cid) && !res.locals.isAPI && !req.params.slug && (categoryFields.slug && categoryFields.slug !== `${cid}/`)) {
return helpers.redirect(res, `/category/${categoryFields.slug}?${qs.stringify(req.query)}`, true);
}

View File

@@ -105,7 +105,11 @@ middleware.assertPayload = async function (req, res, next) {
// Cross-check key ownership against received actor
await activitypub.actors.assert(actor);
const compare = ((await db.getObjectField(`userRemote:${actor}:keys`, 'id')) || '').replace(/#[\w-]+$/, '');
let compare = await db.getObjectsFields([
`userRemote:${actor}:keys`, `categoryRemote:${actor}:keys`,
], ['id']);
compare = compare.reduce((keyId, { id }) => keyId || id || '', '').replace(/#[\w-]+$/, '');
const { signature } = req.headers;
let keyId = new Map(signature.split(',').filter(Boolean).map((v) => {
const index = v.indexOf('=');

View File

@@ -9,6 +9,7 @@ const user = require('../user');
const categories = require('../categories');
const plugins = require('../plugins');
const translator = require('../translator');
const utils = require('../utils');
const helpers = module.exports;
@@ -19,6 +20,11 @@ const uidToSystemGroup = {
};
helpers.isUsersAllowedTo = async function (privilege, uids, cid) {
// Remote categories inherit world pseudo-category privileges
if (!utils.isNumber(cid)) {
cid = -1;
}
const [hasUserPrivilege, hasGroupPrivilege] = await Promise.all([
groups.isMembers(uids, `cid:${cid}:privileges:${privilege}`),
groups.isMembersOfGroupList(uids, `cid:${cid}:privileges:groups:${privilege}`),
@@ -29,6 +35,13 @@ helpers.isUsersAllowedTo = async function (privilege, uids, cid) {
};
helpers.isAllowedTo = async function (privilege, uidOrGroupName, cid) {
// Remote categories (non-numeric) inherit world privileges
if (Array.isArray(cid)) {
cid = cid.map(cid => (utils.isNumber(cid) ? cid : -1));
} else {
cid = utils.isNumber(cid) ? cid : -1;
}
let allowed;
if (Array.isArray(privilege) && !Array.isArray(cid)) {
allowed = await isAllowedToPrivileges(privilege, uidOrGroupName, cid);

View File

@@ -9,13 +9,13 @@ const utils = require('../../utils');
module.exports = function (SocketTopics) {
SocketTopics.isTagAllowed = async function (socket, data) {
if (!data || !utils.isNumber(data.cid) || !data.tag) {
if (!data || !data.tag) {
throw new Error('[[error:invalid-data]]');
}
const systemTags = (meta.config.systemTags || '').split(',');
const [tagWhitelist, isPrivileged] = await Promise.all([
categories.getTagWhitelist([data.cid]),
utils.isNumber(data.cid) ? categories.getTagWhitelist([data.cid]) : [],
user.isPrivileged(socket.uid),
]);
return isPrivileged ||

View File

@@ -1,6 +1,7 @@
'use strict';
const topics = require('../../topics');
const categories = require('../../categories');
const api = require('../../api');
const sockets = require('..');
@@ -32,6 +33,10 @@ module.exports = function (SocketTopics) {
};
SocketTopics.markCategoryTopicsRead = async function (socket, cid) {
const exists = await categories.exists(cid);
if (!exists) {
throw new Error('[[error:invalid-cid]]');
}
const tids = await topics.getUnreadTids({ cid: cid, uid: socket.uid, filter: '' });
await SocketTopics.markAsRead(socket, tids);
};

View File

@@ -67,7 +67,7 @@ module.exports = function (Topics) {
db.sortedSetsAdd(timestampedSortedSetKeys, timestamp, topicData.tid),
db.sortedSetsAdd(countedSortedSetKeys, 0, topicData.tid),
user.addTopicIdToUser(topicData.uid, topicData.tid, timestamp),
db.incrObjectField(`category:${topicData.cid}`, 'topic_count'),
db.incrObjectField(`${utils.isNumber(topicData.cid) ? 'category' : 'categoryRemote'}:${topicData.cid}`, 'topic_count'),
utils.isNumber(tid) ? db.incrObjectField('global', 'topicCount') : null,
Topics.createTags(data.tags, topicData.tid, timestamp),
scheduled ? Promise.resolve() : categories.updateRecentTid(topicData.cid, topicData.tid),

View File

@@ -145,8 +145,8 @@ module.exports = function (Topics) {
const postCountChange = incr * topicData.postcount;
await Promise.all([
db.incrObjectFieldBy('global', 'postCount', postCountChange),
db.incrObjectFieldBy(`category:${topicData.cid}`, 'post_count', postCountChange),
db.incrObjectFieldBy(`category:${topicData.cid}`, 'topic_count', incr),
db.incrObjectFieldBy(`${utils.isNumber(topicData.cid) ? 'category' : 'categoryRemote'}:${topicData.cid}`, 'post_count', postCountChange),
db.incrObjectFieldBy(`${utils.isNumber(topicData.cid) ? 'category' : 'categoryRemote'}:${topicData.cid}`, 'topic_count', incr),
]);
}
};

View File

@@ -74,8 +74,7 @@ Topics.getTopicsByTids = async function (tids, options) {
.map(t => t && t.uid && t.uid.toString())
.filter(v => utils.isNumber(v) || activitypub.helpers.isUri(v)));
const cids = _.uniq(topics
.map(t => t && t.cid && t.cid.toString())
.filter(v => utils.isNumber(v)));
.map(t => t && t.cid && t.cid.toString()));
const guestTopics = topics.filter(t => t && t.uid === 0);
async function loadGuestHandles() {

View File

@@ -4,6 +4,7 @@
const db = require('../database');
const plugins = require('../plugins');
const posts = require('../posts');
const utils = require('../utils');
module.exports = function (Topics) {
const terms = {
@@ -75,7 +76,7 @@ module.exports = function (Topics) {
// Topics in /world are excluded from /recent
const cid = await Topics.getTopicField(tid, 'cid');
if (cid === -1) {
if (!utils.isNumber(cid) || cid === -1) {
return await db.sortedSetRemove('topics:recent', data.tid);
}

View File

@@ -233,7 +233,7 @@ module.exports = function (Topics) {
};
topicTools.move = async function (tid, data) {
const cid = parseInt(data.cid, 10);
const cid = utils.isNumber(data.cid) ? parseInt(data.cid, 10) : data.cid;
const topicData = await Topics.getTopicData(tid);
if (!topicData) {
throw new Error('[[error:no-topic]]');

View File

@@ -148,7 +148,7 @@ module.exports = function (Topics) {
const categoryWatchState = await categories.getWatchState(topicCids, params.uid);
const userCidState = _.zipObject(topicCids, categoryWatchState);
const filterCids = params.cid && params.cid.map(cid => parseInt(cid, 10));
const filterCids = params.cid && params.cid.map(cid => utils.isNumber(cid) ? parseInt(cid, 10) : cid);
const filterTags = params.tag && params.tag.map(tag => String(tag));
topicData.forEach((topic) => {
@@ -325,9 +325,7 @@ module.exports = function (Topics) {
};
Topics.markAllRead = async function (uid) {
const cutoff = await Topics.unreadCutoff(uid);
let tids = await db.getSortedSetRevRangeByScore('topics:recent', 0, -1, '+inf', cutoff);
tids = await privileges.topics.filterTids('topics:read', tids, uid);
const tids = await Topics.getUnreadTids({ uid });
Topics.markTopicNotificationsRead(tids, uid);
await Topics.markAsRead(tids, uid);
await db.delete(`uid:${uid}:tids_unread`);

View File

@@ -3,8 +3,10 @@
const _ = require('lodash');
const db = require('../database');
const meta = require('../meta');
const categories = require('../categories');
const plugins = require('../plugins');
const api = require('../api');
const utils = require('../utils');
module.exports = function (User) {
@@ -27,7 +29,18 @@ module.exports = function (User) {
if (exists.includes(false)) {
throw new Error('[[error:no-category]]');
}
await db.sortedSetsAdd(cids.map(cid => `cid:${cid}:uid:watch:state`), state, uid);
const apiMethod = state >= categories.watchStates.tracking ? 'follow' : 'unfollow';
const follows = cids.filter(cid => !utils.isNumber(cid)).map(cid => api.activitypub[apiMethod]({ uid }, {
type: 'uid',
id: uid,
actor: cid,
})); // returns promises
await Promise.all([
db.sortedSetsAdd(cids.map(cid => `cid:${cid}:uid:watch:state`), state, uid),
...follows,
]);
};
User.getCategoryWatchState = async function (uid) {
@@ -67,7 +80,11 @@ module.exports = function (User) {
};
User.getCategoriesByStates = async function (uid, states) {
const cids = await categories.getAllCidsFromSet('categories:cid');
const [localCids, remoteCids] = await Promise.all([
categories.getAllCidsFromSet('categories:cid'),
meta.config.activitypubEnabled ? db.getObjectValues('handle:cid') : [],
]);
const cids = localCids.concat(remoteCids);
if (!(parseInt(uid, 10) > 0)) {
return cids;
}

View File

@@ -122,6 +122,7 @@ module.exports = function (User) {
`uid:${uid}:upvote`, `uid:${uid}:downvote`,
`uid:${uid}:flag:pids`,
`uid:${uid}:sessions`,
`uid:${uid}:shares`,
`invitation:uid:${uid}`,
];
@@ -158,9 +159,10 @@ module.exports = function (User) {
activitypub.actors.remove(uid),
]);
await db.deleteAll([
`followers:${uid}`, `following:${uid}`, `user:${uid}`,
`followers:${uid}`, `following:${uid}`,
`uid:${uid}:followed_tags`, `uid:${uid}:followed_tids`,
`uid:${uid}:ignored_tids`,
`${utils.isNumber(uid) ? 'user' : 'userRemote'}:${uid}`,
]);
delete deletesInProgress[uid];
return userData;

View File

@@ -82,11 +82,15 @@ module.exports = function (User) {
if (parseInt(uid, 10) <= 0) {
return [];
}
const uids = await db.getSortedSetRevRange([
let uids = await db.getSortedSetRevRange([
`${type}:${uid}`,
`${type}Remote:${uid}`,
], start, stop);
// Filter out remote categories
const isCategory = await db.exists(uids.map(uid => `categoryRemote:${uid}`));
uids = uids.filter((uid, idx) => !isCategory[idx])
const data = await plugins.hooks.fire(`filter:user.${type}`, {
uids: uids,
uid: uid,

View File

@@ -46,9 +46,11 @@ User.exists = async function (uids) {
const singular = !Array.isArray(uids);
uids = singular ? [uids] : uids;
let results = await Promise.all(uids.map(async uid => await db.isMemberOfSortedSets(['users:joindate', 'usersRemote:lastCrawled'], uid)));
results = results.map(set => set.some(Boolean));
const [localExists, remoteExists] = await Promise.all([
db.isSortedSetMembers('users:joindate', uids),
meta.config.activitypubEnabled ? db.exists(uids.map(uid => `userRemote:${uid}`)) : uids.map(() => false),
]);
const results = localExists.map((local, idx) => local || remoteExists[idx]);
return singular ? results.pop() : results;
};

View File

@@ -5,6 +5,7 @@ const nconf = require('nconf');
const db = require('../mocks/databasemock');
const meta = require('../../src/meta');
const install = require('../../src/install');
const categories = require('../../src/categories');
const user = require('../../src/user');
const topics = require('../../src/topics');
@@ -13,7 +14,14 @@ const utils = require('../../src/utils');
const request = require('../../src/request');
const slugify = require('../../src/slugify');
const helpers = require('./helpers');
describe('Actor asserton', () => {
before(async () => {
meta.config.activitypubEnabled = 1;
await install.giveWorldPrivileges();
});
describe('happy path', () => {
let uid;
let actorUri;
@@ -58,9 +66,139 @@ describe('Actor asserton', () => {
const url = await user.getUserField(actorUri, 'url');
assert.strictEqual(url, actorUri);
});
it('should assert group actors by calling actors.assertGroup', async () => {
const { id, actor } = helpers.mocks.group();
const assertion = await activitypub.actors.assert([id]);
assert(assertion);
assert.strictEqual(assertion.length, 1);
assert.strictEqual(assertion[0].cid, actor.id);
});
describe('remote user to remote category migration', () => {
it('should not migrate a user to a category if .assert is called', async () => {
// ... because the user isn't due for an update and so is filtered out during qualification
const { id } = helpers.mocks.person();
await activitypub.actors.assert([id]);
const { actor } = helpers.mocks.group({ id });
const assertion = await activitypub.actors.assertGroup([id]);
assert(assertion.length, 0);
const exists = await user.exists(id);
assert.strictEqual(exists, false);
});
it('should migrate a user to a category if on re-assertion it identifies as an as:Group', async () => {
// This is to handle previous behaviour that saved all as:Group actors as NodeBB users.
const { id } = helpers.mocks.person();
await activitypub.actors.assert([id]);
helpers.mocks.group({ id });
const assertion = await activitypub.actors.assertGroup([id]);
assert(assertion && Array.isArray(assertion) && assertion.length === 1);
const exists = await user.exists(id);
assert.strictEqual(exists, false);
});
it('should migrate any shares by that user, into topics in the category', async () => {
const { id } = helpers.mocks.person();
await activitypub.actors.assert([id]);
// Two shares
for (let x = 0; x < 2; x++) {
const { id: pid } = helpers.mocks.note();
// eslint-disable-next-line no-await-in-loop
const { tid } = await activitypub.notes.assert(0, pid, { skipChecks: 1 });
// eslint-disable-next-line no-await-in-loop
await db.sortedSetAdd(`uid:${id}:shares`, Date.now(), tid);
}
helpers.mocks.group({ id });
await activitypub.actors.assertGroup([id]);
const { topic_count, post_count } = await categories.getCategoryData(id);
assert.strictEqual(topic_count, 2);
assert.strictEqual(post_count, 2);
});
it('should not migrate shares by that user that already belong to a local category', async () => {
const { id } = helpers.mocks.person();
await activitypub.actors.assert([id]);
const { cid } = await categories.create({ name: utils.generateUUID() });
// Two shares, one moved to local cid
for (let x = 0; x < 2; x++) {
const { id: pid } = helpers.mocks.note();
// eslint-disable-next-line no-await-in-loop
const { tid } = await activitypub.notes.assert(0, pid, { skipChecks: 1 });
// eslint-disable-next-line no-await-in-loop
await db.sortedSetAdd(`uid:${id}:shares`, Date.now(), tid);
if (!x) {
await topics.tools.move(tid, {
cid,
uid: 'system',
});
}
}
helpers.mocks.group({ id });
await activitypub.actors.assertGroup([id]);
const { topic_count, post_count } = await categories.getCategoryData(id);
assert.strictEqual(topic_count, 1);
assert.strictEqual(post_count, 1);
});
it('should migrate any local followers into category watches', async () => {
const { id } = helpers.mocks.person();
await activitypub.actors.assert([id]);
const followerUid = await user.create({ username: utils.generateUUID() });
await Promise.all([
db.sortedSetAdd(`followingRemote:${followerUid}`, Date.now(), id),
db.sortedSetAdd(`followersRemote:${id}`, Date.now(), followerUid),
]);
helpers.mocks.group({ id });
await activitypub.actors.assertGroup([id]);
const states = await categories.getWatchState([id], followerUid);
assert.strictEqual(states[0], categories.watchStates.tracking);
})
})
});
describe('edge case: loopback handles and uris', () => {
describe('less happy paths', () => {
describe('actor with `preferredUsername` that is not all lowercase', () => {
it('should save a handle-to-uid association', async () => {
const preferredUsername = 'nameWITHCAPS';
const { id } = helpers.mocks.person({ preferredUsername });
await activitypub.actors.assert([id]);
const uid = await db.getObjectField('handle:uid', `${preferredUsername.toLowerCase()}@example.org`);
assert.strictEqual(uid, id);
});
it('should preserve that association when re-asserted', async () => {
const preferredUsername = 'nameWITHCAPS';
const { id } = helpers.mocks.person({ preferredUsername });
await activitypub.actors.assert([id]);
await activitypub.actors.assert([id], { update: true });
const uid = await db.getObjectField('handle:uid', `${preferredUsername.toLowerCase()}@example.org`);
assert.strictEqual(uid, id);
})
});
});
describe('edge cases: loopback handles and uris', () => {
let uid;
const userslug = utils.generateUUID().slice(0, 8);
before(async () => {
@@ -90,6 +228,257 @@ describe('Actor asserton', () => {
});
});
describe('as:Group', () => {
describe('assertion', () => {
let actorUri;
let actorData;
before(async () => {
const { id, actor } = helpers.mocks.group();
actorUri = id;
actorData = actor;
});
it('should assert a uri identifying as "Group" into a remote category', async () => {
const assertion = await activitypub.actors.assertGroup([actorUri]);
assert(assertion, Array.isArray(assertion));
assert.strictEqual(assertion.length, 1);
const category = assertion.pop();
assert.strictEqual(category.cid, actorUri);
});
it('should be considered existing when checked', async () => {
const exists = await categories.exists(actorUri);
assert(exists);
});
it('should contain an entry in categories search zset', async () => {
const exists = await db.isSortedSetMember('categories:name', `${actorData.name.toLowerCase()}:${actorUri}`);
assert(exists);
});
it('should return category data when getter methods are called', async () => {
const category = await categories.getCategoryData(actorUri);
assert(category);
assert.strictEqual(category.cid, actorUri);
});
it('should not assert non-group users when called', async () => {
const { id } = helpers.mocks.person();
const assertion = await activitypub.actors.assertGroup([id]);
assert(Array.isArray(assertion) && !assertion.length);
});
describe('deletion', () => {
it('should delete a remote category when Categories.purge is called', async () => {
const { id } = helpers.mocks.group();
await activitypub.actors.assertGroup([id]);
let exists = await categories.exists(id);
assert(exists);
await categories.purge(id, 0);
exists = await categories.exists(id);
assert(!exists);
exists = await db.exists(`categoryRemote:${id}`);
assert(!exists);
});
it('should also delete AP-specific keys that were added by assertGroup', async () => {
const { id } = helpers.mocks.group();
const assertion = await activitypub.actors.assertGroup([id]);
const [{ handle, slug }] = assertion;
await categories.purge(id, 0);
const isMember = await db.isObjectField('handle:cid', handle);
const inSearch = await db.isSortedSetMember('categories:name', `${slug}:${id}`);
assert(!isMember);
assert(!inSearch);
});
});
});
describe('following', () => {
let uid;
let cid;
beforeEach(async () => {
uid = await user.create({ username: utils.generateUUID() });
({ id: cid } = helpers.mocks.group());
await activitypub.actors.assertGroup([cid]);
});
afterEach(async () => {
activitypub._sent.clear();
});
describe('user not already following', () => {
it('should report a watch state consistent with not following', async () => {
const states = await categories.getWatchState([cid], uid);
assert(states[0] <= categories.watchStates.notwatching);
});
it('should do nothing when category is a local category', async () => {
const { cid } = await categories.create({ name: utils.generateUUID() });
await user.setCategoryWatchState(uid, cid, categories.watchStates.tracking);
assert.strictEqual(activitypub._sent.size, 0);
});
it('should do nothing when watch state changes to "ignoring"', async () => {
await user.setCategoryWatchState(uid, cid, categories.watchStates.ignoring);
assert.strictEqual(activitypub._sent.size, 0);
});
it('should send out a Follow activity when watch state changes to "tracking"', async () => {
await user.setCategoryWatchState(uid, cid, categories.watchStates.tracking);
assert.strictEqual(activitypub._sent.size, 1);
const activity = Array.from(activitypub._sent.values()).pop();
assert.strictEqual(activity.type, 'Follow');
assert.strictEqual(activity.object, cid);
});
it('should send out a Follow activity when the watch state changes to "watching"', async () => {
await user.setCategoryWatchState(uid, cid, categories.watchStates.watching);
assert.strictEqual(activitypub._sent.size, 1);
const activity = Array.from(activitypub._sent.values()).pop();
assert(activity && activity.object && typeof activity.object === 'string');
assert.strictEqual(activity.type, 'Follow');
assert.strictEqual(activity.object, cid);
});
it('should not show up in the user\'s following list', async () => {
await user.setCategoryWatchState(uid, cid, categories.watchStates.watching);
// Trigger inbox accept
const { activity: body } = helpers.mocks.accept(cid, {
type: 'Follow',
actor: `${nconf.get('url')}/uid/${uid}`,
});
await activitypub.inbox.accept({ body });
const following = await user.getFollowing(uid, 0, 1);
assert(Array.isArray(following));
assert.strictEqual(following.length, 0);
});
});
describe('user already following', () => {
beforeEach(async () => {
await Promise.all([
user.setCategoryWatchState(uid, cid, categories.watchStates.tracking),
db.sortedSetAdd(`followingRemote:${uid}`, Date.now(), cid),
]);
activitypub._sent.clear();
});
it('should report a watch state consistent with following', async () => {
const states = await categories.getWatchState([cid], uid);
assert(states[0] >= categories.watchStates.tracking);
});
it('should do nothing when category is a local category', async () => {
const { cid } = await categories.create({ name: utils.generateUUID() });
await user.setCategoryWatchState(uid, cid, categories.watchStates.ignoring);
assert.strictEqual(activitypub._sent.size, 0);
});
it('should do nothing when watch state changes to "tracking"', async () => {
await user.setCategoryWatchState(uid, cid, categories.watchStates.tracking);
assert.strictEqual(activitypub._sent.size, 0);
});
it('should do nothing when watch state changes to "watching"', async () => {
await user.setCategoryWatchState(uid, cid, categories.watchStates.watching);
assert.strictEqual(activitypub._sent.size, 0);
});
it('should send out an Undo(Follow) activity when watch state changes to "ignoring"', async () => {
await user.setCategoryWatchState(uid, cid, categories.watchStates.ignoring);
assert.strictEqual(activitypub._sent.size, 1);
const activity = Array.from(activitypub._sent.values()).pop();
assert(activity && activity.object && typeof activity.object === 'object');
assert.strictEqual(activity.type, 'Undo');
assert.strictEqual(activity.object.type, 'Follow');
assert.strictEqual(activity.object.actor, `${nconf.get('url')}/uid/${uid}`);
assert.strictEqual(activity.object.object, cid);
});
});
});
});
describe('Inbox resolution', () => {
describe('remote users', () => {
it('should return an inbox if present', async () => {
const { id, actor } = helpers.mocks.person();
await activitypub.actors.assert(id);
const inboxes = await activitypub.resolveInboxes([id]);
assert(inboxes && Array.isArray(inboxes));
assert.strictEqual(inboxes.length, 1);
assert.strictEqual(inboxes[0], actor.inbox);
});
it('should return a shared inbox if present', async () => {
const { id, actor } = helpers.mocks.person({
endpoints: {
sharedInbox: 'https://example.org/inbox',
}
});
await activitypub.actors.assert(id);
const inboxes = await activitypub.resolveInboxes([id]);
assert(inboxes && Array.isArray(inboxes));
assert.strictEqual(inboxes.length, 1);
assert.strictEqual(inboxes[0], 'https://example.org/inbox');
});
});
describe('remote categories', () => {
it('should return an inbox if present', async () => {
const { id, actor } = helpers.mocks.group();
await activitypub.actors.assertGroup(id);
const inboxes = await activitypub.resolveInboxes([id]);
assert(inboxes && Array.isArray(inboxes));
assert.strictEqual(inboxes.length, 1);
assert.strictEqual(inboxes[0], actor.inbox);
});
it('should return a shared inbox if present', async () => {
const { id, actor } = helpers.mocks.group({
endpoints: {
sharedInbox: 'https://example.org/inbox',
}
});
await activitypub.actors.assertGroup(id);
const inboxes = await activitypub.resolveInboxes([id]);
assert(inboxes && Array.isArray(inboxes));
assert.strictEqual(inboxes.length, 1);
assert.strictEqual(inboxes[0], 'https://example.org/inbox');
});
});
});
describe('Controllers', () => {
describe('User Actor endpoint', () => {
let uid;
@@ -393,3 +782,106 @@ describe('Controllers', () => {
});
});
});
describe('Pruning', () => {
before(async () => {
meta.config.activitypubEnabled = 1;
await install.giveWorldPrivileges();
meta.config.activitypubUserPruneDays = 0; // trigger immediate pruning
});
after(() => {
meta.config.activitypubUserPruneDays = 7;
});
describe('Users', () => {
it('should do nothing if the user is newer than the prune cutoff', async () => {
const { id: uid } = helpers.mocks.person();
await activitypub.actors.assert([uid]);
meta.config.activitypubUserPruneDays = 1;
const result = await activitypub.actors.prune();
assert.strictEqual(result.counts.deleted, 0);
assert.strictEqual(result.counts.preserved, 0);
assert.strictEqual(result.counts.missing, 0);
meta.config.activitypubUserPruneDays = 0;
user.deleteAccount(uid);
});
it('should purge the user if they have no content (posts, likes, etc.)', async () => {
const { id: uid } = helpers.mocks.person();
await activitypub.actors.assert([uid]);
const total = await db.sortedSetCard('usersRemote:lastCrawled');
const result = await activitypub.actors.prune();
assert(result.counts.deleted >= 1);
});
it('should do nothing if the user has some content (e.g. a topic)', async () => {
const { cid } = await categories.create({ name: utils.generateUUID() });
const { id: uid } = helpers.mocks.person();
const { id, note } = helpers.mocks.note({
attributedTo: uid,
cc: [`${nconf.get('url')}/category/${cid}`],
});
const assertion = await activitypub.notes.assert(0, id);
assert(assertion);
const result = await activitypub.actors.prune();
assert.strictEqual(result.counts.deleted, 0);
assert.strictEqual(result.counts.preserved, 1);
assert.strictEqual(result.counts.missing, 0);
});
});
describe('Categories', () => {
it('should do nothing if the category is newer than the prune cutoff', async () => {
const { id: cid } = helpers.mocks.group();
await activitypub.actors.assertGroup([cid]);
meta.config.activitypubUserPruneDays = 1;
const result = await activitypub.actors.prune();
assert.strictEqual(result.counts.deleted, 0);
assert.strictEqual(result.counts.preserved, 0);
assert.strictEqual(result.counts.missing, 0);
meta.config.activitypubUserPruneDays = 0;
await categories.purge(cid, 0);
});
it('should purge the category if it has no topics in it', async () => {
const { id: cid } = helpers.mocks.group();
await activitypub.actors.assertGroup([cid]);
const total = await db.sortedSetCard('usersRemote:lastCrawled');
const result = await activitypub.actors.prune();
assert.strictEqual(result.counts.deleted, 1);
assert.strictEqual(result.counts.preserved, total - 1);
});
it('should do nothing if the category has topics in it', async () => {
const { id: cid } = helpers.mocks.group();
await activitypub.actors.assertGroup([cid]);
const { id } = helpers.mocks.note({
cc: [cid],
});
await activitypub.notes.assert(0, id);
const total = await db.sortedSetCard('usersRemote:lastCrawled');
const result = await activitypub.actors.prune();
assert.strictEqual(result.counts.deleted, 0);
assert.strictEqual(result.counts.preserved, total);
assert(result.preserved.has(cid));
});
});
});

View File

@@ -37,8 +37,7 @@ describe('FEPs', () => {
await groups.join('administrators', adminUid);
uid = await user.create({ username: utils.generateUUID() });
const { id: followerId, actor } = helpers.mocks.actor();
activitypub._cache.set(`0;${followerId}`, actor);
const { id: followerId, actor } = helpers.mocks.person();
user.setCategoryWatchState(followerId, [cid], categories.watchStates.tracking);
activitypub._sent.clear();

View File

@@ -8,32 +8,50 @@ const Helpers = module.exports;
Helpers.mocks = {};
Helpers.mocks.actor = () => {
Helpers.mocks.person = (override = {}) => {
const baseUrl = 'https://example.org';
const uuid = utils.generateUUID();
const id = `${baseUrl}/${uuid}`;
let id = `${baseUrl}/${uuid}`;
if (override.hasOwnProperty('id')) {
id = override.id;
}
const actor = {
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
],
id: `${id}`,
id,
url: `${id}`,
inbox: `${id}/inbox`,
outbox: `${id}/outbox`,
type: 'Person',
name: slugify(uuid),
preferredUsername: uuid,
name: slugify(id),
preferredUsername: id,
publicKey: {
id: `${id}#key`,
owner: `${id}`,
publicKeyPem: 'todo',
},
...override,
};
activitypub._cache.set(`0;${id}`, actor);
return { id, actor };
};
Helpers.mocks.group = (override = {}) => {
const { id, actor } = Helpers.mocks.person({
type: 'Group',
...override,
});
activitypub._cache.set(`0;${id}`, actor);
return { id, actor };
};
@@ -88,3 +106,20 @@ Helpers.mocks.create = (object) => {
return { id, activity };
};
Helpers.mocks.accept = (actor, object) => {
const baseUrl = 'https://example.org';
const uuid = utils.generateUUID();
const id = `${baseUrl}/activity/${uuid}`;
const activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
id,
type: 'Accept',
to: ['https://www.w3.org/ns/activitystreams#Public'],
actor,
object,
};
return { activity };
}

View File

@@ -10,18 +10,19 @@ const user = require('../../src/user');
const categories = require('../../src/categories');
const posts = require('../../src/posts');
const topics = require('../../src/topics');
const api = require('../../src/api');
const activitypub = require('../../src/activitypub');
const utils = require('../../src/utils');
const helpers = require('./helpers');
describe('Notes', () => {
describe('Assertion', () => {
before(async () => {
meta.config.activitypubEnabled = 1;
await install.giveWorldPrivileges();
});
before(async () => {
meta.config.activitypubEnabled = 1;
await install.giveWorldPrivileges();
});
describe('Assertion', () => {
describe('Public objects', () => {
it('should pull a remote root-level object by its id and create a new topic', async () => {
const { id } = helpers.mocks.note();
@@ -63,6 +64,152 @@ describe('Notes', () => {
const exists = await topics.exists(tid);
assert(exists);
});
describe('Category-specific behaviours', () => {
it('should slot newly created topic in local category if addressed', async () => {
const { cid } = await categories.create({ name: utils.generateUUID() });
const { id } = helpers.mocks.note({
cc: [`${nconf.get('url')}/category/${cid}`],
});
const assertion = await activitypub.notes.assert(0, id);
assert(assertion);
const { tid, count } = assertion;
assert(tid);
assert.strictEqual(count, 1);
const topic = await topics.getTopicData(tid);
assert.strictEqual(topic.cid, cid);
});
it('should slot newly created topic in remote category if addressed', async () => {
const { id: cid, actor } = helpers.mocks.group();
await activitypub.actors.assertGroup([cid]);
const { id } = helpers.mocks.note({
cc: [cid],
});
const assertion = await activitypub.notes.assert(0, id);
assert(assertion);
const { tid, count } = assertion;
assert(tid);
assert.strictEqual(count, 1);
const topic = await topics.getTopicData(tid);
assert.strictEqual(topic.cid, cid);
const tids = await db.getSortedSetMembers(`cid:${cid}:tids`);
assert(tids.includes(tid));
const category = await categories.getCategoryData(cid);
['topic_count', 'post_count', 'totalPostCount', 'totalTopicCount'].forEach((prop) => {
assert.strictEqual(category[prop], 1);
});
});
it('should add a remote category topic to a user\'s inbox if they are following the category', async () => {
const { id: cid, actor } = helpers.mocks.group();
await activitypub.actors.assertGroup([cid]);
const uid = await user.create({ username: utils.generateUUID() });
await api.categories.setWatchState({ uid }, { cid, state: categories.watchStates.tracking });
const { id } = helpers.mocks.note({
cc: [cid],
});
const { tid } = await activitypub.notes.assert(0, id);
const inInbox = await db.isSortedSetMember(`uid:${uid}:inbox`, tid);
assert(inInbox);
});
});
describe('User-specific behaviours', () => {
let remoteCid;
let uid;
before(async () => {
// Remote
const { id, actor } = helpers.mocks.group();
remoteCid = id;
await activitypub.actors.assertGroup([id]);
// User
uid = await user.create({ username: utils.generateUUID() });
await topics.markAllRead(uid);
});
it('should not show up in my unread if it is in cid -1', async () => {
const { id } = helpers.mocks.note();
const assertion = await activitypub.notes.assert(0, id, { skipChecks: 1 });
assert(assertion);
const unread = await topics.getTotalUnread(uid);
assert.strictEqual(unread, 0);
});
it('should show up in my recent/unread if I am tracking the remote category', async () => {
await api.categories.setWatchState({ uid }, {
cid: remoteCid,
state: categories.watchStates.tracking,
uid,
});
const { id } = helpers.mocks.note({
cc: [remoteCid],
});
const assertion = await activitypub.notes.assert(0, id);
assert(assertion);
const unread = await topics.getTotalUnread(uid);
assert.strictEqual(unread, 1);
await topics.markAllRead(uid);
});
it('should show up in recent/unread and notify me if I am watching the remote category', async () => {
await api.categories.setWatchState({ uid }, {
cid: remoteCid,
state: categories.watchStates.watching,
uid,
});
const { id, note } = helpers.mocks.note({
cc: [remoteCid],
});
const assertion = await activitypub.notes.assert(0, id);
assert(assertion);
const unread = await topics.getTotalUnread(uid);
assert.strictEqual(unread, 1);
// Notification inbox delivery is async so can't test directly
const exists = await db.exists(`notifications:new_topic:tid:${assertion.tid}:uid:${note.attributedTo}`);
assert(exists);
await topics.markAllRead(uid);
});
it('should not show up in recent/unread if I am ignoring the remote category', async () => {
await api.categories.setWatchState({ uid }, {
cid: remoteCid,
state: categories.watchStates.ignoring,
uid,
});
const { id, note } = helpers.mocks.note({
cc: [remoteCid],
});
const assertion = await activitypub.notes.assert(0, id);
assert(assertion);
const unread = await topics.getTotalUnread(uid);
assert.strictEqual(unread, 0);
})
});
});
describe('Private objects', () => {
@@ -99,6 +246,139 @@ describe('Notes', () => {
});
});
describe('Creation', () => {
let uid;
before(async () => {
uid = await user.create({ username: utils.generateUUID() });
});
describe('Local categories', () => {
let cid;
before(async () => {
({ cid } = await categories.create({ name: utils.generateUUID() }));
});
afterEach(() => {
activitypub._sent.clear();
});
describe('new topics', () => {
let activity;
before(async () => {
const { tid } = await api.topics.create({ uid }, {
cid,
title: utils.generateUUID(),
content: utils.generateUUID(),
});
assert(tid);
assert.strictEqual(activitypub._sent.size, 1);
const key = Array.from(activitypub._sent.keys())[0];
activity = activitypub._sent.get(key);
});
it('should federate out a Create activity', () => {
assert(activity && activity.to);
assert.strictEqual(activity.type, 'Create');
});
it('should have the local category addressed', () => {
const addressees = new Set([
...(activity.to || []),
...(activity.cc || []),
...(activity.bcc || []),
...(activity.object.to || []),
...(activity.object.cc || []),
...(activity.object.bcc || []),
]);
assert(addressees.has(`${nconf.get('url')}/category/${cid}`));
});
});
});
describe('Remote Categories', () => {
let cid;
before(async () => {
({ id: cid } = helpers.mocks.group());
await activitypub.actors.assert([cid]);
});
afterEach(() => {
activitypub._sent.clear();
});
describe('new topics', () => {
it('should federate out a Create activity with the remote community addressed', async () => {
const { tid } = await api.topics.create({ uid }, {
cid,
title: utils.generateUUID(),
content: utils.generateUUID(),
});
assert(tid);
assert.strictEqual(activitypub._sent.size, 1);
const key = Array.from(activitypub._sent.keys())[0];
const activity = activitypub._sent.get(key);
assert(activity && activity.to);
assert.strictEqual(activity.type, 'Create');
const addressees = new Set([
...(activity.to || []),
...(activity.cc || []),
...(activity.bcc || []),
...(activity.object.to || []),
...(activity.object.cc || []),
...(activity.object.bcc || []),
]);
assert(addressees.has(cid));
});
});
describe('replies', () => {
it('should federate out a Create activity with the remote community addressed', async () => {
const { tid } = await api.topics.create({ uid }, {
cid,
title: utils.generateUUID(),
content: utils.generateUUID(),
});
activitypub._sent.clear();
const postData = await api.topics.reply({ uid }, {
tid,
content: utils.generateUUID(),
});
assert(postData);
assert.strictEqual(activitypub._sent.size, 1);
const key = Array.from(activitypub._sent.keys())[0];
const activity = activitypub._sent.get(key);
assert(activity && activity.to);
assert.strictEqual(activity.type, 'Create');
const addressees = new Set([
...(activity.to || []),
...(activity.cc || []),
...(activity.bcc || []),
...(activity.object.to || []),
...(activity.object.cc || []),
...(activity.object.bcc || []),
]);
assert(addressees.has(cid));
});
});
});
});
describe('Inbox Synchronization', () => {
let cid;
let uid;