mirror of
https://github.com/NodeBB/NodeBB.git
synced 2025-12-16 05:20:24 +01:00
Compare commits
61 Commits
ea1309e690
...
v4.3.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
779188d88a | ||
|
|
6b960d68c4 | ||
|
|
7040e41ba8 | ||
|
|
573ca11943 | ||
|
|
15e44fad86 | ||
|
|
ee2820767b | ||
|
|
99c961bd56 | ||
|
|
b8ffc99c79 | ||
|
|
d68d92f61a | ||
|
|
e2a01e5d08 | ||
|
|
c02a7b3c77 | ||
|
|
a1385dc7f9 | ||
|
|
8c26dfad9c | ||
|
|
a58f0f981b | ||
|
|
48012ae13d | ||
|
|
f3205e24e8 | ||
|
|
7fbc152c6f | ||
|
|
6034c27adf | ||
|
|
71e1e90703 | ||
|
|
023b6b7c38 | ||
|
|
855bed54c0 | ||
|
|
2fb1e728fd | ||
|
|
63d6192878 | ||
|
|
72560ee986 | ||
|
|
1f18f1548a | ||
|
|
903d6102aa | ||
|
|
0f313a5294 | ||
|
|
8e70726ef6 | ||
|
|
f91c3a7e0b | ||
|
|
0704988218 | ||
|
|
fb993c8a55 | ||
|
|
223d70550c | ||
|
|
8d6d1856ae | ||
|
|
93eb31c98d | ||
|
|
e86a75219b | ||
|
|
59ccb2cc76 | ||
|
|
540af998a4 | ||
|
|
edc6708c68 | ||
|
|
dba791df6f | ||
|
|
c6cacdbd49 | ||
|
|
9c74683b33 | ||
|
|
3ff3403593 | ||
|
|
ef0c48bcda | ||
|
|
e06ddaddf5 | ||
|
|
40ac3df69f | ||
|
|
6f630d76c2 | ||
|
|
b278e8a4ea | ||
|
|
87d2534a4b | ||
|
|
d3a846e877 | ||
|
|
d0e0ce2931 | ||
|
|
05d28c02c2 | ||
|
|
37b60f0aaa | ||
|
|
ea1df8850d | ||
|
|
df233345a7 | ||
|
|
ff839213e4 | ||
|
|
de16003336 | ||
|
|
24114d52ba | ||
|
|
91cebe651a | ||
|
|
1b3673da7a | ||
|
|
244ce8d70e | ||
|
|
3f7c8678a3 |
@@ -2,7 +2,7 @@
|
||||
"name": "nodebb",
|
||||
"license": "GPL-3.0",
|
||||
"description": "NodeBB Forum",
|
||||
"version": "4.2.0",
|
||||
"version": "4.3.0-beta.1",
|
||||
"homepage": "https://www.nodebb.org",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -107,10 +107,10 @@
|
||||
"nodebb-plugin-spam-be-gone": "2.3.1",
|
||||
"nodebb-plugin-web-push": "0.7.3",
|
||||
"nodebb-rewards-essentials": "1.0.1",
|
||||
"nodebb-theme-harmony": "2.1.3",
|
||||
"nodebb-theme-harmony": "2.1.6",
|
||||
"nodebb-theme-lavender": "7.1.18",
|
||||
"nodebb-theme-peace": "2.2.39",
|
||||
"nodebb-theme-persona": "14.1.2",
|
||||
"nodebb-theme-persona": "14.1.5",
|
||||
"nodebb-widget-essentials": "7.0.36",
|
||||
"nodemailer": "6.10.0",
|
||||
"nprogress": "0.2.0",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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 || [];
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ const _ = require('lodash');
|
||||
const db = require('../database');
|
||||
const meta = require('../meta');
|
||||
const batch = require('../batch');
|
||||
const categories = require('../categories');
|
||||
const user = require('../user');
|
||||
const topics = require('../topics');
|
||||
const utils = require('../utils');
|
||||
const TTLCache = require('../cache/ttl');
|
||||
|
||||
@@ -20,15 +22,12 @@ const activitypub = module.parent.exports;
|
||||
|
||||
const Actors = module.exports;
|
||||
|
||||
Actors.assert = async (ids, options = {}) => {
|
||||
Actors.qualify = async (ids, options = {}) => {
|
||||
/**
|
||||
* Ensures that the passed in ids or webfinger handles are stored in database.
|
||||
* Options:
|
||||
* - update: boolean, forces re-fetch/process of the resolved id
|
||||
* Return one of:
|
||||
* - An array of newly processed ids
|
||||
* - false: if input incorrect (or webfinger handle cannot resolve)
|
||||
* - true: no new IDs processed; all passed-in IDs present.
|
||||
* Sanity-checks, cache handling, webfinger translations, so that only
|
||||
* an array of actor uris are handled by assert/assertGroup.
|
||||
*
|
||||
* This method is only called by assert/assertGroup (at least in core.)
|
||||
*/
|
||||
|
||||
// Handle single values
|
||||
@@ -47,7 +46,6 @@ Actors.assert = async (ids, options = {}) => {
|
||||
ids = ids.filter(id => !utils.isNumber(id));
|
||||
|
||||
// Translate webfinger handles to uris
|
||||
const hostMap = new Map();
|
||||
ids = (await Promise.all(ids.map(async (id) => {
|
||||
const originalId = id;
|
||||
if (activitypub.helpers.isWebfinger(id)) {
|
||||
@@ -57,7 +55,6 @@ Actors.assert = async (ids, options = {}) => {
|
||||
}
|
||||
|
||||
({ actorUri: id } = await activitypub.helpers.query(id));
|
||||
hostMap.set(id, host);
|
||||
}
|
||||
// ensure the final id is a valid URI
|
||||
if (!id || !activitypub.helpers.isUri(id)) {
|
||||
@@ -77,18 +74,44 @@ Actors.assert = async (ids, options = {}) => {
|
||||
ids = ids.filter(uri => uri !== 'loopback' && new URL(uri).host !== nconf.get('url_parsed').host);
|
||||
}
|
||||
|
||||
// Separate those who need migration from user to category
|
||||
const migrate = new Set();
|
||||
if (options.qualifyGroup) {
|
||||
const exists = await db.exists(ids.map(id => `userRemote:${id}`));
|
||||
ids.forEach((id, idx) => {
|
||||
if (exists[idx]) {
|
||||
migrate.add(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Only assert those who haven't been seen recently (configurable), unless update flag passed in (force refresh)
|
||||
if (!options.update) {
|
||||
const upperBound = Date.now() - (1000 * 60 * 60 * 24 * meta.config.activitypubUserPruneDays);
|
||||
const lastCrawled = await db.sortedSetScores('usersRemote:lastCrawled', ids.map(id => ((typeof id === 'object' && id.hasOwnProperty('id')) ? id.id : id)));
|
||||
ids = ids.filter((id, idx) => {
|
||||
const timestamp = lastCrawled[idx];
|
||||
return !timestamp || timestamp < upperBound;
|
||||
return migrate.has(id) || !timestamp || timestamp < upperBound;
|
||||
});
|
||||
}
|
||||
|
||||
if (!ids.length) {
|
||||
return true;
|
||||
return ids;
|
||||
};
|
||||
|
||||
Actors.assert = async (ids, options = {}) => {
|
||||
/**
|
||||
* Ensures that the passed in ids or webfinger handles are stored in database.
|
||||
* Options:
|
||||
* - update: boolean, forces re-fetch/process of the resolved id
|
||||
* Return one of:
|
||||
* - An array of newly processed ids
|
||||
* - false: if input incorrect (or webfinger handle cannot resolve)
|
||||
* - true: no new IDs processed; all passed-in IDs present.
|
||||
*/
|
||||
|
||||
ids = await Actors.qualify(ids, options);
|
||||
if (!ids || !ids.length) {
|
||||
return ids;
|
||||
}
|
||||
|
||||
activitypub.helpers.log(`[activitypub/actors] Asserting ${ids.length} actor(s)`);
|
||||
@@ -98,6 +121,7 @@ Actors.assert = async (ids, options = {}) => {
|
||||
const urlMap = new Map();
|
||||
const followersUrlMap = new Map();
|
||||
const pubKeysMap = new Map();
|
||||
const categories = new Set();
|
||||
let actors = await Promise.all(ids.map(async (id) => {
|
||||
try {
|
||||
activitypub.helpers.log(`[activitypub/actors] Processing ${id}`);
|
||||
@@ -106,8 +130,14 @@ Actors.assert = async (ids, options = {}) => {
|
||||
let typeOk = false;
|
||||
if (Array.isArray(actor.type)) {
|
||||
typeOk = actor.type.some(type => activitypub._constants.acceptableActorTypes.has(type));
|
||||
if (!typeOk && actor.type.some(type => activitypub._constants.acceptableGroupTypes.has(type))) {
|
||||
categories.add(actor.id);
|
||||
}
|
||||
} else {
|
||||
typeOk = activitypub._constants.acceptableActorTypes.has(actor.type);
|
||||
if (!typeOk && activitypub._constants.acceptableGroupTypes.has(actor.type)) {
|
||||
categories.add(actor.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -161,9 +191,12 @@ Actors.assert = async (ids, options = {}) => {
|
||||
}
|
||||
}));
|
||||
actors = actors.filter(Boolean); // remove unresolvable actors
|
||||
if (!actors.length && !categories.size) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Build userData object for storage
|
||||
const profiles = (await activitypub.mocks.profile(actors, hostMap)).filter(Boolean);
|
||||
const profiles = (await activitypub.mocks.profile(actors)).filter(Boolean);
|
||||
const now = Date.now();
|
||||
|
||||
const bulkSet = profiles.reduce((memo, profile) => {
|
||||
@@ -219,10 +252,188 @@ Actors.assert = async (ids, options = {}) => {
|
||||
db.setObject('handle:uid', queries.handleAdd),
|
||||
]);
|
||||
|
||||
// Handle any actors that should be asserted as a group instead
|
||||
if (categories.size) {
|
||||
const assertion = await Actors.assertGroup(Array.from(categories), options);
|
||||
if (assertion === false) {
|
||||
return false;
|
||||
} else if (Array.isArray(assertion)) {
|
||||
return [...actors, ...assertion];
|
||||
}
|
||||
|
||||
// otherwise, assertGroup returned true and output can be safely ignored.
|
||||
}
|
||||
|
||||
return actors;
|
||||
};
|
||||
|
||||
Actors.assertGroup = async (ids, options = {}) => {
|
||||
/**
|
||||
* Ensures that the passed in ids or webfinger handles are stored in database.
|
||||
* Options:
|
||||
* - update: boolean, forces re-fetch/process of the resolved id
|
||||
* Return one of:
|
||||
* - An array of newly processed ids
|
||||
* - false: if input incorrect (or webfinger handle cannot resolve)
|
||||
* - true: no new IDs processed; all passed-in IDs present.
|
||||
*/
|
||||
|
||||
ids = await Actors.qualify(ids, {
|
||||
qualifyGroup: true,
|
||||
...options,
|
||||
});
|
||||
if (!ids) {
|
||||
return ids;
|
||||
}
|
||||
|
||||
activitypub.helpers.log(`[activitypub/actors] Asserting ${ids.length} group(s)`);
|
||||
|
||||
// NOTE: MAKE SURE EVERY DB ADDITION HAS A CORRESPONDING REMOVAL IN ACTORS.REMOVEGROUP!
|
||||
|
||||
const urlMap = new Map();
|
||||
const followersUrlMap = new Map();
|
||||
const pubKeysMap = new Map();
|
||||
let groups = await Promise.all(ids.map(async (id) => {
|
||||
try {
|
||||
activitypub.helpers.log(`[activitypub/actors] Processing group ${id}`);
|
||||
const actor = (typeof id === 'object' && id.hasOwnProperty('id')) ? id : await activitypub.get('uid', 0, id, { cache: process.env.CI === 'true' });
|
||||
|
||||
const typeOk = Array.isArray(actor.type) ?
|
||||
actor.type.some(type => activitypub._constants.acceptableGroupTypes.has(type)) :
|
||||
activitypub._constants.acceptableGroupTypes.has(actor.type);
|
||||
|
||||
if (
|
||||
!typeOk ||
|
||||
!activitypub._constants.requiredActorProps.every(prop => actor.hasOwnProperty(prop))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Save url for backreference
|
||||
const url = Array.isArray(actor.url) ? actor.url.shift() : actor.url;
|
||||
if (url && url !== actor.id) {
|
||||
urlMap.set(url, actor.id);
|
||||
}
|
||||
|
||||
// Save followers url for backreference
|
||||
if (actor.hasOwnProperty('followers') && activitypub.helpers.isUri(actor.followers)) {
|
||||
followersUrlMap.set(actor.followers, actor.id);
|
||||
}
|
||||
|
||||
// Public keys
|
||||
pubKeysMap.set(actor.id, actor.publicKey);
|
||||
|
||||
return actor;
|
||||
} catch (e) {
|
||||
if (e.code === 'ap_get_410') {
|
||||
const exists = await categories.exists(id);
|
||||
if (exists) {
|
||||
await categories.purge(id, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
groups = groups.filter(Boolean); // remove unresolvable actors
|
||||
|
||||
// Build userData object for storage
|
||||
const categoryObjs = (await activitypub.mocks.category(groups)).filter(Boolean);
|
||||
const now = Date.now();
|
||||
|
||||
const bulkSet = categoryObjs.reduce((memo, category) => {
|
||||
const key = `categoryRemote:${category.cid}`;
|
||||
memo.push([key, category], [`${key}:keys`, pubKeysMap.get(category.cid)]);
|
||||
return memo;
|
||||
}, []);
|
||||
if (urlMap.size) {
|
||||
bulkSet.push(['remoteUrl:cid', Object.fromEntries(urlMap)]);
|
||||
}
|
||||
if (followersUrlMap.size) {
|
||||
bulkSet.push(['followersUrl:cid', Object.fromEntries(followersUrlMap)]);
|
||||
}
|
||||
|
||||
const exists = await db.isSortedSetMembers('usersRemote:lastCrawled', categoryObjs.map(p => p.cid));
|
||||
const cidsForCurrent = categoryObjs.map((p, idx) => (exists[idx] ? p.cid : 0));
|
||||
const current = await categories.getCategoriesFields(cidsForCurrent, ['slug']);
|
||||
const queries = categoryObjs.reduce((memo, profile, idx) => {
|
||||
const { slug, name } = current[idx];
|
||||
|
||||
if (options.update || slug !== profile.slug) {
|
||||
if (cidsForCurrent[idx] !== 0 && slug) {
|
||||
// memo.searchRemove.push(['ap.preferredUsername:sorted', `${slug.toLowerCase()}:${profile.uid}`]);
|
||||
memo.handleRemove.push(slug.toLowerCase());
|
||||
}
|
||||
|
||||
memo.searchAdd.push(['categories:name', 0, `${profile.slug.slice(0, 200).toLowerCase()}:${profile.cid}`]);
|
||||
memo.handleAdd[profile.slug.toLowerCase()] = profile.cid;
|
||||
}
|
||||
|
||||
if (options.update || (profile.name && name !== profile.name)) {
|
||||
if (name && cidsForCurrent[idx] !== 0) {
|
||||
memo.searchRemove.push(['categories:name', `${name.toLowerCase()}:${profile.cid}`]);
|
||||
}
|
||||
|
||||
memo.searchAdd.push(['categories:name', 0, `${profile.name.toLowerCase()}:${profile.cid}`]);
|
||||
}
|
||||
|
||||
return memo;
|
||||
}, { searchRemove: [], searchAdd: [], handleRemove: [], handleAdd: {} });
|
||||
|
||||
// Removals
|
||||
await Promise.all([
|
||||
db.sortedSetRemoveBulk(queries.searchRemove),
|
||||
db.deleteObjectFields('handle:cid', queries.handleRemove),
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
db.setObjectBulk(bulkSet),
|
||||
db.sortedSetAdd('usersRemote:lastCrawled', groups.map(() => now), groups.map(p => p.id)),
|
||||
db.sortedSetAddBulk(queries.searchAdd),
|
||||
db.setObject('handle:cid', queries.handleAdd),
|
||||
_migratePersonToGroup(categoryObjs),
|
||||
]);
|
||||
|
||||
return categoryObjs;
|
||||
};
|
||||
|
||||
async function _migratePersonToGroup(categoryObjs) {
|
||||
// 4.0.0-4.1.x asserted as:Group as users. This moves relevant stuff over and deletes the now-duplicate user.
|
||||
let ids = categoryObjs.map(category => category.cid);
|
||||
const slugs = categoryObjs.map(category => category.slug);
|
||||
const isUser = await db.isObjectFields('handle:uid', slugs);
|
||||
ids = ids.filter((id, idx) => isUser[idx]);
|
||||
if (!ids.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(ids.map(async (id) => {
|
||||
const shares = await db.getSortedSetMembers(`uid:${id}:shares`);
|
||||
let cids = await topics.getTopicsFields(shares, ['cid']);
|
||||
cids = cids.map(o => o.cid);
|
||||
await Promise.all(shares.map(async (share, idx) => {
|
||||
const cid = cids[idx];
|
||||
if (cid === -1) {
|
||||
await topics.tools.move(share, {
|
||||
cid: id,
|
||||
uid: 'system',
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
const followers = await db.getSortedSetMembersWithScores(`followersRemote:${id}`);
|
||||
await db.sortedSetAdd(
|
||||
`cid:${id}:uid:watch:state`,
|
||||
followers.map(() => categories.watchStates.tracking),
|
||||
followers.map(({ value }) => value),
|
||||
);
|
||||
await user.deleteAccount(id);
|
||||
}));
|
||||
await categories.onTopicsMoved(ids);
|
||||
}
|
||||
|
||||
Actors.getLocalFollowers = async (id) => {
|
||||
// Returns local uids and cids that follow a remote actor (by id)
|
||||
const response = {
|
||||
uids: new Set(),
|
||||
cids: new Set(),
|
||||
@@ -232,15 +443,27 @@ Actors.getLocalFollowers = async (id) => {
|
||||
return response;
|
||||
}
|
||||
|
||||
const members = await db.getSortedSetMembers(`followersRemote:${id}`);
|
||||
const [isUser, isCategory] = await Promise.all([
|
||||
user.exists(id),
|
||||
categories.exists(id),
|
||||
]);
|
||||
|
||||
members.forEach((id) => {
|
||||
if (utils.isNumber(id)) {
|
||||
response.uids.add(parseInt(id, 10));
|
||||
} else if (id.startsWith('cid|') && utils.isNumber(id.slice(4))) {
|
||||
response.cids.add(parseInt(id.slice(4), 10));
|
||||
}
|
||||
});
|
||||
if (isUser) {
|
||||
const members = await db.getSortedSetMembers(`followersRemote:${id}`);
|
||||
|
||||
members.forEach((id) => {
|
||||
if (utils.isNumber(id)) {
|
||||
response.uids.add(parseInt(id, 10));
|
||||
} else if (id.startsWith('cid|') && utils.isNumber(id.slice(4))) {
|
||||
response.cids.add(parseInt(id.slice(4), 10));
|
||||
}
|
||||
});
|
||||
} else if (isCategory) {
|
||||
const members = await db.getSortedSetRangeByScore(`cid:${id}:uid:watch:state`, 0, -1, categories.watchStates.tracking, categories.watchStates.watching);
|
||||
members.forEach((uid) => {
|
||||
response.uids.add(uid);
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
@@ -310,38 +533,105 @@ Actors.remove = async (id) => {
|
||||
]);
|
||||
};
|
||||
|
||||
Actors.removeGroup = async (id) => {
|
||||
/**
|
||||
* Remove ActivityPub related metadata pertaining to a remote id
|
||||
*
|
||||
* Note: don't call this directly! It is called as part of categories.purge
|
||||
*/
|
||||
const exists = await db.isSortedSetMember('usersRemote:lastCrawled', id);
|
||||
if (!exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let { slug, name, url, followersUrl } = await categories.getCategoryFields(id, ['slug', 'name', 'url', 'followersUrl']);
|
||||
slug = slug.toLowerCase();
|
||||
|
||||
const bulkRemove = [
|
||||
['categories:name', `${slug}:${id}`],
|
||||
];
|
||||
if (name) {
|
||||
bulkRemove.push(['categories:name', `${name.toLowerCase()}:${id}`]);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
db.sortedSetRemoveBulk(bulkRemove),
|
||||
db.deleteObjectField('handle:cid', slug),
|
||||
db.deleteObjectField('followersUrl:cid', followersUrl),
|
||||
db.deleteObjectField('remoteUrl:cid', url),
|
||||
db.delete(`categoryRemote:${id}:keys`),
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
db.delete(`categoryRemote:${id}`),
|
||||
db.sortedSetRemove('usersRemote:lastCrawled', id),
|
||||
]);
|
||||
};
|
||||
|
||||
Actors.prune = async () => {
|
||||
/**
|
||||
* Clear out remote user accounts that do not have content on the forum anywhere
|
||||
*/
|
||||
winston.info('[actors/prune] Started scheduled pruning of remote user accounts');
|
||||
activitypub.helpers.log('[actors/prune] Started scheduled pruning of remote user accounts and categories');
|
||||
|
||||
const days = parseInt(meta.config.activitypubUserPruneDays, 10);
|
||||
const timestamp = Date.now() - (1000 * 60 * 60 * 24 * days);
|
||||
const uids = await db.getSortedSetRangeByScore('usersRemote:lastCrawled', 0, 500, '-inf', timestamp);
|
||||
if (!uids.length) {
|
||||
winston.info('[actors/prune] No remote users to prune, all done.');
|
||||
return;
|
||||
const ids = await db.getSortedSetRangeByScore('usersRemote:lastCrawled', 0, 500, '-inf', timestamp);
|
||||
if (!ids.length) {
|
||||
activitypub.helpers.log('[actors/prune] No remote actors to prune, all done.');
|
||||
return {
|
||||
counts: {
|
||||
deleted: 0,
|
||||
missing: 0,
|
||||
preserved: 0,
|
||||
},
|
||||
preserved: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
winston.info(`[actors/prune] Found ${uids.length} remote users last crawled more than ${days} days ago`);
|
||||
activitypub.helpers.log(`[actors/prune] Found ${ids.length} remote actors last crawled more than ${days} days ago`);
|
||||
let deletionCount = 0;
|
||||
let deletionCountNonExisting = 0;
|
||||
let notDeletedDueToLocalContent = 0;
|
||||
const notDeletedUids = [];
|
||||
await batch.processArray(uids, async (uids) => {
|
||||
const exists = await db.exists(uids.map(uid => `userRemote:${uid}`));
|
||||
|
||||
const uidsThatExist = uids.filter((uid, idx) => exists[idx]);
|
||||
const uidsThatDontExist = uids.filter((uid, idx) => !exists[idx]);
|
||||
|
||||
const [postCounts, roomCounts, followCounts] = await Promise.all([
|
||||
db.sortedSetsCard(uidsThatExist.map(uid => `uid:${uid}:posts`)),
|
||||
db.sortedSetsCard(uidsThatExist.map(uid => `uid:${uid}:chat:rooms`)),
|
||||
Actors.getLocalFollowCounts(uidsThatExist),
|
||||
const preservedIds = [];
|
||||
await batch.processArray(ids, async (ids) => {
|
||||
const exists = await Promise.all([
|
||||
db.exists(ids.map(id => `userRemote:${id}`)),
|
||||
db.exists(ids.map(id => `categoryRemote:${id}`)),
|
||||
]);
|
||||
|
||||
await Promise.all(uidsThatExist.map(async (uid, idx) => {
|
||||
let uids = new Set();
|
||||
let cids = new Set();
|
||||
const missing = new Set();
|
||||
ids.forEach((id, idx) => {
|
||||
switch (true) {
|
||||
case exists[0][idx]: {
|
||||
uids.add(id);
|
||||
break;
|
||||
}
|
||||
|
||||
case exists[1][idx]: {
|
||||
cids.add(id);
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
missing.add(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
uids = Array.from(uids);
|
||||
cids = Array.from(cids);
|
||||
|
||||
// Remote users
|
||||
const [postCounts, roomCounts, followCounts] = await Promise.all([
|
||||
db.sortedSetsCard(uids.map(uid => `uid:${uid}:posts`)),
|
||||
db.sortedSetsCard(uids.map(uid => `uid:${uid}:chat:rooms`)),
|
||||
Actors.getLocalFollowCounts(uids),
|
||||
]);
|
||||
|
||||
await Promise.all(uids.map(async (uid, idx) => {
|
||||
const { followers, following } = followCounts[idx];
|
||||
const postCount = postCounts[idx];
|
||||
const roomCount = roomCounts[idx];
|
||||
@@ -354,20 +644,46 @@ Actors.prune = async () => {
|
||||
}
|
||||
} else {
|
||||
notDeletedDueToLocalContent += 1;
|
||||
notDeletedUids.push(uid);
|
||||
preservedIds.push(uid);
|
||||
}
|
||||
}));
|
||||
|
||||
deletionCountNonExisting += uidsThatDontExist.length;
|
||||
await db.sortedSetRemove('usersRemote:lastCrawled', uidsThatDontExist);
|
||||
// Remote categories
|
||||
let counts = await categories.getCategoriesFields(cids, ['topic_count']);
|
||||
counts = counts.map(count => count.topic_count);
|
||||
await Promise.all(cids.map(async (cid, idx) => {
|
||||
const topicCount = counts[idx];
|
||||
if (topicCount === 0) {
|
||||
try {
|
||||
await categories.purge(cid, 0);
|
||||
deletionCount += 1;
|
||||
} catch (err) {
|
||||
winston.error(err.stack);
|
||||
}
|
||||
} else {
|
||||
notDeletedDueToLocalContent += 1;
|
||||
preservedIds.push(cid);
|
||||
}
|
||||
}));
|
||||
|
||||
deletionCountNonExisting += missing.size;
|
||||
await db.sortedSetRemove('usersRemote:lastCrawled', Array.from(missing));
|
||||
// update timestamp in usersRemote:lastCrawled so we don't try to delete users
|
||||
// with content over and over
|
||||
const now = Date.now();
|
||||
await db.sortedSetAdd('usersRemote:lastCrawled', notDeletedUids.map(() => now), notDeletedUids);
|
||||
await db.sortedSetAdd('usersRemote:lastCrawled', preservedIds.map(() => now), preservedIds);
|
||||
}, {
|
||||
batch: 50,
|
||||
interval: 1000,
|
||||
});
|
||||
|
||||
winston.info(`[actors/prune] ${deletionCount} remote users pruned. ${deletionCountNonExisting} does not exist. ${notDeletedDueToLocalContent} not deleted due to local content`);
|
||||
activitypub.helpers.log(`[actors/prune] ${deletionCount} remote users pruned. ${deletionCountNonExisting} did not exist. ${notDeletedDueToLocalContent} not deleted due to local content`);
|
||||
return {
|
||||
counts: {
|
||||
deleted: deletionCount,
|
||||
missing: deletionCountNonExisting,
|
||||
preserved: notDeletedDueToLocalContent,
|
||||
},
|
||||
preserved: new Set(preservedIds),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}, {
|
||||
|
||||
@@ -129,7 +129,7 @@ Mocks._normalize = async (object) => {
|
||||
};
|
||||
};
|
||||
|
||||
Mocks.profile = async (actors, hostMap) => {
|
||||
Mocks.profile = async (actors) => {
|
||||
// Should only ever be called by activitypub.actors.assert
|
||||
const profiles = await Promise.all(actors.map(async (actor) => {
|
||||
if (!actor) {
|
||||
@@ -137,7 +137,7 @@ Mocks.profile = async (actors, hostMap) => {
|
||||
}
|
||||
|
||||
const uid = actor.id;
|
||||
let hostname = hostMap.get(uid);
|
||||
let hostname;
|
||||
let {
|
||||
url, preferredUsername, published, icon, image,
|
||||
name, summary, followers, inbox, endpoints, tag,
|
||||
@@ -145,12 +145,10 @@ Mocks.profile = async (actors, hostMap) => {
|
||||
preferredUsername = slugify(preferredUsername || name);
|
||||
const { followers: followerCount, following: followingCount } = await activitypub.actors.getLocalFollowCounts(uid);
|
||||
|
||||
if (!hostname) { // if not available via webfinger, infer from id
|
||||
try {
|
||||
({ hostname } = new URL(actor.id));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
({ hostname } = new URL(actor.id));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let picture;
|
||||
@@ -218,7 +216,7 @@ Mocks.profile = async (actors, hostMap) => {
|
||||
uploadedpicture: undefined,
|
||||
'cover:url': !image || typeof image === 'string' ? image : image.url,
|
||||
'cover:position': '50% 50%',
|
||||
aboutme: summary,
|
||||
aboutme: posts.sanitize(summary),
|
||||
followerCount,
|
||||
followingCount,
|
||||
|
||||
@@ -235,6 +233,77 @@ Mocks.profile = async (actors, hostMap) => {
|
||||
return profiles;
|
||||
};
|
||||
|
||||
Mocks.category = async (actors) => {
|
||||
const categories = await Promise.all(actors.map(async (actor) => {
|
||||
if (!actor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cid = actor.id;
|
||||
let hostname;
|
||||
let {
|
||||
url, preferredUsername, icon, /* image, */
|
||||
name, summary, followers, inbox, endpoints, tag,
|
||||
} = actor;
|
||||
preferredUsername = slugify(preferredUsername || name);
|
||||
// const { followers: followerCount, following: followingCount } = await activitypub.actors.getLocalFollowCounts(uid);
|
||||
|
||||
try {
|
||||
({ hostname } = new URL(actor.id));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// No support for category avatars yet ;(
|
||||
// let picture;
|
||||
// if (image) {
|
||||
// picture = typeof image === 'string' ? image : image.url;
|
||||
// }
|
||||
const iconBackgrounds = await user.getIconBackgrounds();
|
||||
let bgColor = Array.prototype.reduce.call(preferredUsername, (cur, next) => cur + next.charCodeAt(), 0);
|
||||
bgColor = iconBackgrounds[bgColor % iconBackgrounds.length];
|
||||
|
||||
const backgroundImage = !icon || typeof icon === 'string' ? icon : icon.url;
|
||||
|
||||
// Replace emoji in summary
|
||||
if (tag && Array.isArray(tag)) {
|
||||
tag
|
||||
.filter(tag => tag.type === 'Emoji' &&
|
||||
isEmojiShortcode.test(tag.name) &&
|
||||
tag.icon && tag.icon.mediaType && tag.icon.mediaType.startsWith('image/'))
|
||||
.forEach((tag) => {
|
||||
summary = summary.replace(new RegExp(tag.name, 'g'), `<img class="not-responsive emoji" src="${tag.icon.url}" title="${tag.name}" />`);
|
||||
});
|
||||
}
|
||||
|
||||
const payload = {
|
||||
cid,
|
||||
name,
|
||||
handle: preferredUsername,
|
||||
slug: `${preferredUsername}@${hostname}`,
|
||||
description: summary,
|
||||
descriptionParsed: posts.sanitize(summary),
|
||||
icon: backgroundImage ? 'fa-none' : 'fa-comments',
|
||||
color: '#fff',
|
||||
bgColor,
|
||||
backgroundImage,
|
||||
imageClass: 'cover',
|
||||
numRecentReplies: 1,
|
||||
// followerCount,
|
||||
// followingCount,
|
||||
|
||||
url,
|
||||
inbox,
|
||||
sharedInbox: endpoints ? endpoints.sharedInbox : null,
|
||||
followersUrl: followers,
|
||||
};
|
||||
|
||||
return payload;
|
||||
}));
|
||||
|
||||
return categories;
|
||||
};
|
||||
|
||||
Mocks.post = async (objects) => {
|
||||
let single = false;
|
||||
if (!Array.isArray(objects)) {
|
||||
@@ -492,7 +561,6 @@ Mocks.notes.public = async (post) => {
|
||||
const published = post.timestampISO;
|
||||
const updated = post.edited ? post.editedISO : null;
|
||||
|
||||
// todo: post visibility
|
||||
const to = new Set([activitypub._constants.publicAddress]);
|
||||
const cc = new Set([`${nconf.get('url')}/uid/${post.user.uid}/followers`]);
|
||||
|
||||
@@ -637,13 +705,15 @@ Mocks.notes.public = async (post) => {
|
||||
* audience is exposed as part of 1b12 but is now ignored by Lemmy.
|
||||
* Remove this and most references to audience in 2026.
|
||||
*/
|
||||
let audience = `${nconf.get('url')}/category/${post.category.cid}`; // default
|
||||
let audience = utils.isNumber(post.category.cid) ? // default
|
||||
`${nconf.get('url')}/category/${post.category.cid}` : post.category.cid;
|
||||
if (inReplyTo) {
|
||||
const chain = await activitypub.notes.getParentChain(post.uid, inReplyTo);
|
||||
chain.forEach((post) => {
|
||||
audience = post.audience || audience;
|
||||
});
|
||||
}
|
||||
to.add(audience);
|
||||
|
||||
let object = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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}`));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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('=');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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]]');
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -82,11 +82,15 @@ module.exports = function (User) {
|
||||
if (parseInt(uid, 10) <= 0) {
|
||||
return [];
|
||||
}
|
||||
const uids = await db.getSortedSetRevRange([
|
||||
let uids = await db.getSortedSetRevRange([
|
||||
`${type}:${uid}`,
|
||||
`${type}Remote:${uid}`,
|
||||
], start, stop);
|
||||
|
||||
// Filter out remote categories
|
||||
const isCategory = await db.exists(uids.map(uid => `categoryRemote:${uid}`));
|
||||
uids = uids.filter((uid, idx) => !isCategory[idx])
|
||||
|
||||
const data = await plugins.hooks.fire(`filter:user.${type}`, {
|
||||
uids: uids,
|
||||
uid: uid,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ const nconf = require('nconf');
|
||||
|
||||
const db = require('../mocks/databasemock');
|
||||
const meta = require('../../src/meta');
|
||||
const install = require('../../src/install');
|
||||
const categories = require('../../src/categories');
|
||||
const user = require('../../src/user');
|
||||
const topics = require('../../src/topics');
|
||||
@@ -13,7 +14,14 @@ const utils = require('../../src/utils');
|
||||
const request = require('../../src/request');
|
||||
const slugify = require('../../src/slugify');
|
||||
|
||||
const helpers = require('./helpers');
|
||||
|
||||
describe('Actor asserton', () => {
|
||||
before(async () => {
|
||||
meta.config.activitypubEnabled = 1;
|
||||
await install.giveWorldPrivileges();
|
||||
});
|
||||
|
||||
describe('happy path', () => {
|
||||
let uid;
|
||||
let actorUri;
|
||||
@@ -58,9 +66,139 @@ describe('Actor asserton', () => {
|
||||
const url = await user.getUserField(actorUri, 'url');
|
||||
assert.strictEqual(url, actorUri);
|
||||
});
|
||||
|
||||
it('should assert group actors by calling actors.assertGroup', async () => {
|
||||
const { id, actor } = helpers.mocks.group();
|
||||
const assertion = await activitypub.actors.assert([id]);
|
||||
|
||||
assert(assertion);
|
||||
assert.strictEqual(assertion.length, 1);
|
||||
assert.strictEqual(assertion[0].cid, actor.id);
|
||||
});
|
||||
|
||||
describe('remote user to remote category migration', () => {
|
||||
it('should not migrate a user to a category if .assert is called', async () => {
|
||||
// ... because the user isn't due for an update and so is filtered out during qualification
|
||||
const { id } = helpers.mocks.person();
|
||||
await activitypub.actors.assert([id]);
|
||||
|
||||
const { actor } = helpers.mocks.group({ id });
|
||||
const assertion = await activitypub.actors.assertGroup([id]);
|
||||
|
||||
assert(assertion.length, 0);
|
||||
|
||||
const exists = await user.exists(id);
|
||||
assert.strictEqual(exists, false);
|
||||
});
|
||||
|
||||
it('should migrate a user to a category if on re-assertion it identifies as an as:Group', async () => {
|
||||
// This is to handle previous behaviour that saved all as:Group actors as NodeBB users.
|
||||
const { id } = helpers.mocks.person();
|
||||
await activitypub.actors.assert([id]);
|
||||
|
||||
helpers.mocks.group({ id });
|
||||
const assertion = await activitypub.actors.assertGroup([id]);
|
||||
|
||||
assert(assertion && Array.isArray(assertion) && assertion.length === 1);
|
||||
|
||||
const exists = await user.exists(id);
|
||||
assert.strictEqual(exists, false);
|
||||
});
|
||||
|
||||
it('should migrate any shares by that user, into topics in the category', async () => {
|
||||
const { id } = helpers.mocks.person();
|
||||
await activitypub.actors.assert([id]);
|
||||
|
||||
// Two shares
|
||||
for (let x = 0; x < 2; x++) {
|
||||
const { id: pid } = helpers.mocks.note();
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const { tid } = await activitypub.notes.assert(0, pid, { skipChecks: 1 });
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await db.sortedSetAdd(`uid:${id}:shares`, Date.now(), tid);
|
||||
}
|
||||
|
||||
helpers.mocks.group({ id });
|
||||
await activitypub.actors.assertGroup([id]);
|
||||
|
||||
const { topic_count, post_count } = await categories.getCategoryData(id);
|
||||
assert.strictEqual(topic_count, 2);
|
||||
assert.strictEqual(post_count, 2);
|
||||
});
|
||||
|
||||
it('should not migrate shares by that user that already belong to a local category', async () => {
|
||||
const { id } = helpers.mocks.person();
|
||||
await activitypub.actors.assert([id]);
|
||||
|
||||
const { cid } = await categories.create({ name: utils.generateUUID() });
|
||||
|
||||
// Two shares, one moved to local cid
|
||||
for (let x = 0; x < 2; x++) {
|
||||
const { id: pid } = helpers.mocks.note();
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const { tid } = await activitypub.notes.assert(0, pid, { skipChecks: 1 });
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await db.sortedSetAdd(`uid:${id}:shares`, Date.now(), tid);
|
||||
|
||||
if (!x) {
|
||||
await topics.tools.move(tid, {
|
||||
cid,
|
||||
uid: 'system',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
helpers.mocks.group({ id });
|
||||
await activitypub.actors.assertGroup([id]);
|
||||
|
||||
const { topic_count, post_count } = await categories.getCategoryData(id);
|
||||
assert.strictEqual(topic_count, 1);
|
||||
assert.strictEqual(post_count, 1);
|
||||
});
|
||||
|
||||
it('should migrate any local followers into category watches', async () => {
|
||||
const { id } = helpers.mocks.person();
|
||||
await activitypub.actors.assert([id]);
|
||||
|
||||
const followerUid = await user.create({ username: utils.generateUUID() });
|
||||
await Promise.all([
|
||||
db.sortedSetAdd(`followingRemote:${followerUid}`, Date.now(), id),
|
||||
db.sortedSetAdd(`followersRemote:${id}`, Date.now(), followerUid),
|
||||
]);
|
||||
|
||||
helpers.mocks.group({ id });
|
||||
await activitypub.actors.assertGroup([id]);
|
||||
|
||||
const states = await categories.getWatchState([id], followerUid);
|
||||
assert.strictEqual(states[0], categories.watchStates.tracking);
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
describe('edge case: loopback handles and uris', () => {
|
||||
describe('less happy paths', () => {
|
||||
describe('actor with `preferredUsername` that is not all lowercase', () => {
|
||||
it('should save a handle-to-uid association', async () => {
|
||||
const preferredUsername = 'nameWITHCAPS';
|
||||
const { id } = helpers.mocks.person({ preferredUsername });
|
||||
await activitypub.actors.assert([id]);
|
||||
|
||||
const uid = await db.getObjectField('handle:uid', `${preferredUsername.toLowerCase()}@example.org`);
|
||||
assert.strictEqual(uid, id);
|
||||
});
|
||||
|
||||
it('should preserve that association when re-asserted', async () => {
|
||||
const preferredUsername = 'nameWITHCAPS';
|
||||
const { id } = helpers.mocks.person({ preferredUsername });
|
||||
await activitypub.actors.assert([id]);
|
||||
await activitypub.actors.assert([id], { update: true });
|
||||
|
||||
const uid = await db.getObjectField('handle:uid', `${preferredUsername.toLowerCase()}@example.org`);
|
||||
assert.strictEqual(uid, id);
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases: loopback handles and uris', () => {
|
||||
let uid;
|
||||
const userslug = utils.generateUUID().slice(0, 8);
|
||||
before(async () => {
|
||||
@@ -90,6 +228,257 @@ describe('Actor asserton', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('as:Group', () => {
|
||||
describe('assertion', () => {
|
||||
let actorUri;
|
||||
let actorData;
|
||||
|
||||
before(async () => {
|
||||
const { id, actor } = helpers.mocks.group();
|
||||
actorUri = id;
|
||||
actorData = actor;
|
||||
});
|
||||
|
||||
it('should assert a uri identifying as "Group" into a remote category', async () => {
|
||||
const assertion = await activitypub.actors.assertGroup([actorUri]);
|
||||
|
||||
assert(assertion, Array.isArray(assertion));
|
||||
assert.strictEqual(assertion.length, 1);
|
||||
|
||||
const category = assertion.pop();
|
||||
assert.strictEqual(category.cid, actorUri);
|
||||
});
|
||||
|
||||
it('should be considered existing when checked', async () => {
|
||||
const exists = await categories.exists(actorUri);
|
||||
|
||||
assert(exists);
|
||||
});
|
||||
|
||||
it('should contain an entry in categories search zset', async () => {
|
||||
const exists = await db.isSortedSetMember('categories:name', `${actorData.name.toLowerCase()}:${actorUri}`);
|
||||
|
||||
assert(exists);
|
||||
});
|
||||
|
||||
it('should return category data when getter methods are called', async () => {
|
||||
const category = await categories.getCategoryData(actorUri);
|
||||
assert(category);
|
||||
assert.strictEqual(category.cid, actorUri);
|
||||
});
|
||||
|
||||
it('should not assert non-group users when called', async () => {
|
||||
const { id } = helpers.mocks.person();
|
||||
const assertion = await activitypub.actors.assertGroup([id]);
|
||||
|
||||
assert(Array.isArray(assertion) && !assertion.length);
|
||||
});
|
||||
|
||||
describe('deletion', () => {
|
||||
it('should delete a remote category when Categories.purge is called', async () => {
|
||||
const { id } = helpers.mocks.group();
|
||||
await activitypub.actors.assertGroup([id]);
|
||||
|
||||
let exists = await categories.exists(id);
|
||||
assert(exists);
|
||||
|
||||
await categories.purge(id, 0);
|
||||
|
||||
exists = await categories.exists(id);
|
||||
assert(!exists);
|
||||
|
||||
exists = await db.exists(`categoryRemote:${id}`);
|
||||
assert(!exists);
|
||||
});
|
||||
|
||||
it('should also delete AP-specific keys that were added by assertGroup', async () => {
|
||||
const { id } = helpers.mocks.group();
|
||||
const assertion = await activitypub.actors.assertGroup([id]);
|
||||
const [{ handle, slug }] = assertion;
|
||||
|
||||
await categories.purge(id, 0);
|
||||
|
||||
const isMember = await db.isObjectField('handle:cid', handle);
|
||||
const inSearch = await db.isSortedSetMember('categories:name', `${slug}:${id}`);
|
||||
assert(!isMember);
|
||||
assert(!inSearch);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('following', () => {
|
||||
let uid;
|
||||
let cid;
|
||||
|
||||
beforeEach(async () => {
|
||||
uid = await user.create({ username: utils.generateUUID() });
|
||||
({ id: cid } = helpers.mocks.group());
|
||||
await activitypub.actors.assertGroup([cid]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
activitypub._sent.clear();
|
||||
});
|
||||
|
||||
describe('user not already following', () => {
|
||||
it('should report a watch state consistent with not following', async () => {
|
||||
const states = await categories.getWatchState([cid], uid);
|
||||
assert(states[0] <= categories.watchStates.notwatching);
|
||||
});
|
||||
|
||||
it('should do nothing when category is a local category', async () => {
|
||||
const { cid } = await categories.create({ name: utils.generateUUID() });
|
||||
await user.setCategoryWatchState(uid, cid, categories.watchStates.tracking);
|
||||
assert.strictEqual(activitypub._sent.size, 0);
|
||||
});
|
||||
|
||||
it('should do nothing when watch state changes to "ignoring"', async () => {
|
||||
await user.setCategoryWatchState(uid, cid, categories.watchStates.ignoring);
|
||||
assert.strictEqual(activitypub._sent.size, 0);
|
||||
});
|
||||
|
||||
it('should send out a Follow activity when watch state changes to "tracking"', async () => {
|
||||
await user.setCategoryWatchState(uid, cid, categories.watchStates.tracking);
|
||||
|
||||
assert.strictEqual(activitypub._sent.size, 1);
|
||||
|
||||
const activity = Array.from(activitypub._sent.values()).pop();
|
||||
assert.strictEqual(activity.type, 'Follow');
|
||||
assert.strictEqual(activity.object, cid);
|
||||
});
|
||||
|
||||
it('should send out a Follow activity when the watch state changes to "watching"', async () => {
|
||||
await user.setCategoryWatchState(uid, cid, categories.watchStates.watching);
|
||||
|
||||
assert.strictEqual(activitypub._sent.size, 1);
|
||||
|
||||
const activity = Array.from(activitypub._sent.values()).pop();
|
||||
assert(activity && activity.object && typeof activity.object === 'string');
|
||||
assert.strictEqual(activity.type, 'Follow');
|
||||
assert.strictEqual(activity.object, cid);
|
||||
});
|
||||
|
||||
it('should not show up in the user\'s following list', async () => {
|
||||
await user.setCategoryWatchState(uid, cid, categories.watchStates.watching);
|
||||
|
||||
// Trigger inbox accept
|
||||
const { activity: body } = helpers.mocks.accept(cid, {
|
||||
type: 'Follow',
|
||||
actor: `${nconf.get('url')}/uid/${uid}`,
|
||||
});
|
||||
await activitypub.inbox.accept({ body });
|
||||
|
||||
const following = await user.getFollowing(uid, 0, 1);
|
||||
assert(Array.isArray(following));
|
||||
assert.strictEqual(following.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('user already following', () => {
|
||||
beforeEach(async () => {
|
||||
await Promise.all([
|
||||
user.setCategoryWatchState(uid, cid, categories.watchStates.tracking),
|
||||
db.sortedSetAdd(`followingRemote:${uid}`, Date.now(), cid),
|
||||
]);
|
||||
|
||||
activitypub._sent.clear();
|
||||
});
|
||||
|
||||
it('should report a watch state consistent with following', async () => {
|
||||
const states = await categories.getWatchState([cid], uid);
|
||||
assert(states[0] >= categories.watchStates.tracking);
|
||||
});
|
||||
|
||||
it('should do nothing when category is a local category', async () => {
|
||||
const { cid } = await categories.create({ name: utils.generateUUID() });
|
||||
await user.setCategoryWatchState(uid, cid, categories.watchStates.ignoring);
|
||||
assert.strictEqual(activitypub._sent.size, 0);
|
||||
});
|
||||
|
||||
it('should do nothing when watch state changes to "tracking"', async () => {
|
||||
await user.setCategoryWatchState(uid, cid, categories.watchStates.tracking);
|
||||
assert.strictEqual(activitypub._sent.size, 0);
|
||||
});
|
||||
|
||||
it('should do nothing when watch state changes to "watching"', async () => {
|
||||
await user.setCategoryWatchState(uid, cid, categories.watchStates.watching);
|
||||
assert.strictEqual(activitypub._sent.size, 0);
|
||||
});
|
||||
|
||||
it('should send out an Undo(Follow) activity when watch state changes to "ignoring"', async () => {
|
||||
await user.setCategoryWatchState(uid, cid, categories.watchStates.ignoring);
|
||||
|
||||
assert.strictEqual(activitypub._sent.size, 1);
|
||||
|
||||
const activity = Array.from(activitypub._sent.values()).pop();
|
||||
assert(activity && activity.object && typeof activity.object === 'object');
|
||||
assert.strictEqual(activity.type, 'Undo');
|
||||
assert.strictEqual(activity.object.type, 'Follow');
|
||||
assert.strictEqual(activity.object.actor, `${nconf.get('url')}/uid/${uid}`);
|
||||
assert.strictEqual(activity.object.object, cid);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Inbox resolution', () => {
|
||||
describe('remote users', () => {
|
||||
it('should return an inbox if present', async () => {
|
||||
const { id, actor } = helpers.mocks.person();
|
||||
await activitypub.actors.assert(id);
|
||||
|
||||
const inboxes = await activitypub.resolveInboxes([id]);
|
||||
|
||||
assert(inboxes && Array.isArray(inboxes));
|
||||
assert.strictEqual(inboxes.length, 1);
|
||||
assert.strictEqual(inboxes[0], actor.inbox);
|
||||
});
|
||||
|
||||
it('should return a shared inbox if present', async () => {
|
||||
const { id, actor } = helpers.mocks.person({
|
||||
endpoints: {
|
||||
sharedInbox: 'https://example.org/inbox',
|
||||
}
|
||||
});
|
||||
await activitypub.actors.assert(id);
|
||||
|
||||
const inboxes = await activitypub.resolveInboxes([id]);
|
||||
|
||||
assert(inboxes && Array.isArray(inboxes));
|
||||
assert.strictEqual(inboxes.length, 1);
|
||||
assert.strictEqual(inboxes[0], 'https://example.org/inbox');
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote categories', () => {
|
||||
it('should return an inbox if present', async () => {
|
||||
const { id, actor } = helpers.mocks.group();
|
||||
await activitypub.actors.assertGroup(id);
|
||||
|
||||
const inboxes = await activitypub.resolveInboxes([id]);
|
||||
|
||||
assert(inboxes && Array.isArray(inboxes));
|
||||
assert.strictEqual(inboxes.length, 1);
|
||||
assert.strictEqual(inboxes[0], actor.inbox);
|
||||
});
|
||||
|
||||
it('should return a shared inbox if present', async () => {
|
||||
const { id, actor } = helpers.mocks.group({
|
||||
endpoints: {
|
||||
sharedInbox: 'https://example.org/inbox',
|
||||
}
|
||||
});
|
||||
await activitypub.actors.assertGroup(id);
|
||||
|
||||
const inboxes = await activitypub.resolveInboxes([id]);
|
||||
|
||||
assert(inboxes && Array.isArray(inboxes));
|
||||
assert.strictEqual(inboxes.length, 1);
|
||||
assert.strictEqual(inboxes[0], 'https://example.org/inbox');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Controllers', () => {
|
||||
describe('User Actor endpoint', () => {
|
||||
let uid;
|
||||
@@ -393,3 +782,106 @@ describe('Controllers', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pruning', () => {
|
||||
before(async () => {
|
||||
meta.config.activitypubEnabled = 1;
|
||||
await install.giveWorldPrivileges();
|
||||
|
||||
meta.config.activitypubUserPruneDays = 0; // trigger immediate pruning
|
||||
});
|
||||
|
||||
after(() => {
|
||||
meta.config.activitypubUserPruneDays = 7;
|
||||
});
|
||||
|
||||
describe('Users', () => {
|
||||
it('should do nothing if the user is newer than the prune cutoff', async () => {
|
||||
const { id: uid } = helpers.mocks.person();
|
||||
await activitypub.actors.assert([uid]);
|
||||
|
||||
meta.config.activitypubUserPruneDays = 1;
|
||||
const result = await activitypub.actors.prune();
|
||||
|
||||
assert.strictEqual(result.counts.deleted, 0);
|
||||
assert.strictEqual(result.counts.preserved, 0);
|
||||
assert.strictEqual(result.counts.missing, 0);
|
||||
|
||||
meta.config.activitypubUserPruneDays = 0;
|
||||
user.deleteAccount(uid);
|
||||
});
|
||||
|
||||
it('should purge the user if they have no content (posts, likes, etc.)', async () => {
|
||||
const { id: uid } = helpers.mocks.person();
|
||||
await activitypub.actors.assert([uid]);
|
||||
|
||||
const total = await db.sortedSetCard('usersRemote:lastCrawled');
|
||||
const result = await activitypub.actors.prune();
|
||||
|
||||
assert(result.counts.deleted >= 1);
|
||||
});
|
||||
|
||||
it('should do nothing if the user has some content (e.g. a topic)', async () => {
|
||||
const { cid } = await categories.create({ name: utils.generateUUID() });
|
||||
const { id: uid } = helpers.mocks.person();
|
||||
const { id, note } = helpers.mocks.note({
|
||||
attributedTo: uid,
|
||||
cc: [`${nconf.get('url')}/category/${cid}`],
|
||||
});
|
||||
|
||||
const assertion = await activitypub.notes.assert(0, id);
|
||||
assert(assertion);
|
||||
|
||||
const result = await activitypub.actors.prune();
|
||||
|
||||
assert.strictEqual(result.counts.deleted, 0);
|
||||
assert.strictEqual(result.counts.preserved, 1);
|
||||
assert.strictEqual(result.counts.missing, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Categories', () => {
|
||||
it('should do nothing if the category is newer than the prune cutoff', async () => {
|
||||
const { id: cid } = helpers.mocks.group();
|
||||
await activitypub.actors.assertGroup([cid]);
|
||||
|
||||
meta.config.activitypubUserPruneDays = 1;
|
||||
const result = await activitypub.actors.prune();
|
||||
|
||||
assert.strictEqual(result.counts.deleted, 0);
|
||||
assert.strictEqual(result.counts.preserved, 0);
|
||||
assert.strictEqual(result.counts.missing, 0);
|
||||
|
||||
meta.config.activitypubUserPruneDays = 0;
|
||||
await categories.purge(cid, 0);
|
||||
});
|
||||
|
||||
it('should purge the category if it has no topics in it', async () => {
|
||||
const { id: cid } = helpers.mocks.group();
|
||||
await activitypub.actors.assertGroup([cid]);
|
||||
|
||||
const total = await db.sortedSetCard('usersRemote:lastCrawled');
|
||||
const result = await activitypub.actors.prune();
|
||||
|
||||
assert.strictEqual(result.counts.deleted, 1);
|
||||
assert.strictEqual(result.counts.preserved, total - 1);
|
||||
});
|
||||
|
||||
it('should do nothing if the category has topics in it', async () => {
|
||||
const { id: cid } = helpers.mocks.group();
|
||||
await activitypub.actors.assertGroup([cid]);
|
||||
|
||||
const { id } = helpers.mocks.note({
|
||||
cc: [cid],
|
||||
});
|
||||
await activitypub.notes.assert(0, id);
|
||||
|
||||
const total = await db.sortedSetCard('usersRemote:lastCrawled');
|
||||
const result = await activitypub.actors.prune();
|
||||
|
||||
assert.strictEqual(result.counts.deleted, 0);
|
||||
assert.strictEqual(result.counts.preserved, total);
|
||||
assert(result.preserved.has(cid));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -8,32 +8,50 @@ const Helpers = module.exports;
|
||||
|
||||
Helpers.mocks = {};
|
||||
|
||||
Helpers.mocks.actor = () => {
|
||||
Helpers.mocks.person = (override = {}) => {
|
||||
const baseUrl = 'https://example.org';
|
||||
const uuid = utils.generateUUID();
|
||||
const id = `${baseUrl}/${uuid}`;
|
||||
let id = `${baseUrl}/${uuid}`;
|
||||
if (override.hasOwnProperty('id')) {
|
||||
id = override.id;
|
||||
}
|
||||
|
||||
|
||||
const actor = {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1',
|
||||
],
|
||||
id: `${id}`,
|
||||
id,
|
||||
url: `${id}`,
|
||||
inbox: `${id}/inbox`,
|
||||
outbox: `${id}/outbox`,
|
||||
|
||||
type: 'Person',
|
||||
name: slugify(uuid),
|
||||
preferredUsername: uuid,
|
||||
name: slugify(id),
|
||||
preferredUsername: id,
|
||||
|
||||
publicKey: {
|
||||
id: `${id}#key`,
|
||||
owner: `${id}`,
|
||||
publicKeyPem: 'todo',
|
||||
},
|
||||
...override,
|
||||
};
|
||||
|
||||
activitypub._cache.set(`0;${id}`, actor);
|
||||
|
||||
return { id, actor };
|
||||
};
|
||||
|
||||
Helpers.mocks.group = (override = {}) => {
|
||||
const { id, actor } = Helpers.mocks.person({
|
||||
type: 'Group',
|
||||
...override,
|
||||
});
|
||||
|
||||
activitypub._cache.set(`0;${id}`, actor);
|
||||
|
||||
return { id, actor };
|
||||
};
|
||||
|
||||
@@ -88,3 +106,20 @@ Helpers.mocks.create = (object) => {
|
||||
|
||||
return { id, activity };
|
||||
};
|
||||
|
||||
Helpers.mocks.accept = (actor, object) => {
|
||||
const baseUrl = 'https://example.org';
|
||||
const uuid = utils.generateUUID();
|
||||
const id = `${baseUrl}/activity/${uuid}`;
|
||||
|
||||
const activity = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
id,
|
||||
type: 'Accept',
|
||||
to: ['https://www.w3.org/ns/activitystreams#Public'],
|
||||
actor,
|
||||
object,
|
||||
};
|
||||
|
||||
return { activity };
|
||||
}
|
||||
|
||||
@@ -10,18 +10,19 @@ const user = require('../../src/user');
|
||||
const categories = require('../../src/categories');
|
||||
const posts = require('../../src/posts');
|
||||
const topics = require('../../src/topics');
|
||||
const api = require('../../src/api');
|
||||
const activitypub = require('../../src/activitypub');
|
||||
const utils = require('../../src/utils');
|
||||
|
||||
const helpers = require('./helpers');
|
||||
|
||||
describe('Notes', () => {
|
||||
describe('Assertion', () => {
|
||||
before(async () => {
|
||||
meta.config.activitypubEnabled = 1;
|
||||
await install.giveWorldPrivileges();
|
||||
});
|
||||
before(async () => {
|
||||
meta.config.activitypubEnabled = 1;
|
||||
await install.giveWorldPrivileges();
|
||||
});
|
||||
|
||||
describe('Assertion', () => {
|
||||
describe('Public objects', () => {
|
||||
it('should pull a remote root-level object by its id and create a new topic', async () => {
|
||||
const { id } = helpers.mocks.note();
|
||||
@@ -63,6 +64,152 @@ describe('Notes', () => {
|
||||
const exists = await topics.exists(tid);
|
||||
assert(exists);
|
||||
});
|
||||
|
||||
describe('Category-specific behaviours', () => {
|
||||
it('should slot newly created topic in local category if addressed', async () => {
|
||||
const { cid } = await categories.create({ name: utils.generateUUID() });
|
||||
const { id } = helpers.mocks.note({
|
||||
cc: [`${nconf.get('url')}/category/${cid}`],
|
||||
});
|
||||
|
||||
const assertion = await activitypub.notes.assert(0, id);
|
||||
assert(assertion);
|
||||
|
||||
const { tid, count } = assertion;
|
||||
assert(tid);
|
||||
assert.strictEqual(count, 1);
|
||||
|
||||
const topic = await topics.getTopicData(tid);
|
||||
assert.strictEqual(topic.cid, cid);
|
||||
});
|
||||
|
||||
it('should slot newly created topic in remote category if addressed', async () => {
|
||||
const { id: cid, actor } = helpers.mocks.group();
|
||||
await activitypub.actors.assertGroup([cid]);
|
||||
|
||||
const { id } = helpers.mocks.note({
|
||||
cc: [cid],
|
||||
});
|
||||
|
||||
const assertion = await activitypub.notes.assert(0, id);
|
||||
assert(assertion);
|
||||
|
||||
const { tid, count } = assertion;
|
||||
assert(tid);
|
||||
assert.strictEqual(count, 1);
|
||||
|
||||
const topic = await topics.getTopicData(tid);
|
||||
assert.strictEqual(topic.cid, cid);
|
||||
|
||||
const tids = await db.getSortedSetMembers(`cid:${cid}:tids`);
|
||||
assert(tids.includes(tid));
|
||||
|
||||
const category = await categories.getCategoryData(cid);
|
||||
['topic_count', 'post_count', 'totalPostCount', 'totalTopicCount'].forEach((prop) => {
|
||||
assert.strictEqual(category[prop], 1);
|
||||
});
|
||||
});
|
||||
|
||||
it('should add a remote category topic to a user\'s inbox if they are following the category', async () => {
|
||||
const { id: cid, actor } = helpers.mocks.group();
|
||||
await activitypub.actors.assertGroup([cid]);
|
||||
|
||||
const uid = await user.create({ username: utils.generateUUID() });
|
||||
await api.categories.setWatchState({ uid }, { cid, state: categories.watchStates.tracking });
|
||||
|
||||
const { id } = helpers.mocks.note({
|
||||
cc: [cid],
|
||||
});
|
||||
const { tid } = await activitypub.notes.assert(0, id);
|
||||
|
||||
const inInbox = await db.isSortedSetMember(`uid:${uid}:inbox`, tid);
|
||||
assert(inInbox);
|
||||
});
|
||||
});
|
||||
|
||||
describe('User-specific behaviours', () => {
|
||||
let remoteCid;
|
||||
let uid;
|
||||
|
||||
before(async () => {
|
||||
// Remote
|
||||
const { id, actor } = helpers.mocks.group();
|
||||
remoteCid = id;
|
||||
await activitypub.actors.assertGroup([id]);
|
||||
|
||||
// User
|
||||
uid = await user.create({ username: utils.generateUUID() });
|
||||
await topics.markAllRead(uid);
|
||||
});
|
||||
|
||||
it('should not show up in my unread if it is in cid -1', async () => {
|
||||
const { id } = helpers.mocks.note();
|
||||
const assertion = await activitypub.notes.assert(0, id, { skipChecks: 1 });
|
||||
assert(assertion);
|
||||
|
||||
const unread = await topics.getTotalUnread(uid);
|
||||
assert.strictEqual(unread, 0);
|
||||
});
|
||||
|
||||
it('should show up in my recent/unread if I am tracking the remote category', async () => {
|
||||
await api.categories.setWatchState({ uid }, {
|
||||
cid: remoteCid,
|
||||
state: categories.watchStates.tracking,
|
||||
uid,
|
||||
});
|
||||
|
||||
const { id } = helpers.mocks.note({
|
||||
cc: [remoteCid],
|
||||
});
|
||||
const assertion = await activitypub.notes.assert(0, id);
|
||||
assert(assertion);
|
||||
|
||||
const unread = await topics.getTotalUnread(uid);
|
||||
assert.strictEqual(unread, 1);
|
||||
|
||||
await topics.markAllRead(uid);
|
||||
});
|
||||
|
||||
it('should show up in recent/unread and notify me if I am watching the remote category', async () => {
|
||||
await api.categories.setWatchState({ uid }, {
|
||||
cid: remoteCid,
|
||||
state: categories.watchStates.watching,
|
||||
uid,
|
||||
});
|
||||
|
||||
const { id, note } = helpers.mocks.note({
|
||||
cc: [remoteCid],
|
||||
});
|
||||
const assertion = await activitypub.notes.assert(0, id);
|
||||
assert(assertion);
|
||||
|
||||
const unread = await topics.getTotalUnread(uid);
|
||||
assert.strictEqual(unread, 1);
|
||||
|
||||
// Notification inbox delivery is async so can't test directly
|
||||
const exists = await db.exists(`notifications:new_topic:tid:${assertion.tid}:uid:${note.attributedTo}`);
|
||||
assert(exists);
|
||||
|
||||
await topics.markAllRead(uid);
|
||||
});
|
||||
|
||||
it('should not show up in recent/unread if I am ignoring the remote category', async () => {
|
||||
await api.categories.setWatchState({ uid }, {
|
||||
cid: remoteCid,
|
||||
state: categories.watchStates.ignoring,
|
||||
uid,
|
||||
});
|
||||
|
||||
const { id, note } = helpers.mocks.note({
|
||||
cc: [remoteCid],
|
||||
});
|
||||
const assertion = await activitypub.notes.assert(0, id);
|
||||
assert(assertion);
|
||||
|
||||
const unread = await topics.getTotalUnread(uid);
|
||||
assert.strictEqual(unread, 0);
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
describe('Private objects', () => {
|
||||
@@ -99,6 +246,139 @@ describe('Notes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Creation', () => {
|
||||
let uid;
|
||||
|
||||
before(async () => {
|
||||
uid = await user.create({ username: utils.generateUUID() });
|
||||
});
|
||||
|
||||
describe('Local categories', () => {
|
||||
let cid;
|
||||
|
||||
before(async () => {
|
||||
({ cid } = await categories.create({ name: utils.generateUUID() }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
activitypub._sent.clear();
|
||||
});
|
||||
|
||||
describe('new topics', () => {
|
||||
let activity;
|
||||
|
||||
before(async () => {
|
||||
const { tid } = await api.topics.create({ uid }, {
|
||||
cid,
|
||||
title: utils.generateUUID(),
|
||||
content: utils.generateUUID(),
|
||||
});
|
||||
|
||||
assert(tid);
|
||||
assert.strictEqual(activitypub._sent.size, 1);
|
||||
const key = Array.from(activitypub._sent.keys())[0];
|
||||
activity = activitypub._sent.get(key);
|
||||
});
|
||||
|
||||
it('should federate out a Create activity', () => {
|
||||
assert(activity && activity.to);
|
||||
assert.strictEqual(activity.type, 'Create');
|
||||
});
|
||||
|
||||
it('should have the local category addressed', () => {
|
||||
const addressees = new Set([
|
||||
...(activity.to || []),
|
||||
...(activity.cc || []),
|
||||
...(activity.bcc || []),
|
||||
...(activity.object.to || []),
|
||||
...(activity.object.cc || []),
|
||||
...(activity.object.bcc || []),
|
||||
]);
|
||||
|
||||
assert(addressees.has(`${nconf.get('url')}/category/${cid}`));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Remote Categories', () => {
|
||||
let cid;
|
||||
|
||||
before(async () => {
|
||||
({ id: cid } = helpers.mocks.group());
|
||||
await activitypub.actors.assert([cid]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
activitypub._sent.clear();
|
||||
});
|
||||
|
||||
describe('new topics', () => {
|
||||
it('should federate out a Create activity with the remote community addressed', async () => {
|
||||
const { tid } = await api.topics.create({ uid }, {
|
||||
cid,
|
||||
title: utils.generateUUID(),
|
||||
content: utils.generateUUID(),
|
||||
});
|
||||
|
||||
assert(tid);
|
||||
assert.strictEqual(activitypub._sent.size, 1);
|
||||
|
||||
const key = Array.from(activitypub._sent.keys())[0];
|
||||
const activity = activitypub._sent.get(key);
|
||||
assert(activity && activity.to);
|
||||
assert.strictEqual(activity.type, 'Create');
|
||||
|
||||
const addressees = new Set([
|
||||
...(activity.to || []),
|
||||
...(activity.cc || []),
|
||||
...(activity.bcc || []),
|
||||
...(activity.object.to || []),
|
||||
...(activity.object.cc || []),
|
||||
...(activity.object.bcc || []),
|
||||
]);
|
||||
|
||||
assert(addressees.has(cid));
|
||||
});
|
||||
});
|
||||
|
||||
describe('replies', () => {
|
||||
it('should federate out a Create activity with the remote community addressed', async () => {
|
||||
const { tid } = await api.topics.create({ uid }, {
|
||||
cid,
|
||||
title: utils.generateUUID(),
|
||||
content: utils.generateUUID(),
|
||||
});
|
||||
|
||||
activitypub._sent.clear();
|
||||
|
||||
const postData = await api.topics.reply({ uid }, {
|
||||
tid,
|
||||
content: utils.generateUUID(),
|
||||
});
|
||||
|
||||
assert(postData);
|
||||
assert.strictEqual(activitypub._sent.size, 1);
|
||||
|
||||
const key = Array.from(activitypub._sent.keys())[0];
|
||||
const activity = activitypub._sent.get(key);
|
||||
assert(activity && activity.to);
|
||||
assert.strictEqual(activity.type, 'Create');
|
||||
|
||||
const addressees = new Set([
|
||||
...(activity.to || []),
|
||||
...(activity.cc || []),
|
||||
...(activity.bcc || []),
|
||||
...(activity.object.to || []),
|
||||
...(activity.object.cc || []),
|
||||
...(activity.object.bcc || []),
|
||||
]);
|
||||
|
||||
assert(addressees.has(cid));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Inbox Synchronization', () => {
|
||||
let cid;
|
||||
let uid;
|
||||
|
||||
Reference in New Issue
Block a user