2024-01-26 15:10:35 -05:00
|
|
|
'use strict';
|
|
|
|
|
|
2024-04-05 11:37:23 -04:00
|
|
|
const nconf = require('nconf');
|
2024-06-07 16:27:44 -04:00
|
|
|
const winston = require('winston');
|
2024-01-26 15:10:35 -05:00
|
|
|
|
|
|
|
|
const db = require('../database');
|
2024-06-07 16:27:44 -04:00
|
|
|
const meta = require('../meta');
|
|
|
|
|
const batch = require('../batch');
|
2024-03-05 09:56:15 -05:00
|
|
|
const user = require('../user');
|
2024-01-26 15:10:35 -05:00
|
|
|
const utils = require('../utils');
|
2024-03-21 00:25:27 +01:00
|
|
|
const TTLCache = require('../cache/ttl');
|
|
|
|
|
|
2024-06-04 12:31:13 -04:00
|
|
|
const failedWebfingerCache = TTLCache({
|
|
|
|
|
max: 5000,
|
|
|
|
|
ttl: 1000 * 60 * 10, // 10 minutes
|
|
|
|
|
});
|
2024-01-26 15:10:35 -05:00
|
|
|
|
|
|
|
|
const activitypub = module.parent.exports;
|
|
|
|
|
|
|
|
|
|
const Actors = module.exports;
|
|
|
|
|
|
2024-01-26 16:48:16 -05:00
|
|
|
Actors.assert = async (ids, options = {}) => {
|
2024-01-26 15:10:35 -05:00
|
|
|
// Handle single values
|
|
|
|
|
if (!Array.isArray(ids)) {
|
|
|
|
|
ids = [ids];
|
|
|
|
|
}
|
2024-06-07 12:13:28 -04:00
|
|
|
if (!ids.length) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2024-04-15 13:37:00 -04:00
|
|
|
// Existance in failure cache is automatic assertion failure
|
|
|
|
|
if (ids.some(id => failedWebfingerCache.has(id))) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2024-01-26 15:10:35 -05:00
|
|
|
// Filter out uids if passed in
|
2024-04-15 13:37:00 -04:00
|
|
|
ids = ids.filter(id => !utils.isNumber(id));
|
2024-01-26 15:10:35 -05:00
|
|
|
|
2024-03-07 15:39:42 -05:00
|
|
|
// Translate webfinger handles to uris
|
2024-04-05 11:37:23 -04:00
|
|
|
ids = (await Promise.all(ids.map(async (id) => {
|
2024-03-21 00:25:27 +01:00
|
|
|
const originalId = id;
|
2024-05-07 10:11:36 -04:00
|
|
|
if (activitypub.helpers.isWebfinger(id)) {
|
|
|
|
|
const host = id.split('@')[1];
|
2024-04-05 11:37:23 -04:00
|
|
|
if (host === nconf.get('url_parsed').host) { // do not assert loopback ids
|
2024-04-26 11:30:08 -04:00
|
|
|
return 'loopback';
|
2024-04-05 11:37:23 -04:00
|
|
|
}
|
|
|
|
|
|
2024-03-07 15:39:42 -05:00
|
|
|
({ actorUri: id } = await activitypub.helpers.query(id));
|
|
|
|
|
}
|
2024-05-06 23:57:47 +02:00
|
|
|
// ensure the final id is a valid URI
|
|
|
|
|
if (!id || !activitypub.helpers.isUri(id)) {
|
2024-03-21 00:25:27 +01:00
|
|
|
failedWebfingerCache.set(originalId, true);
|
2024-05-06 23:57:47 +02:00
|
|
|
return;
|
2024-03-21 00:25:27 +01:00
|
|
|
}
|
2024-03-07 15:39:42 -05:00
|
|
|
return id;
|
2024-04-15 13:37:00 -04:00
|
|
|
})));
|
|
|
|
|
|
|
|
|
|
// Webfinger failures = assertion failure
|
|
|
|
|
if (!ids.every(Boolean)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2024-03-07 15:39:42 -05:00
|
|
|
|
2024-04-05 11:37:23 -04:00
|
|
|
// Filter out loopback uris
|
2024-04-26 11:30:08 -04:00
|
|
|
ids = ids.filter(uri => uri !== 'loopback' && new URL(uri).host !== nconf.get('url_parsed').host);
|
2024-03-07 15:39:42 -05:00
|
|
|
|
2024-01-26 15:10:35 -05:00
|
|
|
// Filter out existing
|
2024-01-26 16:48:16 -05:00
|
|
|
if (!options.update) {
|
|
|
|
|
const exists = await db.isSortedSetMembers('usersRemote:lastCrawled', ids.map(id => ((typeof id === 'object' && id.hasOwnProperty('id')) ? id.id : id)));
|
|
|
|
|
ids = ids.filter((id, idx) => !exists[idx]);
|
|
|
|
|
}
|
2024-01-26 15:10:35 -05:00
|
|
|
|
|
|
|
|
if (!ids.length) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2024-06-07 11:56:58 -04:00
|
|
|
// winston.verbose(`[activitypub/actors] Asserting ${ids.length} actor(s)`);
|
2024-02-20 11:57:50 -05:00
|
|
|
|
2024-06-07 12:55:52 -04:00
|
|
|
// NOTE: MAKE SURE EVERY DB ADDITION HAS A CORRESPONDING REMOVAL IN ACTORS.REMOVE!
|
|
|
|
|
|
2024-05-24 14:11:06 -04:00
|
|
|
const urlMap = new Map();
|
2024-02-12 14:32:55 -05:00
|
|
|
const followersUrlMap = new Map();
|
2024-02-21 13:43:56 -05:00
|
|
|
const pubKeysMap = new Map();
|
2024-03-07 16:59:40 -05:00
|
|
|
let actors = await Promise.all(ids.map(async (id) => {
|
2024-01-26 15:10:35 -05:00
|
|
|
try {
|
2024-06-07 11:56:58 -04:00
|
|
|
// winston.verbose(`[activitypub/actors] Processing ${id}`);
|
2024-02-05 16:57:17 -05:00
|
|
|
const actor = (typeof id === 'object' && id.hasOwnProperty('id')) ? id : await activitypub.get('uid', 0, id);
|
2024-01-26 15:10:35 -05:00
|
|
|
|
|
|
|
|
// Follow counts
|
2024-01-26 16:24:14 -05:00
|
|
|
try {
|
|
|
|
|
const [followers, following] = await Promise.all([
|
2024-02-05 16:57:17 -05:00
|
|
|
actor.followers ? activitypub.get('uid', 0, actor.followers) : { totalItems: 0 },
|
|
|
|
|
actor.following ? activitypub.get('uid', 0, actor.following) : { totalItems: 0 },
|
2024-01-26 16:24:14 -05:00
|
|
|
]);
|
|
|
|
|
actor.followerCount = followers.totalItems;
|
|
|
|
|
actor.followingCount = following.totalItems;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// no action required
|
2024-06-07 11:56:58 -04:00
|
|
|
// winston.verbose(`[activitypub/actor.assert] Unable to retrieve follower counts for ${actor.id}`);
|
2024-01-26 16:24:14 -05:00
|
|
|
}
|
2024-01-26 15:10:35 -05:00
|
|
|
|
|
|
|
|
// Post count
|
2024-02-26 11:34:03 -05:00
|
|
|
try {
|
|
|
|
|
const outbox = actor.outbox ? await activitypub.get('uid', 0, actor.outbox) : { totalItems: 0 };
|
|
|
|
|
actor.postcount = outbox.totalItems;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// no action required
|
2024-06-07 11:56:58 -04:00
|
|
|
// winston.verbose(`[activitypub/actor.assert] Unable to retrieve post counts for ${actor.id}`);
|
2024-02-26 11:34:03 -05:00
|
|
|
}
|
2024-01-26 15:10:35 -05:00
|
|
|
|
2024-05-24 14:11:06 -04:00
|
|
|
// 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
|
2024-02-12 14:32:55 -05:00
|
|
|
if (actor.hasOwnProperty('followers') && activitypub.helpers.isUri(actor.followers)) {
|
|
|
|
|
followersUrlMap.set(actor.followers, actor.id);
|
|
|
|
|
}
|
|
|
|
|
|
2024-02-21 13:43:56 -05:00
|
|
|
// Public keys
|
|
|
|
|
pubKeysMap.set(actor.id, actor.publicKey);
|
|
|
|
|
|
2024-01-26 15:10:35 -05:00
|
|
|
return actor;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}));
|
2024-03-07 16:59:40 -05:00
|
|
|
actors = actors.filter(Boolean); // remove unresolvable actors
|
2024-01-26 15:10:35 -05:00
|
|
|
|
|
|
|
|
// Build userData object for storage
|
2024-06-07 11:55:21 -04:00
|
|
|
const profiles = (await activitypub.mocks.profile(actors)).filter(Boolean);
|
2024-01-26 15:10:35 -05:00
|
|
|
const now = Date.now();
|
|
|
|
|
|
2024-02-21 13:43:56 -05:00
|
|
|
const bulkSet = profiles.reduce((memo, profile) => {
|
2024-03-07 16:59:40 -05:00
|
|
|
const key = `userRemote:${profile.uid}`;
|
|
|
|
|
memo.push([key, profile], [`${key}:keys`, pubKeysMap.get(profile.uid)]);
|
2024-02-21 13:43:56 -05:00
|
|
|
return memo;
|
|
|
|
|
}, []);
|
2024-05-24 14:11:06 -04:00
|
|
|
if (urlMap.size) {
|
|
|
|
|
bulkSet.push(['remoteUrl:uid', Object.fromEntries(urlMap)]);
|
|
|
|
|
}
|
2024-02-12 14:32:55 -05:00
|
|
|
if (followersUrlMap.size) {
|
|
|
|
|
bulkSet.push(['followersUrl:uid', Object.fromEntries(followersUrlMap)]);
|
|
|
|
|
}
|
|
|
|
|
|
2024-03-05 09:56:15 -05:00
|
|
|
const exists = await db.isSortedSetMembers('usersRemote:lastCrawled', profiles.map(p => p.uid));
|
|
|
|
|
const uidsForCurrent = profiles.map((p, idx) => (exists[idx] ? p.uid : 0));
|
|
|
|
|
const current = await user.getUsersFields(uidsForCurrent, ['username', 'fullname']);
|
2024-03-05 14:24:13 -05:00
|
|
|
const queries = profiles.reduce((memo, profile, idx) => {
|
2024-03-07 16:59:40 -05:00
|
|
|
const { username, fullname } = current[idx];
|
2024-03-05 14:24:13 -05:00
|
|
|
|
2024-03-07 16:59:40 -05:00
|
|
|
if (username !== profile.username) {
|
|
|
|
|
if (uidsForCurrent[idx] !== 0) {
|
|
|
|
|
memo.searchRemove.push(['ap.preferredUsername:sorted', `${username.toLowerCase()}:${profile.uid}`]);
|
|
|
|
|
memo.handleRemove.push(username.toLowerCase());
|
2024-03-05 09:56:15 -05:00
|
|
|
}
|
|
|
|
|
|
2024-03-07 16:59:40 -05:00
|
|
|
memo.searchAdd.push(['ap.preferredUsername:sorted', 0, `${profile.username.toLowerCase()}:${profile.uid}`]);
|
|
|
|
|
memo.handleAdd[profile.username.toLowerCase()] = profile.uid;
|
|
|
|
|
}
|
2024-03-05 14:24:13 -05:00
|
|
|
|
2024-03-13 10:56:00 -04:00
|
|
|
if (profile.fullname && fullname !== profile.fullname) {
|
2024-03-07 16:59:40 -05:00
|
|
|
if (uidsForCurrent[idx] !== 0) {
|
|
|
|
|
memo.searchRemove.push(['ap.name:sorted', `${fullname.toLowerCase()}:${profile.uid}`]);
|
2024-03-05 09:56:15 -05:00
|
|
|
}
|
2024-03-07 16:59:40 -05:00
|
|
|
|
|
|
|
|
memo.searchAdd.push(['ap.name:sorted', 0, `${profile.fullname.toLowerCase()}:${profile.uid}`]);
|
2024-03-05 09:56:15 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return memo;
|
2024-03-05 14:24:13 -05:00
|
|
|
}, { searchRemove: [], searchAdd: [], handleRemove: [], handleAdd: {} });
|
2024-03-05 09:56:15 -05:00
|
|
|
|
2024-01-26 15:10:35 -05:00
|
|
|
await Promise.all([
|
2024-02-12 14:32:55 -05:00
|
|
|
db.setObjectBulk(bulkSet),
|
2024-03-06 14:59:49 -05:00
|
|
|
db.sortedSetAdd('usersRemote:lastCrawled', profiles.map(() => now), profiles.map(p => p.uid)),
|
2024-03-05 14:24:13 -05:00
|
|
|
db.sortedSetRemoveBulk(queries.searchRemove),
|
|
|
|
|
db.sortedSetAddBulk(queries.searchAdd),
|
|
|
|
|
db.deleteObjectFields('handle:uid', queries.handleRemove),
|
|
|
|
|
db.setObject('handle:uid', queries.handleAdd),
|
2024-01-26 15:10:35 -05:00
|
|
|
]);
|
|
|
|
|
|
2024-03-07 16:59:40 -05:00
|
|
|
return actors;
|
2024-01-26 15:10:35 -05:00
|
|
|
};
|
2024-04-16 14:00:01 -04:00
|
|
|
|
|
|
|
|
Actors.getLocalFollowers = async (id) => {
|
|
|
|
|
const response = {
|
|
|
|
|
uids: new Set(),
|
|
|
|
|
cids: new Set(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (!activitypub.helpers.isUri(id)) {
|
|
|
|
|
return response;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return response;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Actors.getLocalFollowersCount = async (id) => {
|
|
|
|
|
if (!activitypub.helpers.isUri(id)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return await db.sortedSetCard(`followersRemote:${id}`);
|
|
|
|
|
};
|
2024-06-07 12:55:52 -04:00
|
|
|
|
|
|
|
|
Actors.remove = async (id) => {
|
2024-06-07 16:27:44 -04:00
|
|
|
/**
|
|
|
|
|
* Remove ActivityPub related metadata pertaining to a remote id
|
|
|
|
|
*
|
|
|
|
|
* Note: don't call this directly! It is called as part of user.deleteAccount
|
|
|
|
|
*/
|
2024-06-07 12:55:52 -04:00
|
|
|
const exists = await db.isSortedSetMember('usersRemote:lastCrawled', id);
|
|
|
|
|
if (!exists) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let { username, fullname, url, followersUrl } = await user.getUserFields(id, ['username', 'fullname', 'url', 'followersUrl']);
|
|
|
|
|
username = username.toLowerCase();
|
|
|
|
|
|
2024-06-10 15:18:32 -04:00
|
|
|
const bulkRemove = [
|
|
|
|
|
['ap.preferredUsername:sorted', `${username}:${id}`],
|
|
|
|
|
];
|
|
|
|
|
if (fullname) {
|
|
|
|
|
bulkRemove.push(['ap.name:sorted', `${fullname.toLowerCase()}:${id}`]);
|
|
|
|
|
}
|
|
|
|
|
|
2024-06-07 12:55:52 -04:00
|
|
|
await Promise.all([
|
2024-06-10 15:18:32 -04:00
|
|
|
db.sortedSetRemoveBulk(bulkRemove),
|
2024-06-07 12:55:52 -04:00
|
|
|
db.deleteObjectField('handle:uid', username),
|
|
|
|
|
db.deleteObjectField('followersUrl:uid', followersUrl),
|
|
|
|
|
db.deleteObjectField('remoteUrl:uid', url),
|
|
|
|
|
db.delete(`userRemote:${id}:keys`),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await Promise.all([
|
|
|
|
|
db.delete(`userRemote:${id}`),
|
|
|
|
|
db.sortedSetRemove('usersRemote:lastCrawled', id),
|
|
|
|
|
]);
|
|
|
|
|
};
|
2024-06-07 16:27:44 -04:00
|
|
|
|
|
|
|
|
Actors.prune = async () => {
|
|
|
|
|
/**
|
|
|
|
|
* Clear out remote user accounts that do not have content on the forum anywhere
|
|
|
|
|
* Re-crawl those that have not been updated recently
|
|
|
|
|
*/
|
2024-06-10 15:18:32 -04:00
|
|
|
winston.info('[actors/prune] Started scheduled pruning of remote user accounts');
|
2024-06-07 16:27:44 -04:00
|
|
|
|
|
|
|
|
const days = parseInt(meta.config.activitypubUserPruneDays, 10);
|
|
|
|
|
const timestamp = Date.now() - (1000 * 60 * 60 * 24 * days);
|
|
|
|
|
const uids = await db.getSortedSetRangeByScore('usersRemote:lastCrawled', 0, -1, 0, timestamp);
|
|
|
|
|
if (!uids.length) {
|
2024-06-10 15:18:32 -04:00
|
|
|
winston.info('[actors/prune] No remote users to prune, all done.');
|
2024-06-07 16:27:44 -04:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2024-06-10 15:18:32 -04:00
|
|
|
winston.info(`[actors/prune] Found ${uids.length} remote users last crawled more than ${days} days ago`);
|
2024-06-07 16:27:44 -04:00
|
|
|
let deletionCount = 0;
|
|
|
|
|
const reassertionSet = new Set();
|
|
|
|
|
|
|
|
|
|
await batch.processArray(uids, async (uids) => {
|
|
|
|
|
const exists = await db.exists(uids.map(uid => `userRemote:${uid}`));
|
|
|
|
|
const counts = await db.sortedSetsCard(uids.map(uid => `uid:${uid}:posts`));
|
|
|
|
|
await Promise.all(uids.map(async (uid, idx) => {
|
|
|
|
|
if (!exists[idx]) {
|
|
|
|
|
// id in zset but not asserted, handle and return early
|
|
|
|
|
await db.sortedSetRemove('usersRemote:lastCrawled', uid);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const count = counts[idx];
|
|
|
|
|
if (count < 1) {
|
|
|
|
|
await user.deleteAccount(uid);
|
|
|
|
|
deletionCount += 1;
|
|
|
|
|
} else {
|
|
|
|
|
reassertionSet.add(uid);
|
|
|
|
|
}
|
|
|
|
|
}));
|
|
|
|
|
}, {
|
|
|
|
|
batch: 50,
|
|
|
|
|
interval: 1000,
|
|
|
|
|
});
|
|
|
|
|
|
2024-06-10 15:18:32 -04:00
|
|
|
winston.info(`[actors/prune] ${deletionCount} remote users pruned, re-asserting ${reassertionSet.size} remote users.`);
|
2024-06-07 16:27:44 -04:00
|
|
|
|
|
|
|
|
await Actors.assert(Array.from(reassertionSet), { update: true });
|
|
|
|
|
};
|