Compare commits

...

41 Commits

Author SHA1 Message Date
Julian Lam
15cc5b1540 chore: cut v4.3.0-alpha 2025-03-28 12:27:34 -04:00
Julian Lam
c1744258cc fix: #13255, assert all recipients of the main post when asserting a note, so that remote categories can be discovered 2025-03-28 12:25:40 -04:00
Julian Lam
3a507ea04c fix: remote categories should not show up in a user's follow lists 2025-03-28 12:25:40 -04:00
Julian Lam
a3fcb3a1c6 fix: #13255, remote user-to-category migration should not move shares that are already in an existing cid 2025-03-28 12:25:40 -04:00
Julian Lam
277f074cee fix: proper handling of actors.qualify response 2025-03-28 12:25:40 -04:00
Julian Lam
2eca994c57 fix: missing dep 2025-03-28 12:25:40 -04:00
Julian Lam
aa452a3b41 test: additional test for remote category topic assertion when ignoring category 2025-03-28 12:25:40 -04:00
Julian Lam
58580d4e48 fix: topics in remote categories showing up in /recent 2025-03-28 12:25:40 -04:00
Julian Lam
1a27a36c42 fix: regression that caused resolveInboxes to always return empty, added tests for resolveInboxes 2025-03-28 12:25:40 -04:00
Barış Soner Uşaklı
71d93d1754 dont make db call if ap disabled 2025-03-28 12:25:40 -04:00
Barış Soner Uşaklı
e256fbe052 refactor: use promise.all 2025-03-28 12:25:40 -04:00
Barış Soner Uşaklı
2bd5293a3a fix: spread fail, @julianlam
add ap check
2025-03-28 12:25:40 -04:00
Julian Lam
700f91c44a feat: remote user to category migration should also migrate local user follows into category watches 2025-03-28 12:25:40 -04:00
Julian Lam
ca934de55a fix: filter out non-asserted targets when sending ap messages, diff. getter method when passed-in ID is a remote category 2025-03-28 12:25:40 -04:00
Julian Lam
c6d7fcdeaf fix: tag whitelist check socket call for remote categories 2025-03-28 12:25:40 -04:00
Julian Lam
17a107d1d5 feat: allowing manual group assertion via category search input 2025-03-28 12:25:40 -04:00
Julian Lam
452eaff723 fix: migrate topics as system user instead of uid 0 2025-03-28 12:25:40 -04:00
Julian Lam
0387be7782 send ap follow/undo-follow if remote category watch state changes 2025-03-28 12:25:40 -04:00
Julian Lam
1686fb2c63 feat: remote group actors migrated to categories if they were previous asserted as remote users 2025-03-28 12:25:40 -04:00
Julian Lam
502de25136 fix: do not send out ap (undo:)follow if local user or category is (not)already following 2025-03-28 12:25:40 -04:00
Julian Lam
d5c27043ac test: #13255, reply to topic in remote category addresses remote category 2025-03-28 12:25:40 -04:00
Julian Lam
38b82acfbc feat: #13255 new topics in remote category addresses remote category, tests, fixes to tests 2025-03-28 12:25:40 -04:00
Julian Lam
77bd92d55e fix: allow category controller to respond also by remote category id 2025-03-28 12:25:40 -04:00
Julian Lam
936ea55516 feat: #13255, deliver asserted topics to remote category followers 2025-03-28 12:25:40 -04:00
Julian Lam
0ceb1a6965 fix: #13255, update category search logic to allow for remote categories 2025-03-28 12:25:40 -04:00
Julian Lam
5c94ec4d14 feat: #13255, add category name and handle to category search zset 2025-03-28 12:25:40 -04:00
Julian Lam
4b19c18d51 refactor: categories.sortTidsBySet to not take cid, retrieve from tids themselves
re: ##13255, this fixes the issue with topics outside of cid -1 in /world being sorted incorrectly
2025-03-28 12:25:40 -04:00
Julian Lam
b60c28c3fa test: remote user pruning tests 2025-03-28 12:25:40 -04:00
Julian Lam
131a8c948b feat: integrate remote category pruning into actor pruning logic 2025-03-28 12:25:40 -04:00
Julian Lam
124c493000 feat: migration of group-as-user to group-as-category, remote category purging, more tests 2025-03-28 12:25:40 -04:00
Julian Lam
d97d150939 fix: delete shares zset on account deletion 2025-03-28 12:25:40 -04:00
Julian Lam
bbd638c5f6 test: introduce overrides into person and group mocks 2025-03-28 12:25:40 -04:00
Julian Lam
345c600a96 test: have ap helper mocks for person and group auto-save to ap cache 2025-03-28 12:25:40 -04:00
Julian Lam
9614ef17ae test: add failing tests for actor/group assertion via wrong method, remote user to category migration 2025-03-28 12:25:40 -04:00
Julian Lam
3fbb805721 feat: asserted topics and posts to remote categories will notify and add to unread based on remote category watch state 2025-03-28 12:25:40 -04:00
Julian Lam
9de9e4e9d8 test: add tests for topics slotting into remote categories if addressed 2025-03-28 12:25:40 -04:00
Julian Lam
83fd49fe0d test: group actor assertion tests 2025-03-28 12:25:40 -04:00
Julian Lam
c607e8928e refactor: allow topics to be asserted directly into a remote category, or -1 otherwise 2025-03-28 12:25:40 -04:00
Julian Lam
9417e491bb feat: also include category in to field when mocking post for federation 2025-03-28 12:25:40 -04:00
Julian Lam
aab3a62b25 refactor: ability to browse to remote categories, group actor assertion logic, etc. -- no logic to assign topics to remote categories yet 2025-03-28 12:25:40 -04:00
Julian Lam
48ba372cc5 revert: use of vanity domains, needs rethinking. Originally added in 709a02d97a 2025-03-28 12:25:40 -04:00
32 changed files with 1439 additions and 133 deletions

View File

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

View File

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

View File

@@ -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,108 @@ 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);
// const uidsThatExist = ids.filter((uid, idx) => exists[idx]);
// const uidsThatDontExist = ids.filter((uid, idx) => !exists[idx]);
// 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 +647,46 @@ Actors.prune = async () => {
}
} else {
notDeletedDueToLocalContent += 1;
notDeletedUids.push(uid);
preservedIds.push(uid);
}
}));
deletionCountNonExisting += uidsThatDontExist.length;
await db.sortedSetRemove('usersRemote:lastCrawled', uidsThatDontExist);
// Remote categories
let counts = await categories.getCategoriesFields(cids, ['topic_count']);
counts = counts.map(count => count.topic_count);
await Promise.all(cids.map(async (cid, idx) => {
const topicCount = counts[idx];
if (topicCount === 0) {
try {
await categories.purge(cid, 0);
deletionCount += 1;
} catch (err) {
winston.error(err.stack);
}
} else {
notDeletedDueToLocalContent += 1;
preservedIds.push(cid);
}
}));
deletionCountNonExisting += missing.size;
await db.sortedSetRemove('usersRemote:lastCrawled', Array.from(missing));
// update timestamp in usersRemote:lastCrawled so we don't try to delete users
// with content over and over
const now = Date.now();
await db.sortedSetAdd('usersRemote:lastCrawled', notDeletedUids.map(() => now), notDeletedUids);
await db.sortedSetAdd('usersRemote:lastCrawled', preservedIds.map(() => now), preservedIds);
}, {
batch: 50,
interval: 1000,
});
winston.info(`[actors/prune] ${deletionCount} remote users pruned. ${deletionCountNonExisting} does not exist. ${notDeletedDueToLocalContent} not deleted due to local content`);
activitypub.helpers.log(`[actors/prune] ${deletionCount} remote users pruned. ${deletionCountNonExisting} did not exist. ${notDeletedDueToLocalContent} not deleted due to local content`);
return {
counts: {
deleted: deletionCount,
missing: deletionCountNonExisting,
preserved: notDeletedDueToLocalContent,
},
preserved: new Set(preservedIds),
};
};

View File

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

View File

@@ -129,7 +129,7 @@ Mocks._normalize = async (object) => {
};
};
Mocks.profile = async (actors, hostMap) => {
Mocks.profile = async (actors) => {
// Should only ever be called by activitypub.actors.assert
const profiles = await Promise.all(actors.map(async (actor) => {
if (!actor) {
@@ -137,7 +137,7 @@ Mocks.profile = async (actors, hostMap) => {
}
const uid = actor.id;
let hostname = hostMap.get(uid);
let hostname;
let {
url, preferredUsername, published, icon, image,
name, summary, followers, inbox, endpoints, tag,
@@ -145,12 +145,10 @@ Mocks.profile = async (actors, hostMap) => {
preferredUsername = slugify(preferredUsername || name);
const { followers: followerCount, following: followingCount } = await activitypub.actors.getLocalFollowCounts(uid);
if (!hostname) { // if not available via webfinger, infer from id
try {
({ hostname } = new URL(actor.id));
} catch (e) {
return null;
}
try {
({ hostname } = new URL(actor.id));
} catch (e) {
return null;
}
let picture;
@@ -218,7 +216,7 @@ Mocks.profile = async (actors, hostMap) => {
uploadedpicture: undefined,
'cover:url': !image || typeof image === 'string' ? image : image.url,
'cover:position': '50% 50%',
aboutme: summary,
aboutme: posts.sanitize(summary),
followerCount,
followingCount,
@@ -235,6 +233,73 @@ 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 (icon) {
// picture = typeof icon === 'string' ? icon : icon.url;
// }
const iconBackgrounds = await user.getIconBackgrounds();
let bgColor = Array.prototype.reduce.call(preferredUsername, (cur, next) => cur + next.charCodeAt(), 0);
bgColor = iconBackgrounds[bgColor % iconBackgrounds.length];
// 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: 'fa-comments',
color: '#fff',
bgColor,
backgroundImage: !image || typeof image === 'string' ? image : image.url,
// 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 +557,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 +701,15 @@ Mocks.notes.public = async (post) => {
* audience is exposed as part of 1b12 but is now ignored by Lemmy.
* Remove this and most references to audience in 2026.
*/
let audience = `${nconf.get('url')}/category/${post.category.cid}`; // default
let audience = utils.isNumber(post.category.cid) ? // default
`${nconf.get('url')}/category/${post.category.cid}` : post.category.cid;
if (inReplyTo) {
const chain = await activitypub.notes.getParentChain(post.uid, inReplyTo);
chain.forEach((post) => {
audience = post.audience || audience;
});
}
to.add(audience);
let object = {
'@context': 'https://www.w3.org/ns/activitystreams',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ const plugins = require('../plugins');
const privileges = require('../privileges');
const cache = require('../cache');
const meta = require('../meta');
const utils = require('../utils');
const Categories = module.exports;
@@ -26,9 +27,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) {

View File

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

View File

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

View File

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

View File

@@ -52,7 +52,7 @@ 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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -122,6 +122,7 @@ module.exports = function (User) {
`uid:${uid}:upvote`, `uid:${uid}:downvote`,
`uid:${uid}:flag:pids`,
`uid:${uid}:sessions`,
`uid:${uid}:shares`,
`invitation:uid:${uid}`,
];

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ const nconf = require('nconf');
const db = require('../mocks/databasemock');
const meta = require('../../src/meta');
const install = require('../../src/install');
const categories = require('../../src/categories');
const user = require('../../src/user');
const topics = require('../../src/topics');
@@ -13,7 +14,14 @@ const utils = require('../../src/utils');
const request = require('../../src/request');
const slugify = require('../../src/slugify');
const helpers = require('./helpers');
describe('Actor asserton', () => {
before(async () => {
meta.config.activitypubEnabled = 1;
await install.giveWorldPrivileges();
});
describe('happy path', () => {
let uid;
let actorUri;
@@ -58,9 +66,116 @@ 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('edge cases: loopback handles and uris', () => {
let uid;
const userslug = utils.generateUUID().slice(0, 8);
before(async () => {
@@ -90,6 +205,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.only('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 +759,106 @@ describe('Controllers', () => {
});
});
});
describe('Pruning', () => {
before(async () => {
meta.config.activitypubEnabled = 1;
await install.giveWorldPrivileges();
meta.config.activitypubUserPruneDays = 0; // trigger immediate pruning
});
after(() => {
meta.config.activitypubUserPruneDays = 7;
});
describe('Users', () => {
it('should do nothing if the user is newer than the prune cutoff', async () => {
const { id: uid } = helpers.mocks.person();
await activitypub.actors.assert([uid]);
meta.config.activitypubUserPruneDays = 1;
const result = await activitypub.actors.prune();
assert.strictEqual(result.counts.deleted, 0);
assert.strictEqual(result.counts.preserved, 0);
assert.strictEqual(result.counts.missing, 0);
meta.config.activitypubUserPruneDays = 0;
user.deleteAccount(uid);
});
it('should purge the user if they have no content (posts, likes, etc.)', async () => {
const { id: uid } = helpers.mocks.person();
await activitypub.actors.assert([uid]);
const total = await db.sortedSetCard('usersRemote:lastCrawled');
const result = await activitypub.actors.prune();
assert(result.counts.deleted >= 1);
});
it('should do nothing if the user has some content (e.g. a topic)', async () => {
const { cid } = await categories.create({ name: utils.generateUUID() });
const { id: uid } = helpers.mocks.person();
const { id, note } = helpers.mocks.note({
attributedTo: uid,
cc: [`${nconf.get('url')}/category/${cid}`],
});
const assertion = await activitypub.notes.assert(0, id);
assert(assertion);
const result = await activitypub.actors.prune();
assert.strictEqual(result.counts.deleted, 0);
assert.strictEqual(result.counts.preserved, 1);
assert.strictEqual(result.counts.missing, 0);
});
});
describe('Categories', () => {
it('should do nothing if the category is newer than the prune cutoff', async () => {
const { id: cid } = helpers.mocks.group();
await activitypub.actors.assertGroup([cid]);
meta.config.activitypubUserPruneDays = 1;
const result = await activitypub.actors.prune();
assert.strictEqual(result.counts.deleted, 0);
assert.strictEqual(result.counts.preserved, 0);
assert.strictEqual(result.counts.missing, 0);
meta.config.activitypubUserPruneDays = 0;
await categories.purge(cid, 0);
});
it('should purge the category if it has no topics in it', async () => {
const { id: cid } = helpers.mocks.group();
await activitypub.actors.assertGroup([cid]);
const total = await db.sortedSetCard('usersRemote:lastCrawled');
const result = await activitypub.actors.prune();
assert.strictEqual(result.counts.deleted, 1);
assert.strictEqual(result.counts.preserved, total - 1);
});
it('should do nothing if the category has topics in it', async () => {
const { id: cid } = helpers.mocks.group();
await activitypub.actors.assertGroup([cid]);
const { id } = helpers.mocks.note({
cc: [cid],
});
await activitypub.notes.assert(0, id);
const total = await db.sortedSetCard('usersRemote:lastCrawled');
const result = await activitypub.actors.prune();
assert.strictEqual(result.counts.deleted, 0);
assert.strictEqual(result.counts.preserved, total);
assert(result.preserved.has(cid));
});
});
});

View File

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

View File

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

View File

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