Files
NodeBB/src/activitypub/inbox.js

627 lines
18 KiB
JavaScript
Raw Normal View History

'use strict';
2024-01-16 12:00:50 -05:00
const winston = require('winston');
const nconf = require('nconf');
2024-01-16 12:00:50 -05:00
const db = require('../database');
const privileges = require('../privileges');
const user = require('../user');
const posts = require('../posts');
const topics = require('../topics');
const categories = require('../categories');
const notifications = require('../notifications');
const messaging = require('../messaging');
2024-04-06 19:00:52 +02:00
const flags = require('../flags');
const api = require('../api');
2023-12-13 13:15:03 -05:00
const activitypub = require('.');
const socketHelpers = require('../socket.io/helpers');
const helpers = require('./helpers');
const inbox = module.exports;
function reject(type, object, target, senderType = 'uid', id = 0) {
activitypub.send(senderType, id, target, {
2024-05-03 17:48:09 +02:00
id: `${helpers.resolveActor(senderType, id)}#/activity/reject/${encodeURIComponent(object.id)}`,
type: 'Reject',
object: {
type,
target,
object,
},
2024-06-13 18:36:05 -04:00
}).catch(err => winston.error(err.stack));
}
2024-01-16 12:00:50 -05:00
inbox.create = async (req) => {
const { object, actor } = req.body;
2025-08-27 11:29:43 -04:00
const start = Date.now();
// Alternative logic for non-public objects
const isPublic = [...(object.to || []), ...(object.cc || [])].includes(activitypub._constants.publicAddress);
if (!isPublic) {
return await activitypub.notes.assertPrivate(object);
}
2025-08-27 11:29:43 -04:00
console.log(' 4a', Date.now() - start);
2025-05-13 13:59:34 -04:00
// Category sync, remove when cross-posting available
const { cids } = await activitypub.actors.getLocalFollowers(actor);
let cid = null;
if (cids.size > 0) {
cid = Array.from(cids)[0];
}
2025-08-27 11:29:43 -04:00
console.log(' 4b', Date.now() - start);
const asserted = await activitypub.notes.assert(0, object, { cid });
2025-08-27 11:29:43 -04:00
console.log(' 4c', Date.now() - start);
if (asserted) {
await activitypub.feps.announce(object.id, req.body);
// api.activitypub.add(req, { pid: object.id });
}
2025-08-27 11:29:43 -04:00
console.log(' 4d', Date.now() - start);
2024-01-16 13:55:58 -05:00
};
inbox.add = async (req) => {
const { actor, object, target } = req.body;
// Only react on Adds pertaining to local posts
const { type, id: pid } = await activitypub.helpers.resolveLocalId(object);
if (type === 'post') {
// Check context of OP
const tid = await posts.getPostField(pid, 'tid');
const context = await topics.getTopicField(tid, 'context');
if (context) {
const { attributedTo } = await activitypub.get('uid', 0, context);
if (context === target && attributedTo === actor) {
activitypub.helpers.log(`[activitypub/inbox/add] Associating pid ${pid} with new context ${target}`);
await posts.setPostField(pid, 'context', target);
}
}
}
};
2024-01-16 13:55:58 -05:00
inbox.update = async (req) => {
const { actor, object } = req.body;
2024-10-17 11:19:22 -04:00
const isPublic = [...(object.to || []), ...(object.cc || [])].includes(activitypub._constants.publicAddress);
// Origin checking
const actorHostname = new URL(actor).hostname;
const objectHostname = new URL(object.id).hostname;
if (actorHostname !== objectHostname) {
throw new Error('[[error:activitypub.origin-mismatch]]');
}
2024-01-16 13:55:58 -05:00
switch (true) {
case activitypub._constants.acceptedPostTypes.includes(object.type): {
const [isNote, isMessage] = await Promise.all([
posts.exists(object.id),
messaging.messageExists(object.id),
]);
try {
switch (true) {
case isNote: {
const cid = await posts.getCidByPid(object.id);
const allowed = await privileges.categories.can('posts:edit', cid, activitypub._constants.uid);
if (!allowed) {
throw new Error('[[error:no-privileges]]');
}
const postData = await activitypub.mocks.post(object);
postData.tags = await activitypub.notes._normalizeTags(postData._activitypub.tag, postData.cid);
await posts.edit(postData);
const isDeleted = await posts.getPostField(object.id, 'deleted');
if (isDeleted) {
await api.posts.restore({ uid: actor }, { pid: object.id });
}
break;
}
case isMessage: {
2024-10-17 11:16:25 -04:00
const { roomId, deleted } = await messaging.getMessageFields(object.id, ['roomId', 'deleted']);
await messaging.editMessage(actor, object.id, roomId, object.content);
if (deleted) {
await api.chats.restoreMessage({ uid: actor }, { mid: object.id });
}
break;
}
default: {
if (!isPublic) {
return await activitypub.notes.assertPrivate(object);
}
const { cids } = await activitypub.actors.getLocalFollowers(actor);
let cid = null;
if (cids.size > 0) {
cid = Array.from(cids)[0];
}
const asserted = await activitypub.notes.assert(0, object.id, { cid });
if (asserted) {
activitypub.feps.announce(object.id, req.body);
}
break;
}
}
} catch (e) {
reject('Update', object, actor);
2024-02-28 13:14:15 -05:00
}
2024-01-26 16:48:16 -05:00
break;
}
case activitypub._constants.acceptableActorTypes.has(object.type): {
await activitypub.actors.assert(object.id, { update: true });
2024-01-26 16:48:16 -05:00
break;
}
case object.type === 'Tombstone': {
const [isNote, isMessage/* , isActor */] = await Promise.all([
posts.exists(object.id),
messaging.messageExists(object.id),
2024-05-08 10:41:11 -04:00
// db.isSortedSetMember('usersRemote:lastCrawled', object.id),
]);
switch (true) {
case isNote: {
await api.posts.delete({ uid: actor }, { pid: object.id });
break;
}
case isMessage: {
await api.chats.deleteMessage({ uid: actor }, { mid: object.id });
break;
}
// case isActor: {
2024-05-08 10:41:11 -04:00
// console.log('actor');
// break;
// }
}
}
2024-01-16 12:00:50 -05:00
}
};
inbox.delete = async (req) => {
const { actor, object } = req.body;
if (typeof object !== 'string') {
const { id } = object;
if (!id) {
throw new Error('[[error:invalid-pid]]');
}
}
const pid = object.id || object;
let type = object.type || undefined;
// Deletes don't have their objects resolved automatically
let method = 'purge';
try {
if (!type) {
({ type } = await activitypub.get('uid', 0, pid));
}
if (type === 'Tombstone') {
method = 'delete';
}
} catch (e) {
// probably 410/404
}
// Deletions must be made by an actor of the same origin
const actorHostname = new URL(actor).hostname;
const objectHostname = new URL(pid).hostname;
if (actorHostname !== objectHostname) {
return reject('Delete', object, actor);
}
const [isNote/* , isActor */] = await Promise.all([
posts.exists(pid),
// db.isSortedSetMember('usersRemote:lastCrawled', object.id),
]);
switch (true) {
case isNote: {
const cid = await posts.getCidByPid(pid);
const allowed = await privileges.categories.can('posts:edit', cid, activitypub._constants.uid);
if (!allowed) {
return reject('Delete', object, actor);
}
const uid = await posts.getPostField(pid, 'uid');
await activitypub.feps.announce(pid, req.body);
await api.posts[method]({ uid }, { pid });
break;
}
// case isActor: {
// console.log('actor');
// break;
// }
default: {
activitypub.helpers.log(`[activitypub/inbox.delete] Object (${pid}) does not exist locally. Doing nothing.`);
break;
}
}
};
inbox.like = async (req) => {
const { actor, object } = req.body;
2024-04-10 00:20:16 +02:00
const { type, id } = await activitypub.helpers.resolveLocalId(object.id);
2024-03-07 13:46:20 -05:00
2024-02-06 15:20:30 -05:00
if (type !== 'post' || !(await posts.exists(id))) {
return reject('Like', object, actor);
}
const allowed = await privileges.posts.can('posts:upvote', id, activitypub._constants.uid);
if (!allowed) {
activitypub.helpers.log(`[activitypub/inbox.like] ${id} not allowed to be upvoted.`);
return reject('Like', object, actor);
}
activitypub.helpers.log(`[activitypub/inbox/like] id ${id} via ${actor}`);
2024-02-07 12:50:26 -05:00
const result = await posts.upvote(id, actor);
await activitypub.feps.announce(object.id, req.body);
socketHelpers.upvote(result, 'notifications:upvoted-your-post-in');
};
inbox.announce = async (req) => {
2025-05-05 16:50:44 -04:00
let { actor, object, published, to, cc } = req.body;
activitypub.helpers.log(`[activitypub/inbox/announce] Parsing Announce(${object.type}) from ${actor}`);
let timestamp = new Date(published);
2024-02-14 10:23:06 -05:00
timestamp = timestamp.toString() !== 'Invalid Date' ? timestamp.getTime() : Date.now();
const assertion = await activitypub.actors.assert(actor);
if (!assertion) {
throw new Error('[[error:activitypub.invalid-id]]');
}
2024-02-07 12:28:27 -05:00
let tid;
let pid;
2025-05-13 13:59:34 -04:00
// Category sync, remove when cross-posting available
2024-04-17 12:55:15 -04:00
const { cids } = await activitypub.actors.getLocalFollowers(actor);
let cid = null;
if (cids.size > 0) {
cid = Array.from(cids)[0];
}
2025-05-13 13:59:34 -04:00
// 1b12 announce
const categoryActor = await categories.exists(actor);
if (categoryActor) {
cid = actor;
}
switch(true) {
case object.type === 'Like': {
const id = object.object.id || object.object;
const { id: localId } = await activitypub.helpers.resolveLocalId(id);
const exists = await posts.exists(localId || id);
if (exists) {
try {
const result = await posts.upvote(localId || id, object.actor);
if (localId) {
socketHelpers.upvote(result, 'notifications:upvoted-your-post-in');
}
} catch (e) {
// vote denied due to local limitations (frequency, privilege, etc.); noop.
}
}
break;
2024-04-17 12:55:15 -04:00
}
case object.type === 'Update': {
req.body = object;
await inbox.update(req);
break;
}
2025-05-05 16:50:44 -04:00
case object.type === 'Create': {
object = object.object;
// falls through
}
// Announce(Object)
case activitypub._constants.acceptedPostTypes.includes(object.type): {
if (String(object.id).startsWith(nconf.get('url'))) { // Local object
const { type, id } = await activitypub.helpers.resolveLocalId(object.id);
if (type !== 'post' || !(await posts.exists(id))) {
reject('Announce', object, actor);
return;
}
pid = id;
tid = await posts.getPostField(id, 'tid');
socketHelpers.sendNotificationToPostOwner(pid, actor, 'announce', 'notifications:activitypub.announce');
} else { // Remote object
// Follower check
if (!cid) {
const { followers } = await activitypub.actors.getLocalFollowCounts(actor);
if (!followers) {
winston.verbose(`[activitypub/inbox.announce] Rejecting ${object.id} via ${actor} due to no followers`);
reject('Announce', object, actor);
return;
}
}
2024-02-12 15:25:49 -05:00
2025-05-13 13:59:34 -04:00
pid = object.id;
pid = await activitypub.resolveId(0, pid); // in case wrong id is passed-in; unlikely, but still.
if (!pid) {
return;
}
const assertion = await activitypub.notes.assert(0, pid, { cid, skipChecks: true });
if (!assertion) {
return;
}
2024-02-07 12:50:26 -05:00
({ tid } = assertion);
await activitypub.notes.updateLocalRecipients(pid, { to, cc });
await activitypub.notes.syncUserInboxes(tid);
}
if (!cid) { // Topic events from actors followed by users only
await activitypub.notes.announce.add(pid, actor, timestamp);
}
}
2024-04-17 12:55:15 -04:00
}
};
inbox.follow = async (req) => {
2024-04-10 00:30:46 +02:00
const { actor, object, id: followId } = req.body;
// Sanity checks
const { type, id } = await helpers.resolveLocalId(object.id);
if (!['category', 'user'].includes(type)) {
throw new Error('[[error:activitypub.invalid-id]]');
}
2024-03-07 13:46:20 -05:00
const assertion = await activitypub.actors.assert(actor);
if (!assertion) {
throw new Error('[[error:activitypub.invalid-id]]');
}
2024-04-15 09:48:58 -04:00
const handle = await user.getUserField(actor, 'username');
if (type === 'user') {
const [exists, allowed] = await Promise.all([
user.exists(id),
privileges.global.can('view:users', activitypub._constants.uid),
]);
if (!exists || !allowed) {
throw new Error('[[error:invalid-uid]]');
}
2024-03-07 13:46:20 -05:00
const isFollowed = await inbox.isFollowed(actor, id);
if (isFollowed) {
// No additional parsing required
return;
}
const now = Date.now();
2024-03-07 13:46:20 -05:00
await db.sortedSetAdd(`followersRemote:${id}`, now, actor);
await db.sortedSetAdd(`followingRemote:${actor}`, now, id); // for following backreference (actor pruning)
const followerRemoteCount = await db.sortedSetCard(`followersRemote:${id}`);
await user.setUserField(id, 'followerRemoteCount', followerRemoteCount);
2024-06-13 18:36:05 -04:00
await user.onFollow(actor, id);
2024-03-07 13:46:20 -05:00
activitypub.send('uid', id, actor, {
id: `${nconf.get('url')}/${type}/${id}#activity/accept:follow/${handle}/${Date.now()}`,
type: 'Accept',
object: {
2024-04-10 00:30:46 +02:00
id: followId,
type: 'Follow',
2024-03-07 13:46:20 -05:00
actor,
2024-04-10 00:30:46 +02:00
object: object.id,
},
2024-06-13 18:36:05 -04:00
}).catch(err => winston.error(err.stack));
} else if (type === 'category') {
const [exists, allowed] = await Promise.all([
categories.exists(id),
privileges.categories.can('read', id, activitypub._constants.uid),
]);
if (!exists) {
throw new Error('[[error:invalid-cid]]');
}
if (!allowed) {
return reject('Follow', object, actor);
}
2024-03-07 13:46:20 -05:00
const watchState = await categories.getWatchState([id], actor);
if (watchState[0] !== categories.watchStates.tracking) {
2024-03-07 13:46:20 -05:00
await user.setCategoryWatchState(actor, id, categories.watchStates.tracking);
}
2024-03-07 13:46:20 -05:00
activitypub.send('cid', id, actor, {
id: `${nconf.get('url')}/${type}/${id}#activity/accept:follow/${handle}/${Date.now()}`,
type: 'Accept',
object: {
2024-04-10 00:30:46 +02:00
id: followId,
type: 'Follow',
2024-03-07 13:46:20 -05:00
actor,
object: object.id,
},
2024-06-13 18:36:05 -04:00
}).catch(err => winston.error(err.stack));
}
};
inbox.isFollowed = async (actorId, uid) => {
if (actorId.indexOf('@') === -1 || parseInt(uid, 10) <= 0) {
return false;
}
return await db.isSortedSetMember(`followersRemote:${uid}`, actorId);
};
2023-12-13 13:15:03 -05:00
inbox.accept = async (req) => {
const { actor, object } = req.body;
2023-12-13 13:15:03 -05:00
const { type } = object;
const { type: localType, id } = await helpers.resolveLocalId(object.actor);
if (!['user', 'category'].includes(localType)) {
throw new Error('[[error:invalid-data]]');
}
const assertion = await activitypub.actors.assert(actor);
if (!assertion) {
throw new Error('[[error:activitypub.invalid-id]]');
}
2023-12-13 13:15:03 -05:00
if (type === 'Follow') {
if (localType === 'user') {
if (!await db.isSortedSetMember(`followRequests:uid.${id}`, actor)) {
if (await db.isSortedSetMember(`followingRemote:${id}`, actor)) return; // already following
return reject('Accept', req.body, actor); // not following, not requested, so reject to hopefully stop retries
}
const timestamp = await db.sortedSetScore(`followRequests:uid.${id}`, actor);
await Promise.all([
db.sortedSetRemove(`followRequests:uid.${id}`, actor),
db.sortedSetAdd(`followingRemote:${id}`, timestamp, actor),
db.sortedSetAdd(`followersRemote:${actor}`, timestamp, id), // for followers backreference and notes assertion checking
]);
const followingRemoteCount = await db.sortedSetCard(`followingRemote:${id}`);
await user.setUserField(id, 'followingRemoteCount', followingRemoteCount);
} else if (localType === 'category') {
if (!await db.isSortedSetMember(`followRequests:cid.${id}`, actor)) {
if (await db.isSortedSetMember(`cid:${id}:following`, actor)) return; // already following
return reject('Accept', req.body, actor); // not following, not requested, so reject to hopefully stop retries
}
const timestamp = await db.sortedSetScore(`followRequests:cid.${id}`, actor);
await Promise.all([
db.sortedSetRemove(`followRequests:cid.${id}`, actor),
db.sortedSetAdd(`cid:${id}:following`, timestamp, actor),
db.sortedSetAdd(`followersRemote:${actor}`, timestamp, `cid|${id}`), // for notes assertion checking
]);
}
2023-12-13 13:15:03 -05:00
}
};
inbox.undo = async (req) => {
// todo: "actor" in this case should be the one in object, no?
const { actor, object } = req.body;
2023-12-13 13:15:03 -05:00
const { type } = object;
if (actor !== object.actor) {
throw new Error('[[error:activitypub.actor-mismatch]]');
}
const assertion = await activitypub.actors.assert(actor);
if (!assertion) {
throw new Error('[[error:activitypub.invalid-id]]');
}
2023-12-13 13:15:03 -05:00
let { type: localType, id } = await helpers.resolveLocalId(object.object);
winston.verbose(`[activitypub/inbox/undo] ${type} ${localType && id ? `${localType} ${id}` : object.object} via ${actor}`);
2024-02-07 12:50:26 -05:00
switch (type) {
case 'Follow': {
switch (localType) {
case 'user': {
const exists = await user.exists(id);
if (!exists) {
throw new Error('[[error:invalid-uid]]');
}
await Promise.all([
db.sortedSetRemove(`followersRemote:${id}`, actor),
db.sortedSetRemove(`followingRemote:${actor}`, id),
]);
const followerRemoteCount = await db.sortedSetCard(`followerRemote:${id}`);
await user.setUserField(id, 'followerRemoteCount', followerRemoteCount);
notifications.rescind(`follow:${id}:uid:${actor}`);
break;
}
case 'category': {
const exists = await categories.exists(id);
if (!exists) {
throw new Error('[[error:invalid-cid]]');
}
await user.setCategoryWatchState(actor, id, categories.watchStates.notwatching);
break;
}
}
break;
}
case 'Like': {
const exists = await posts.exists(id);
if (localType !== 'post' || !exists) {
reject('Like', object, actor);
break;
}
const allowed = await privileges.posts.can('posts:upvote', id, activitypub._constants.uid);
if (!allowed) {
winston.verbose(`[activitypub/inbox.like] ${id} not allowed to be upvoted.`);
reject('Like', object, actor);
break;
}
await posts.unvote(id, actor);
activitypub.feps.announce(object.object, req.body);
notifications.rescind(`upvote:post:${id}:uid:${actor}`);
break;
}
case 'Announce': {
id = id || object.object; // remote announces
const exists = await posts.exists(id);
if (!exists) {
activitypub.helpers.log(`[activitypub/inbox/undo] Attempted to undo announce of ${id} but couldn't find it, so doing nothing.`);
2024-06-07 12:13:28 -04:00
break;
}
await activitypub.notes.announce.remove(id, actor);
notifications.rescind(`announce:post:${id}:uid:${actor}`);
2024-04-14 02:02:17 +02:00
break;
}
case 'Flag': {
if (!Array.isArray(object.object)) {
object.object = [object.object];
}
await Promise.all(object.object.map(async (subject) => {
const { type, id } = await activitypub.helpers.resolveLocalId(subject.id);
try {
await flags.rescindReport(type, id, actor);
} catch (e) {
reject('Undo', { type: 'Flag', object: [subject] }, actor);
}
}));
break;
}
2023-12-13 13:15:03 -05:00
}
};
2024-04-06 19:00:52 +02:00
inbox.flag = async (req) => {
const { actor, object, content } = req.body;
const objects = Array.isArray(object) ? object : [object];
// Check if the actor is valid
if (!await activitypub.actors.assert(actor)) {
2024-04-06 19:10:49 +02:00
return reject('Flag', objects, actor);
2024-04-06 19:00:52 +02:00
}
2024-04-10 00:06:24 +02:00
await Promise.all(objects.map(async (subject, index) => {
const { type, id } = await activitypub.helpers.resolveObjects(subject.id);
2024-04-06 19:00:52 +02:00
try {
await flags.create(activitypub.helpers.mapToLocalType(type), id, actor, content);
2024-04-06 19:00:52 +02:00
} catch (e) {
reject('Flag', objects[index], actor);
}
}));
};
2024-05-06 22:49:31 +02:00
inbox.reject = async (req) => {
const { actor, object } = req.body;
const { type, id } = object;
const { hostname } = new URL(actor);
const queueId = `${type}:${id}:${hostname}`;
// stop retrying rejected requests
await Promise.all([
db.sortedSetRemove('ap:retry:queue', queueId),
db.delete(`ap:retry:queue:${queueId}`),
]);
2024-05-06 22:49:31 +02:00
};