Compare commits

...

68 Commits

Author SHA1 Message Date
Julian Lam
689f2587e9 chore: v4.3.0-beta.2 2025-04-23 13:34:08 -04:00
Julian Lam
1aab4bb12a feat: handle Announce(Update(Note)) as well, #13320 2025-04-23 13:17:10 -04:00
Julian Lam
8e60047e12 feat: #13255, proper handling of upvotes shared by group actors
fixes #13320
2025-04-23 12:47:16 -04:00
Julian Lam
62ed89d9c5 feat: send the whole post content in summary as well 2025-04-23 09:41:04 -04:00
Julian Lam
f0f9bce557 fix: bug where disparate ids all claiming to be the same handle were causing duplicate remote users due to collisions, #13352 2025-04-23 09:41:04 -04:00
Julian Lam
ffc33bc263 test: article for new topic, note for replies 2025-04-23 09:41:04 -04:00
Julian Lam
d3478d8f86 fix: posts incorrectly excluded from results if result pid is in a remote category 2025-04-23 09:41:04 -04:00
Julian Lam
19c1a1c649 fix: ap helpers.makeSet to handle undefined property values 2025-04-23 09:41:04 -04:00
Julian Lam
2fdb5db841 test: missing clear ap send cache 2025-04-23 09:41:04 -04:00
Julian Lam
edfb3c2839 chore: v4.3.0-beta.1 2025-04-23 09:41:04 -04:00
Julian Lam
1afd5ee093 feat: show/hide categories on world page, #13255 2025-04-23 09:41:04 -04:00
Julian Lam
22d89dbe8f fix: add back localCategories to categorySearch when defaultCategories is supplied 2025-04-23 09:41:04 -04:00
Julian Lam
45fd3117a1 feat: notice on remote categories that have no local followers, #13255 2025-04-23 09:41:04 -04:00
Julian Lam
362541f4ae fix: remote bare hash for remote users on prune as well 2025-04-23 09:41:04 -04:00
Julian Lam
49db1b4eb6 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-23 09:41:04 -04:00
Julian Lam
38a13bb7d9 fix: missing teasers for remote categories on /world 2025-04-23 09:41:04 -04:00
Julian Lam
df7777dc75 fix: remove superfluous privilege filter in markAllRead 2025-04-23 09:41:04 -04:00
Julian Lam
ed37785828 chore: cut 4.3.0-alpha.3 2025-04-23 09:41:04 -04:00
Julian Lam
da1890e905 feat: category quick search on world page, theme version updates, #13255 2025-04-23 09:41:04 -04:00
Julian Lam
9d3dd1fe6a fix: reversed image and icon for remote categories, omit fa icon if remote category has icon property set, #13255 2025-04-23 09:41:04 -04:00
Julian Lam
71fb61c8b6 feat: show tracked/watched remote categories in world page, #13255 2025-04-23 09:41:04 -04:00
Barış Soner Uşaklı
6096278472 fix: closes #13289, id can be null 2025-04-23 09:41:04 -04:00
Julian Lam
2f1d9c3da0 fix: marking remote category topics as read 2025-04-23 09:41:04 -04:00
Julian Lam
3c4276f7c3 fix: markAllRead to get tids based on same logic as unread page, instead of marking all recent posts read 2025-04-23 09:41:04 -04:00
Julian Lam
881a6724ad test: additional test for ensuring handle:uid is continually set even after re-assertion 2025-04-23 09:41:04 -04:00
Julian Lam
d3b5f3f7d3 chore: cut v4.3.0-alpha.2 2025-04-23 09:41:04 -04:00
Julian Lam
56d37da1ba fix: key ownership cross-check to also work with remote categories, #13255 2025-04-23 09:41:03 -04:00
Julian Lam
42beab6219 chore: cut v4.3.0-alpha 2025-04-23 09:41:03 -04:00
Julian Lam
6f2766114e fix: #13255, assert all recipients of the main post when asserting a note, so that remote categories can be discovered 2025-04-23 09:41:03 -04:00
Julian Lam
86b5a159d7 fix: remote categories should not show up in a user's follow lists 2025-04-23 09:41:03 -04:00
Julian Lam
ca51597306 fix: #13255, remote user-to-category migration should not move shares that are already in an existing cid 2025-04-23 09:41:03 -04:00
Julian Lam
7eea1c67d0 fix: proper handling of actors.qualify response 2025-04-23 09:41:03 -04:00
Julian Lam
4fa8581704 fix: missing dep 2025-04-23 09:41:03 -04:00
Julian Lam
dc907bec7a test: additional test for remote category topic assertion when ignoring category 2025-04-23 09:41:03 -04:00
Julian Lam
2f480e37ad fix: topics in remote categories showing up in /recent 2025-04-23 09:41:03 -04:00
Julian Lam
0d77e860bf fix: regression that caused resolveInboxes to always return empty, added tests for resolveInboxes 2025-04-23 09:41:03 -04:00
Barış Soner Uşaklı
70dac3aede dont make db call if ap disabled 2025-04-23 09:41:03 -04:00
Barış Soner Uşaklı
00cd1817f6 refactor: use promise.all 2025-04-23 09:41:03 -04:00
Barış Soner Uşaklı
6a1edf6098 fix: spread fail, @julianlam
add ap check
2025-04-23 09:41:03 -04:00
Julian Lam
f2a1ff4077 feat: remote user to category migration should also migrate local user follows into category watches 2025-04-23 09:41:03 -04:00
Julian Lam
4d6de2d174 fix: filter out non-asserted targets when sending ap messages, diff. getter method when passed-in ID is a remote category 2025-04-23 09:41:03 -04:00
Julian Lam
8763d80d98 fix: tag whitelist check socket call for remote categories 2025-04-23 09:41:03 -04:00
Julian Lam
f477d12555 feat: allowing manual group assertion via category search input 2025-04-23 09:41:03 -04:00
Julian Lam
b10e5aa269 fix: migrate topics as system user instead of uid 0 2025-04-23 09:41:03 -04:00
Julian Lam
d2b8a7c504 send ap follow/undo-follow if remote category watch state changes 2025-04-23 09:41:03 -04:00
Julian Lam
82beaf8fd3 feat: remote group actors migrated to categories if they were previous asserted as remote users 2025-04-23 09:41:03 -04:00
Julian Lam
f012b44d11 fix: do not send out ap (undo:)follow if local user or category is (not)already following 2025-04-23 09:41:03 -04:00
Julian Lam
378c114815 test: #13255, reply to topic in remote category addresses remote category 2025-04-23 09:41:03 -04:00
Julian Lam
0dc2f618e4 feat: #13255 new topics in remote category addresses remote category, tests, fixes to tests 2025-04-23 09:41:03 -04:00
Julian Lam
5ada2dd234 fix: allow category controller to respond also by remote category id 2025-04-23 09:41:03 -04:00
Julian Lam
72470d6159 feat: #13255, deliver asserted topics to remote category followers 2025-04-23 09:41:03 -04:00
Julian Lam
e15e4d354f fix: #13255, update category search logic to allow for remote categories 2025-04-23 09:41:03 -04:00
Julian Lam
e5c4843439 feat: #13255, add category name and handle to category search zset 2025-04-23 09:41:03 -04:00
Julian Lam
fbedd7290a 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-23 09:41:03 -04:00
Julian Lam
2bd474535a test: remote user pruning tests 2025-04-23 09:41:03 -04:00
Julian Lam
681ec76247 feat: integrate remote category pruning into actor pruning logic 2025-04-23 09:41:03 -04:00
Julian Lam
d7b976529a feat: migration of group-as-user to group-as-category, remote category purging, more tests 2025-04-23 09:41:03 -04:00
Julian Lam
5ced4898bc fix: delete shares zset on account deletion 2025-04-23 09:41:03 -04:00
Julian Lam
c556f40ee3 test: introduce overrides into person and group mocks 2025-04-23 09:41:03 -04:00
Julian Lam
93a0b571f9 test: have ap helper mocks for person and group auto-save to ap cache 2025-04-23 09:41:03 -04:00
Julian Lam
d7f1860cbb test: add failing tests for actor/group assertion via wrong method, remote user to category migration 2025-04-23 09:41:03 -04:00
Julian Lam
36a2c3336d feat: asserted topics and posts to remote categories will notify and add to unread based on remote category watch state 2025-04-23 09:41:03 -04:00
Julian Lam
dd030a5efb test: add tests for topics slotting into remote categories if addressed 2025-04-23 09:41:03 -04:00
Julian Lam
adf74f9e79 test: group actor assertion tests 2025-04-23 09:41:03 -04:00
Julian Lam
71e937b939 refactor: allow topics to be asserted directly into a remote category, or -1 otherwise 2025-04-23 09:41:03 -04:00
Julian Lam
ff1b7a0faa feat: also include category in to field when mocking post for federation 2025-04-23 09:41:03 -04:00
Julian Lam
76b2cf56a5 refactor: ability to browse to remote categories, group actor assertion logic, etc. -- no logic to assign topics to remote categories yet 2025-04-23 09:41:03 -04:00
Julian Lam
6a363edf9d revert: use of vanity domains, needs rethinking. Originally added in 709a02d97a 2025-04-23 09:41:03 -04:00
45 changed files with 2002 additions and 229 deletions

View File

@@ -2,7 +2,7 @@
"name": "nodebb",
"license": "GPL-3.0",
"description": "NodeBB Forum",
"version": "4.2.2",
"version": "4.3.0-beta.2",
"homepage": "https://www.nodebb.org",
"repository": {
"type": "git",

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,16 +121,30 @@ 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}`);
const actor = (typeof id === 'object' && id.hasOwnProperty('id')) ? id : await activitypub.get('uid', 0, id, { cache: process.env.CI === 'true' });
// webfinger backreference check
const { hostname: domain } = new URL(id);
const { actorUri: canonicalId } = await activitypub.helpers.query(`${actor.preferredUsername}@${domain}`);
if (id !== canonicalId) {
return null;
}
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 +198,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 +259,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 +450,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 +540,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 +651,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

@@ -28,6 +28,8 @@ const sha256 = payload => crypto.createHash('sha256').update(payload).digest('he
const Helpers = module.exports;
Helpers._webfingerCache = webfingerCache; // exported for tests
Helpers._test = (method, args) => {
// because I am lazy and I probably wrote some variant of this below code 1000 times already
setTimeout(async () => {
@@ -439,8 +441,12 @@ Helpers.remoteAnchorToLocalProfile = async (content, isMarkdown = false) => {
return content;
};
// eslint-disable-next-line max-len
Helpers.makeSet = (object, properties) => new Set(properties.reduce((memo, property) => memo.concat(Array.isArray(object[property]) ? object[property] : [object[property]]), []));
Helpers.makeSet = (object, properties) => new Set(properties.reduce((memo, property) =>
memo.concat(object[property] ?
Array.isArray(object[property]) ?
object[property] :
[object[property]] :
[]), []));
Helpers.generateCollection = async ({ set, method, page, perPage, url }) => {
if (!method) {

View File

@@ -240,11 +240,11 @@ inbox.like = async (req) => {
const allowed = await privileges.posts.can('posts:upvote', id, activitypub._constants.uid);
if (!allowed) {
winston.verbose(`[activitypub/inbox.like] ${id} not allowed to be upvoted.`);
activitypub.helpers.log(`[activitypub/inbox.like] ${id} not allowed to be upvoted.`);
return reject('Like', object, actor);
}
winston.verbose(`[activitypub/inbox/like] id ${id} via ${actor}`);
activitypub.helpers.log(`[activitypub/inbox/like] id ${id} via ${actor}`);
const result = await posts.upvote(id, actor);
activitypub.feps.announce(object.id, req.body);
@@ -253,6 +253,7 @@ inbox.like = async (req) => {
inbox.announce = async (req) => {
const { actor, object, published, to, cc } = req.body;
activitypub.helpers.log(`[activitypub/inbox/announce] Parsing Announce(${object.type}) from ${actor}`);
let timestamp = new Date(published);
timestamp = timestamp.toString() !== 'Invalid Date' ? timestamp.getTime() : Date.now();
@@ -270,53 +271,76 @@ inbox.announce = async (req) => {
cid = Array.from(cids)[0];
}
if (String(object.id).startsWith(nconf.get('url'))) { // Local object
const { type, id } = await activitypub.helpers.resolveLocalId(object.id);
if (type !== 'post' || !(await posts.exists(id))) {
throw new Error('[[error:activitypub.invalid-id]]');
switch(true) {
case object.type === 'Like': {
const id = object.object.id || object.object;
const { id: localId } = await activitypub.helpers.resolveLocalId(id);
const exists = await posts.exists(localId || id);
if (exists) {
const result = await posts.upvote(localId || id, object.actor);
if (localId) {
socketHelpers.upvote(result, 'notifications:upvoted-your-post-in');
}
}
break;
}
pid = id;
tid = await posts.getPostField(id, 'tid');
case object.type === 'Update': {
req.body = object;
await inbox.update(req);
break;
}
socketHelpers.sendNotificationToPostOwner(pid, actor, 'announce', 'notifications:activitypub.announce');
} else { // Remote object
// Follower check
if (!cid) {
const { followers } = await activitypub.actors.getLocalFollowCounts(actor);
if (!followers) {
winston.verbose(`[activitypub/inbox.announce] Rejecting ${object.id} via ${actor} due to no followers`);
reject('Announce', object, actor);
return;
case activitypub._constants.acceptedPostTypes.includes(object.type): {
if (String(object.id).startsWith(nconf.get('url'))) { // Local object
const { type, id } = await activitypub.helpers.resolveLocalId(object.id);
if (type !== 'post' || !(await posts.exists(id))) {
reject('Announce', object, actor);
return;
}
pid = id;
tid = await posts.getPostField(id, 'tid');
socketHelpers.sendNotificationToPostOwner(pid, actor, 'announce', 'notifications:activitypub.announce');
} else { // Remote object
// Follower check
if (!cid) {
const { followers } = await activitypub.actors.getLocalFollowCounts(actor);
if (!followers) {
winston.verbose(`[activitypub/inbox.announce] Rejecting ${object.id} via ${actor} due to no followers`);
reject('Announce', object, actor);
return;
}
}
// Handle case where Announce(Create(Note-ish)) is received
if (object.type === 'Create' && activitypub._constants.acceptedPostTypes.includes(object.object.type)) {
pid = object.object.id;
} else {
pid = object.id;
}
pid = await activitypub.resolveId(0, pid); // in case wrong id is passed-in; unlikely, but still.
if (!pid) {
return;
}
const assertion = await activitypub.notes.assert(0, pid, { cid, skipChecks: true });
if (!assertion) {
return;
}
({ tid } = assertion);
await activitypub.notes.updateLocalRecipients(pid, { to, cc });
await activitypub.notes.syncUserInboxes(tid);
}
if (!cid) { // Topic events from actors followed by users only
await activitypub.notes.announce.add(pid, actor, timestamp);
}
}
// Handle case where Announce(Create(Note-ish)) is received
if (object.type === 'Create' && activitypub._constants.acceptedPostTypes.includes(object.object.type)) {
pid = object.object.id;
} else {
pid = object.id;
}
pid = await activitypub.resolveId(0, pid); // in case wrong id is passed-in; unlikely, but still.
if (!pid) {
return;
}
const assertion = await activitypub.notes.assert(0, pid, { cid });
if (!assertion) {
return;
}
({ tid } = assertion);
await activitypub.notes.updateLocalRecipients(pid, { to, cc });
await activitypub.notes.syncUserInboxes(tid);
}
winston.verbose(`[activitypub/inbox/announce] Parsing id ${pid}`);
if (!cid) { // Topic events from actors followed by users only
await activitypub.notes.announce.add(pid, actor, timestamp);
}
};

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`]);
@@ -639,6 +707,7 @@ Mocks.notes.public = async (post) => {
attachment = normalizeAttachment(attachment);
let preview;
let summary = null;
if (isMainPost) {
preview = {
type: 'Note',
@@ -647,6 +716,8 @@ Mocks.notes.public = async (post) => {
published,
attachment: normalizeAttachment(noteAttachment),
};
summary = post.content;
}
let context = await posts.getPostField(post.pid, 'context');
@@ -656,13 +727,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',
@@ -677,7 +750,7 @@ Mocks.notes.public = async (post) => {
attributedTo: `${nconf.get('url')}/uid/${post.user.uid}`,
context,
audience,
summary: null,
summary,
name,
preview,
content: post.content,

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

@@ -86,7 +86,7 @@ privsPosts.filter = async function (privilege, pids, uid) {
post.topic = tidToTopic[post.tid];
}
return tidToTopic[post.tid] && tidToTopic[post.tid].cid;
}).filter(cid => parseInt(cid, 10));
}).filter(cid => utils.isNumber(cid) ? parseInt(cid, 10) : cid);
cids = _.uniq(cids);

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

@@ -79,11 +79,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;
@@ -38,6 +46,7 @@ describe('Actor asserton', () => {
publicKeyPem: 'somekey',
},
});
activitypub.helpers._webfingerCache.set('example@example.org', { actorUri })
});
it('should return true if successfully asserted', async () => {
@@ -58,9 +67,158 @@ 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);
});
it('should fail to assert if a passed-in ID\'s webfinger query does not respond with the same ID (gh#13352)', async () => {
const { id } = helpers.mocks.person({
preferredUsername: 'foobar',
});
const actorUri = `https://example.org/${utils.generateUUID()}`;
activitypub.helpers._webfingerCache.set('foobar@example.org', {
username: 'foobar',
hostname: 'example.org',
actorUri,
});
const { actorUri: confirm } = await activitypub.helpers.query('foobar@example.org');
assert.strictEqual(confirm, actorUri);
const response = await activitypub.actors.assert([id]);
assert.deepStrictEqual(response, []);
})
});
});
describe('edge cases: loopback handles and uris', () => {
let uid;
const userslug = utils.generateUUID().slice(0, 8);
before(async () => {
@@ -90,6 +248,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 +802,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,39 +8,67 @@ const Helpers = module.exports;
Helpers.mocks = {};
Helpers.mocks.actor = () => {
const baseUrl = 'https://example.org';
Helpers.mocks._baseUrl = 'https://example.org';
Helpers.mocks.person = (override = {}) => {
const uuid = utils.generateUUID();
const id = `${baseUrl}/${uuid}`;
let id = `${Helpers.mocks._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);
activitypub.helpers._webfingerCache.set(`${actor.preferredUsername}@example.org`, {
actorUri: id,
username: id,
hostname: 'example.org',
});
return { id, actor };
};
Helpers.mocks.group = (override = {}) => {
const { id, actor } = Helpers.mocks.person({
type: 'Group',
...override,
});
activitypub._cache.set(`0;${id}`, actor);
activitypub.helpers._webfingerCache.set(`${actor.preferredUsername}@example.org`, {
actorUri: id,
username: id,
hostname: 'example.org',
});
return { id, actor };
};
Helpers.mocks.note = (override = {}) => {
const baseUrl = 'https://example.org';
const uuid = utils.generateUUID();
const id = `${baseUrl}/object/${uuid}`;
const id = `${Helpers.mocks._baseUrl}/object/${uuid}`;
const note = {
'@context': 'https://www.w3.org/ns/activitystreams',
id,
@@ -69,9 +97,8 @@ Helpers.mocks.note = (override = {}) => {
Helpers.mocks.create = (object) => {
// object is optional, will generate a public note if undefined
const baseUrl = 'https://example.org';
const uuid = utils.generateUUID();
const id = `${baseUrl}/activity/${uuid}`;
const id = `${Helpers.mocks._baseUrl}/activity/${uuid}`;
object = object || Helpers.mocks.note().note;
const activity = {
@@ -88,3 +115,87 @@ Helpers.mocks.create = (object) => {
return { id, activity };
};
Helpers.mocks.accept = (actor, object) => {
const uuid = utils.generateUUID();
const id = `${Helpers.mocks._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 };
};
Helpers.mocks.like = (override = {}) => {
let actor = override.actor;
let object = override.object;
if (!actor) {
({ id: actor } = Helpers.mocks.person());
}
if (!object) {
({ id: object } = Helpers.mocks.note());
}
const activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: `${Helpers.mocks._baseUrl}/like/${encodeURIComponent(object)}`,
type: 'Like',
actor,
object,
};
return { activity };
};
Helpers.mocks.announce = (override = {}) => {
let actor = override.actor;
let object = override.object;
if (!actor) {
({ id: actor } = Helpers.mocks.person());
}
if (!object) {
({ id: object } = Helpers.mocks.note());
}
const activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: `${Helpers.mocks._baseUrl}/announce/${encodeURIComponent(object.id || object)}`,
type: 'Announce',
to: [activitypub._constants.publicAddress],
cc: [`${actor}/followers`],
actor,
object,
};
return { activity };
};
Helpers.mocks.update = (override = {}) => {
let actor = override.actor;
let object = override.object;
if (!actor) {
({ id: actor } = Helpers.mocks.person());
}
if (!object) {
({ id: object } = Helpers.mocks.note());
}
const activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: `${Helpers.mocks._baseUrl}/update/${encodeURIComponent(object.id || object)}`,
type: 'Update',
to: [activitypub._constants.publicAddress],
cc: [`${actor}/followers`],
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,323 @@ 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() }));
activitypub._sent.clear();
});
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}`));
});
it('should federate out an activity with object of type "Article"', () => {
assert(activity.object && activity.object.type);
assert.strictEqual(activity.object.type, 'Article');
});
});
describe('new reply', () => {
let activity;
before(async () => {
const { tid } = await api.topics.create({ uid }, {
cid,
title: utils.generateUUID(),
content: utils.generateUUID(),
});
activitypub._sent.clear();
const { pid } = await api.topics.reply({ uid }, {
tid,
content: utils.generateUUID(),
});
const key = Array.from(activitypub._sent.keys())[0];
activity = activitypub._sent.get(key);
});
it('should federate out an activity with object of type "Note"', () => {
assert(activity.object && activity.object.type);
assert.strictEqual(activity.object.type, 'Note');
});
});
});
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 handling', () => {
describe('helper self-check', () => {
it('should generate a Like activity', () => {
const object = utils.generateUUID();
const { id: actor } = helpers.mocks.person();
const { activity } = helpers.mocks.like({
object,
actor,
});
assert.deepStrictEqual(activity, {
'@context': 'https://www.w3.org/ns/activitystreams',
id: `${helpers.mocks._baseUrl}/like/${encodeURIComponent(object)}`,
type: 'Like',
actor,
object,
});
});
it('should generate an Announce activity wrapping a Like activity', () => {
const object = utils.generateUUID();
const { id: actor } = helpers.mocks.person();
const { activity: like } = helpers.mocks.like({
object,
actor,
});
const { id: gActor } = helpers.mocks.group();
const { activity } = helpers.mocks.announce({
actor: gActor,
object: like,
});
assert.deepStrictEqual(activity, {
'@context': 'https://www.w3.org/ns/activitystreams',
id: `${helpers.mocks._baseUrl}/announce/${encodeURIComponent(like.id)}`,
type: 'Announce',
to: [ 'https://www.w3.org/ns/activitystreams#Public' ],
cc: [
`${gActor}/followers`,
],
actor: gActor,
object: like,
});
});
});
describe('Announce', () => {
let cid;
before(async () => {
({ cid } = await categories.create({ name: utils.generateUUID().slice(0, 8) }));
});
describe('(Note)', () => {
it('should create a new topic in cid -1 if category not addressed', async () => {
const { note } = helpers.mocks.note();
await activitypub.actors.assert([note.attributedTo]);
const { activity } = helpers.mocks.announce({
object: note,
});
const uid = await user.create({ username: utils.generateUUID().slice(0, 10) });
await db.sortedSetAdd(`followersRemote:${activity.actor}`, Date.now(), uid);
const beforeCount = await db.sortedSetCard(`cid:-1:tids`);
await activitypub.inbox.announce({ body: activity });
const count = await db.sortedSetCard(`cid:-1:tids`);
assert.strictEqual(count, beforeCount + 1);
});
it('should create a new topic in local category', async () => {
const { note } = helpers.mocks.note({
cc: [`${nconf.get('url')}/category/${cid}`],
});
await activitypub.actors.assert([note.attributedTo]);
const { activity } = helpers.mocks.announce({
object: note,
});
const uid = await user.create({ username: utils.generateUUID().slice(0, 10) });
await db.sortedSetAdd(`followersRemote:${activity.actor}`, Date.now(), uid);
const beforeCount = await db.sortedSetCard(`cid:${cid}:tids`);
await activitypub.inbox.announce({ body: activity });
const count = await db.sortedSetCard(`cid:${cid}:tids`);
assert.strictEqual(count, beforeCount + 1);
});
});
describe('(Like)', () => {
it('should upvote a local post', async () => {
const uid = await user.create({ username: utils.generateUUID().slice(0, 10) });
const { postData } = await topics.post({
cid,
uid,
title: utils.generateUUID(),
content: utils.generateUUID(),
});
const { activity: like } = helpers.mocks.like({
object: `${nconf.get('url')}/post/${postData.pid}`,
});
const { activity } = helpers.mocks.announce({
object: like,
});
let { upvotes } = await posts.getPostFields(postData.pid, 'upvotes');
assert.strictEqual(upvotes, 0);
await activitypub.inbox.announce({ body: activity });
({ upvotes } = await posts.getPostFields(postData.pid, 'upvotes'));
assert.strictEqual(upvotes, 1);
});
it('should upvote an asserted remote post', async () => {
const { id } = helpers.mocks.note();
await activitypub.notes.assert(0, [id], { skipChecks: true });
const { activity: like } = helpers.mocks.like({
object: id,
});
const { activity } = helpers.mocks.announce({
object: like,
});
let { upvotes } = await posts.getPostFields(id, 'upvotes');
assert.strictEqual(upvotes, 0);
await activitypub.inbox.announce({ body: activity });
({ upvotes } = await posts.getPostFields(id, 'upvotes'));
assert.strictEqual(upvotes, 1);
});
});
describe('(Update)', () => {
it('should update a note\'s content', async () => {
const { id: actor } = helpers.mocks.person();
const { id, note } = helpers.mocks.note({ attributedTo: actor });
await activitypub.notes.assert(0, [id], { skipChecks: true });
note.content = utils.generateUUID();
const { activity: update } = helpers.mocks.update({ object: note });
const { activity } = helpers.mocks.announce({ object: update });
await activitypub.inbox.announce({ body: activity });
const content = await posts.getPostField(id, 'content');
assert.strictEqual(content, note.content);
});
});
});
});
describe('Inbox Synchronization', () => {
let cid;
let uid;