'use strict'; const winston = require('winston'); const nconf = require('nconf'); 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'); const flags = require('../flags'); const api = require('../api'); 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, { id: `${helpers.resolveActor(senderType, id)}#/activity/reject/${encodeURIComponent(object.id)}`, type: 'Reject', object: { type, target, object, }, }).catch(err => winston.error(err.stack)); } inbox.create = async (req) => { const { object, actor } = req.body; 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); } console.log(' 4a', Date.now() - start); // 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]; } console.log(' 4b', Date.now() - start); const asserted = await activitypub.notes.assert(0, object, { cid }); console.log(' 4c', Date.now() - start); if (asserted) { await activitypub.feps.announce(object.id, req.body); // api.activitypub.add(req, { pid: object.id }); } console.log(' 4d', Date.now() - start); }; 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); } } } }; inbox.update = async (req) => { const { actor, object } = req.body; 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]]'); } 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: { 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); } break; } case activitypub._constants.acceptableActorTypes.has(object.type): { await activitypub.actors.assert(object.id, { update: true }); break; } case object.type === 'Tombstone': { const [isNote, isMessage/* , isActor */] = await Promise.all([ posts.exists(object.id), messaging.messageExists(object.id), // 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: { // console.log('actor'); // break; // } } } } }; 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; const { type, id } = await activitypub.helpers.resolveLocalId(object.id); 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}`); 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) => { 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); 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]]'); } let tid; let pid; // 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]; } // 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; } case object.type === 'Update': { req.body = object; await inbox.update(req); break; } 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; } } 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; } ({ 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); } } } }; inbox.follow = async (req) => { 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]]'); } const assertion = await activitypub.actors.assert(actor); if (!assertion) { throw new Error('[[error:activitypub.invalid-id]]'); } 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]]'); } const isFollowed = await inbox.isFollowed(actor, id); if (isFollowed) { // No additional parsing required return; } const now = Date.now(); 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); await user.onFollow(actor, id); activitypub.send('uid', id, actor, { id: `${nconf.get('url')}/${type}/${id}#activity/accept:follow/${handle}/${Date.now()}`, type: 'Accept', object: { id: followId, type: 'Follow', actor, object: object.id, }, }).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); } const watchState = await categories.getWatchState([id], actor); if (watchState[0] !== categories.watchStates.tracking) { await user.setCategoryWatchState(actor, id, categories.watchStates.tracking); } activitypub.send('cid', id, actor, { id: `${nconf.get('url')}/${type}/${id}#activity/accept:follow/${handle}/${Date.now()}`, type: 'Accept', object: { id: followId, type: 'Follow', actor, object: object.id, }, }).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); }; inbox.accept = async (req) => { const { actor, object } = req.body; 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]]'); } 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 ]); } } }; inbox.undo = async (req) => { // todo: "actor" in this case should be the one in object, no? const { actor, object } = req.body; 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]]'); } let { type: localType, id } = await helpers.resolveLocalId(object.object); winston.verbose(`[activitypub/inbox/undo] ${type} ${localType && id ? `${localType} ${id}` : object.object} via ${actor}`); 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.`); break; } await activitypub.notes.announce.remove(id, actor); notifications.rescind(`announce:post:${id}:uid:${actor}`); 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; } } }; 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)) { return reject('Flag', objects, actor); } await Promise.all(objects.map(async (subject, index) => { const { type, id } = await activitypub.helpers.resolveObjects(subject.id); try { await flags.create(activitypub.helpers.mapToLocalType(type), id, actor, content); } catch (e) { reject('Flag', objects[index], actor); } })); }; 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}`), ]); };