mirror of
https://github.com/NodeBB/NodeBB.git
synced 2025-12-25 09:50:35 +01:00
Compare commits
43 Commits
v4.7.2
...
v4.3.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b8839ec66 | ||
|
|
6089b8ff04 | ||
|
|
455461d37a | ||
|
|
4e5a4fc403 | ||
|
|
2ead2972f8 | ||
|
|
96c5063468 | ||
|
|
67476ec6e1 | ||
|
|
ddb57ac41a | ||
|
|
fe16ee9c3e | ||
|
|
4ffb417c93 | ||
|
|
7872b29b18 | ||
|
|
477aacec4b | ||
|
|
f60b1fdd51 | ||
|
|
9bc4fb3fc3 | ||
|
|
4672d5aff8 | ||
|
|
c622cccb40 | ||
|
|
fad0d2fbb4 | ||
|
|
24a144832c | ||
|
|
276558fb55 | ||
|
|
3b40b03c6d | ||
|
|
f0ccca2a55 | ||
|
|
39bcc32f7a | ||
|
|
4b95db1da8 | ||
|
|
2733a7a612 | ||
|
|
aa1ff61c06 | ||
|
|
8da0413810 | ||
|
|
6f34744130 | ||
|
|
eb335c98c2 | ||
|
|
6e12b7468d | ||
|
|
c08fdfa04e | ||
|
|
f9ffbb27cb | ||
|
|
b1f5fd046f | ||
|
|
0e88379189 | ||
|
|
2ad8c1dbfe | ||
|
|
884ed1b973 | ||
|
|
4c6b1ef4ae | ||
|
|
173d9133b0 | ||
|
|
ae36ab5727 | ||
|
|
286df3c66b | ||
|
|
f487115967 | ||
|
|
a422268b09 | ||
|
|
5e266e185c | ||
|
|
0c5bf395ed |
@@ -2,7 +2,7 @@
|
||||
"name": "nodebb",
|
||||
"license": "GPL-3.0",
|
||||
"description": "NodeBB Forum",
|
||||
"version": "4.2.0",
|
||||
"version": "4.3.0-alpha.2",
|
||||
"homepage": "https://www.nodebb.org",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
@@ -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}`));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,8 @@ 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 ||
|
||||
|
||||
@@ -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]]');
|
||||
|
||||
@@ -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}`,
|
||||
];
|
||||
|
||||
|
||||
@@ -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,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));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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