From ce7dd8e1cdc0c7be82554e2a00f0d28ff20841cc Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 21 Jan 2023 01:19:19 -0700 Subject: [PATCH] Handle Undo --- core/activitypub/activity.js | 5 - core/activitypub/actor.js | 41 ++++--- core/activitypub/collection.js | 37 +++++-- core/database.js | 14 ++- core/servers/content/web.js | 4 + .../content/web_handlers/activitypub.js | 102 +++++++++++++----- 6 files changed, 129 insertions(+), 74 deletions(-) diff --git a/core/activitypub/activity.js b/core/activitypub/activity.js index bd06f337..ee06084b 100644 --- a/core/activitypub/activity.js +++ b/core/activitypub/activity.js @@ -24,11 +24,6 @@ module.exports = class Activity extends ActivityPubObject { return WellKnownActivityTypes; } - static fromJsonString(json) { - const parsed = JSON.parse(json); - return new Activity(parsed); - } - // https://www.w3.org/TR/activitypub/#accept-activity-inbox static makeAccept(webServer, localActor, followRequest, id = null) { id = id || Activity._makeFullId(webServer, 'accept'); diff --git a/core/activitypub/actor.js b/core/activitypub/actor.js index 721a5e4d..a97c7130 100644 --- a/core/activitypub/actor.js +++ b/core/activitypub/actor.js @@ -15,45 +15,40 @@ const Log = require('../logger').log; const { queryWebFinger } = require('../webfinger'); const EnigAssert = require('../enigma_assert'); const ActivityPubSettings = require('./settings'); +const ActivityPubObject = require('./object'); // deps const _ = require('lodash'); -const isString = require('lodash/isString'); const mimeTypes = require('mime-types'); const { getJson } = require('../http_util.js'); // https://www.w3.org/TR/activitypub/#actor-objects -module.exports = class Actor { +module.exports = class Actor extends ActivityPubObject { constructor(obj) { - this['@context'] = [ActivityStreamsContext]; - - if (obj) { - Object.assign(this, obj); - } else { - this.id = ''; - this.type = ''; - this.inbox = ''; - this.outbox = ''; - this.following = ''; - this.followers = ''; - } + super(obj); } isValid() { + if (!super.isValid()) { + return false; + } + if ( - !Array.isArray(this['@context']) || - this['@context'][0] !== ActivityStreamsContext + !['Person', 'Group', 'Organization', 'Service', 'Application'].includes( + this.type + ) ) { return false; } - if (!isString(this.type) || this.type.length < 1) { - return false; - } - - const linksValid = ['inbox', 'outbox', 'following', 'followers'].every(p => { - return isValidLink(this[p]); + const linksValid = ['inbox', 'outbox', 'following', 'followers'].every(l => { + // must be valid if set + if (this[l] && !isValidLink(this[l])) { + return false; + } + return true; }); + if (!linksValid) { return false; } @@ -136,11 +131,11 @@ module.exports = class Actor { } static fromRemoteUrl(url, cb) { + // :TODO: cache first const headers = { Accept: 'application/activity+json', }; - // :TODO: use getJson() getJson(url, { headers }, (err, actor) => { if (err) { return cb(err); diff --git a/core/activitypub/collection.js b/core/activitypub/collection.js index 1aa9d617..3a221a1d 100644 --- a/core/activitypub/collection.js +++ b/core/activitypub/collection.js @@ -35,7 +35,13 @@ module.exports = class Collection extends ActivityPubObject { } static addFollower(owningUser, followingActor, cb) { - return Collection.addToCollection('followers', owningUser, followingActor, cb); + return Collection.addToCollection( + 'followers', + owningUser, + followingActor.id, + followingActor, + cb + ); } static getOrdered(name, owningUser, includePrivate, page, mapper, webServer, cb) { @@ -45,7 +51,7 @@ module.exports = class Collection extends ActivityPubObject { if (!page) { return apDb.get( `SELECT COUNT(id) AS count - FROM collection_entry + FROM collection WHERE name = ?;`, [name], (err, row) => { @@ -77,8 +83,8 @@ module.exports = class Collection extends ActivityPubObject { // :TODO: actual paging... apDb.all( - `SELECT entry_json - FROM collection_entry + `SELECT obj_json + FROM collection WHERE user_id = ? AND name = ? ORDER BY timestamp;`, [owningUser.userId, name], @@ -105,15 +111,15 @@ module.exports = class Collection extends ActivityPubObject { ); } - static addToCollection(name, owningUser, entry, cb) { - if (!isString(entry)) { - entry = JSON.stringify(entry); + static addToCollection(name, owningUser, objectId, obj, cb) { + if (!isString(obj)) { + obj = JSON.stringify(obj); } apDb.run( - `INSERT OR IGNORE INTO collection_entry (name, timestamp, user_id, entry_json) - VALUES (?, ?, ?, ?);`, - [name, getISOTimestampString(), owningUser.userId, entry], + `INSERT OR IGNORE INTO collection (name, timestamp, user_id, obj_id, obj_json) + VALUES (?, ?, ?, ?, ?);`, + [name, getISOTimestampString(), owningUser.userId, objectId, obj], function res(err) { // non-arrow for 'this' scope if (err) { @@ -123,4 +129,15 @@ module.exports = class Collection extends ActivityPubObject { } ); } + + static remoteFromCollectionById(name, owningUser, objectId, cb) { + apDb.run( + `DELETE FROM collection + WHERE user_id = ? AND name = ? AND obj_id = ?;`, + [owningUser.userId, name, objectId], + err => { + return cb(err); + } + ); + } }; diff --git a/core/database.js b/core/database.js index 2b08daba..f27eed51 100644 --- a/core/database.js +++ b/core/database.js @@ -532,23 +532,21 @@ dbs.message.run( ); dbs.activitypub.run( - `CREATE TABLE IF NOT EXISTS collection_entry ( + `CREATE TABLE IF NOT EXISTS collection ( id INTEGER PRIMARY KEY, -- Auto-generated key name VARCHAR NOT NULL, -- examples: followers, follows, ... timestamp DATETIME NOT NULL, -- Timestamp in which this entry was created user_id INTEGER NOT NULL, -- Local, owning user ID - entry_json VARCHAR NOT NULL -- Varies by collection + obj_id VARCHAR NOT NULL, -- Object ID from obj_json.id + obj_json VARCHAR NOT NULL, -- Object varies by collection (obj_json.type) + + UNIQUE(name, user_id, obj_id) );` ); dbs.activitypub.run( `CREATE INDEX IF NOT EXISTS collection_entry_by_user_index0 - ON collection_entry (name, user_id);` - ); - - dbs.activitypub.run( - `CREATE UNIQUE INDEX IF NOT EXISTS collection_entry_unique_index0 - ON collection_entry (name, user_id, json_extract(entry_json, '$.id'));` + ON collection (name, user_id);` ); return cb(null); diff --git a/core/servers/content/web.js b/core/servers/content/web.js index 5524635c..d10a9394 100644 --- a/core/servers/content/web.js +++ b/core/servers/content/web.js @@ -346,6 +346,10 @@ exports.getModule = class WebServerModule extends ServerModule { ); } + notImplemented(resp) { + return this.respondWithError(resp, 501, 'Not implemented.', 'Not Implemented'); + } + tryRouteIndex(req, resp, cb) { const tryFiles = Config().contentServers.web.tryFiles || [ 'index.html', diff --git a/core/servers/content/web_handlers/activitypub.js b/core/servers/content/web_handlers/activitypub.js index abca78f2..a6d773c9 100644 --- a/core/servers/content/web_handlers/activitypub.js +++ b/core/servers/content/web_handlers/activitypub.js @@ -10,6 +10,7 @@ const Activity = require('../../../activitypub/activity'); const ActivityPubSettings = require('../../../activitypub/settings'); const Actor = require('../../../activitypub/actor'); const Collection = require('../../../activitypub/collection'); +const EnigAssert = require('../../../enigma_assert'); // deps const _ = require('lodash'); @@ -46,7 +47,13 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { this.webServer.addRoute({ method: 'POST', path: /^\/_enig\/ap\/users\/.+\/inbox$/, - handler: this._inboxPostHandler.bind(this), + handler: (req, resp) => { + return this._enforceSigningPolicy( + req, + resp, + this._inboxPostHandler.bind(this) + ); + }, }); this.webServer.addRoute({ @@ -102,7 +109,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { return this.webServer.accessDenied(resp); } - return next(req, resp); + return next(req, resp, signature); } _selfUrlRequestHandler(req, resp) { @@ -146,12 +153,8 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { }); } - _inboxPostHandler(req, resp) { - // the request must be signed, and the signature must be valid - const signature = this._parseAndValidateSignature(req); - if (!signature) { - return this.webServer.accessDenied(resp); - } + _inboxPostHandler(req, resp, signature) { + EnigAssert(signature, 'Called without signature!'); const body = []; req.on('data', d => { @@ -161,7 +164,8 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { req.on('end', () => { let activity; try { - activity = Activity.fromJsonString(Buffer.concat(body).toString()); + activity = JSON.parse(Buffer.concat(body).toString()); + activity = new Activity(activity); } catch (e) { this.log.error( { error: e.message, url: req.url, method: req.method }, @@ -175,26 +179,28 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { return this.webServer.badRequest(resp); } - const activityFunctions = { - Follow: this._inboxFollowRequestHandler.bind(this), - // TODO: 'Create', 'Update', etc. - }; + switch (activity.type) { + case 'Follow': + return this._withUserRequestHandler( + signature, + activity, + this._inboxFollowRequestHandler.bind(this), + req, + resp + ); - if (_.has(activityFunctions, activity.type)) { - return this._withUserRequestHandler( - signature, - activity, - activityFunctions[activity.type], - req, - resp - ); - } else { - this.log.debug( - { type: activity.type }, - `Unsupported Activity type "${activity.type}"` - ); - return this.webServer.resourceNotFound(resp); + case 'Undo': + return this._inboxUndoRequestHandler(activity, req, resp); + + default: + this.log.warn( + { type: activity.type }, + `Unsupported Activity type "${activity.type}"` + ); + break; } + + return this.webServer.resourceNotFound(resp); }); } @@ -332,7 +338,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { } _inboxFollowRequestHandler(activity, remoteActor, user, resp) { - this.log.debug({ user_id: user.userId, actor: activity.actor }, 'Follow request'); + this.log.info({ user_id: user.userId, actor: activity.actor }, 'Follow request'); // // If the user blindly accepts Followers, we can persist @@ -351,6 +357,46 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { return resp.end(''); } + _inboxUndoRequestHandler(activity, req, resp) { + this.log.info({ actor: activity.actor }, 'Undo request'); + + const url = new URL(req.url, `https://${req.headers.host}`); + const accountName = this._accountNameFromUserPath(url, 'inbox'); + if (!accountName) { + return this.webServer.resourceNotFound(resp); + } + + userFromAccount(accountName, (err, user) => { + if (err) { + return this.webServer.resourceNotFound(resp); + } + + // we only understand Follow right now + if (!activity.object || activity.object.type !== 'Follow') { + return this.webServer.notImplemented(resp); + } + + Collection.remoteFromCollectionById( + 'followers', + user, + activity.actor, + err => { + if (err) { + return this.webServer.internalServerError(resp, err); + } + + this.log.info( + { userId: user.userId, actor: activity.actor }, + 'Undo "Follow" (un-follow) success' + ); + + resp.writeHead(202); + return resp.end(''); + } + ); + }); + } + _withUserRequestHandler(signature, activity, activityHandler, req, resp) { this.log.debug({ actor: activity.actor }, `Inbox request from ${activity.actor}`);