diff --git a/core/activitypub/activity.js b/core/activitypub/activity.js index 46e46d4f..f6c9db5d 100644 --- a/core/activitypub/activity.js +++ b/core/activitypub/activity.js @@ -23,6 +23,16 @@ module.exports = class Activity extends ActivityPubObject { return WellKnownActivityTypes; } + static makeFollow(webServer, localActor, remoteActor, id = null) { + id = id || Activity._makeFullId(webServer, 'follow'); + return new Activity({ + id, + type: 'Follow', + actor: localActor, + object: remoteActor.id, + }); + } + // 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 1caacd06..b88ce060 100644 --- a/core/activitypub/actor.js +++ b/core/activitypub/actor.js @@ -9,6 +9,7 @@ const { makeUserUrl, selfUrl, isValidLink, + makeSharedInboxUrl, } = require('../activitypub/util'); const { ActivityStreamsContext } = require('./const'); const Log = require('../logger').log; @@ -86,9 +87,11 @@ module.exports = class Actor extends ActivityPubObject { id: userSelfUrl, type: 'Person', preferredUsername: user.username, - name: user.getSanitizedName('real'), + name: userSettings.showRealName + ? user.getSanitizedName('real') + : user.username, endpoints: { - sharedInbox: 'TODO', + sharedInbox: makeSharedInboxUrl(webServer), }, inbox: makeUserUrl(webServer, user, '/ap/users/') + '/inbox', outbox: makeUserUrl(webServer, user, '/ap/users/') + '/outbox', diff --git a/core/activitypub/collection.js b/core/activitypub/collection.js index f9c92634..b40b8573 100644 --- a/core/activitypub/collection.js +++ b/core/activitypub/collection.js @@ -3,13 +3,20 @@ const ActivityPubObject = require('./object'); const apDb = require('../database').dbs.activitypub; const { getISOTimestampString } = require('../database'); -const { isString, get } = require('lodash'); +const { isString, get, isObject } = require('lodash'); + +const APPublicCollectionId = 'https://www.w3.org/ns/activitystreams#Public'; +const APPublicOwningUserId = 0; module.exports = class Collection extends ActivityPubObject { constructor(obj) { super(obj); } + static get PublicCollectionId() { + return APPublicCollectionId; + } + static followers(owningUser, page, webServer, cb) { return Collection.getOrdered( 'followers', @@ -68,17 +75,29 @@ module.exports = class Collection extends ActivityPubObject { ); } + static addPublicInboxItem(inboxItem, cb) { + return Collection.addToCollection( + 'publicInbox', + APPublicOwningUserId, + inboxItem.id, + inboxItem, + false, + cb + ); + } + static getOrdered(name, owningUser, includePrivate, page, mapper, webServer, cb) { const privateQuery = includePrivate ? '' : ' AND is_private = FALSE'; const followersUrl = makeUserUrl(webServer, owningUser, '/ap/users/') + `/${name}`; + const owningUserId = isObject(owningUser) ? owningUser.userId : owningUser; if (!page) { return apDb.get( `SELECT COUNT(id) AS count FROM collection WHERE user_id = ? AND name = ?${privateQuery};`, - [owningUser.userId, name], + [owningUserId, name], (err, row) => { if (err) { return cb(err); @@ -118,7 +137,7 @@ module.exports = class Collection extends ActivityPubObject { FROM collection WHERE user_id = ? AND name = ?${privateQuery} ORDER BY timestamp;`, - [owningUser.userId, name], + [owningUserId, name], (err, entries) => { if (err) { return cb(err); @@ -147,11 +166,12 @@ module.exports = class Collection extends ActivityPubObject { obj = JSON.stringify(obj); } + const owningUserId = isObject(owningUser) ? owningUser.userId : owningUser; isPrivate = isPrivate ? 1 : 0; apDb.run( `INSERT OR IGNORE INTO collection (name, timestamp, user_id, obj_id, obj_json, is_private) VALUES (?, ?, ?, ?, ?, ?);`, - [name, getISOTimestampString(), owningUser.userId, objectId, obj, isPrivate], + [name, getISOTimestampString(), owningUserId, objectId, obj, isPrivate], function res(err) { // non-arrow for 'this' scope if (err) { @@ -163,10 +183,11 @@ module.exports = class Collection extends ActivityPubObject { } static removeFromCollectionById(name, owningUser, objectId, cb) { + const owningUserId = isObject(owningUser) ? owningUser.userId : owningUser; apDb.run( `DELETE FROM collection WHERE user_id = ? AND name = ? AND obj_id = ?;`, - [owningUser.userId, name, objectId], + [owningUserId, name, objectId], err => { return cb(err); } diff --git a/core/activitypub/settings.js b/core/activitypub/settings.js index 7b09e6ef..3b848a28 100644 --- a/core/activitypub/settings.js +++ b/core/activitypub/settings.js @@ -5,7 +5,7 @@ module.exports = class ActivityPubSettings { this.enabled = true; // :TODO: fetch from +op config default this.manuallyApproveFollowers = false; this.hideSocialGraph = false; // followers, following - this.showRealName = false; + this.showRealName = true; this.image = ''; this.icon = ''; diff --git a/core/activitypub/util.js b/core/activitypub/util.js index 1461c940..a155f1b7 100644 --- a/core/activitypub/util.js +++ b/core/activitypub/util.js @@ -14,6 +14,7 @@ const moment = require('moment'); exports.ActivityStreamsContext = 'https://www.w3.org/ns/activitystreams'; exports.isValidLink = isValidLink; +exports.makeSharedInboxUrl = makeSharedInboxUrl; exports.makeUserUrl = makeUserUrl; exports.webFingerProfileUrl = webFingerProfileUrl; exports.selfUrl = selfUrl; @@ -39,6 +40,10 @@ function isValidLink(l) { return /^https?:\/\/.+$/.test(l); } +function makeSharedInboxUrl(webServer) { + return webServer.buildUrl(WellKnownLocations.Internal + '/ap/shared-inbox'); +} + function makeUserUrl(webServer, user, relPrefix) { return webServer.buildUrl( WellKnownLocations.Internal + `${relPrefix}${user.username}` diff --git a/core/database.js b/core/database.js index 0ed3fab4..8427b592 100644 --- a/core/database.js +++ b/core/database.js @@ -529,7 +529,7 @@ dbs.message.run( 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 + user_id INTEGER NOT NULL, -- Local, owning user ID, 0 means "all" for sharedInbox obj_id VARCHAR NOT NULL, -- Object ID from obj_json.id obj_json VARCHAR NOT NULL, -- Object varies by collection (obj_json.type) is_private INTEGER NOT NULL, -- Is this object private to |user_id|? diff --git a/core/servers/content/web_handlers/activitypub.js b/core/servers/content/web_handlers/activitypub.js index 771b6874..81be485a 100644 --- a/core/servers/content/web_handlers/activitypub.js +++ b/core/servers/content/web_handlers/activitypub.js @@ -56,6 +56,12 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { }, }); + this.webServer.addRoute({ + method: 'POST', + path: /^\/_enig\/ap\/shared-inbox$/, + handler: this._sharedInboxPostHandler.bind(this), + }); + this.webServer.addRoute({ method: 'GET', path: /^\/_enig\/ap\/users\/.+\/outbox(\?page=[0-9]+)?$/, @@ -162,20 +168,20 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { }); req.on('end', () => { - let activity; - try { - activity = JSON.parse(Buffer.concat(body).toString()); - activity = new Activity(activity); - } catch (e) { + const activity = Activity.fromJsonString(Buffer.concat(body).toString()); + if (!activity) { this.log.error( - { error: e.message, url: req.url, method: req.method }, + { url: req.url, method: req.method, endpoint: 'inbox' }, 'Failed to parse Activity' ); return this.webServer.resourceNotFound(resp); } if (!activity.isValid()) { - this.log.warn({ activity }, 'Invalid or unsupported Activity'); + this.log.warn( + { activity, endpoint: 'inbox' }, + 'Invalid or unsupported Activity' + ); return this.webServer.badRequest(resp); } @@ -192,6 +198,8 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { case 'Undo': return this._inboxUndoRequestHandler(activity, req, resp); + // :TODO: Create, etc. + default: this.log.warn( { type: activity.type }, @@ -204,6 +212,100 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { }); } + _sharedInboxPostHandler(req, resp) { + const body = []; + req.on('data', d => { + body.push(d); + }); + + req.on('end', () => { + const activity = Activity.fromJsonString(Buffer.concat(body).toString()); + if (!activity) { + this.log.error( + { url: req.url, method: req.method, endpoint: 'sharedInbox' }, + 'Failed to parse Activity' + ); + return this.webServer.resourceNotFound(resp); + } + + if (!activity.isValid()) { + this.log.warn( + { activity, endpoint: 'sharedInbox' }, + 'Invalid or unsupported Activity' + ); + return this.webServer.badRequest(resp); + } + + switch (activity.type) { + case 'Create': + return this._sharedInboxCreateActivity(req, resp, activity); + + default: + this.log.warn( + { type: activity.type }, + 'Invalid or unknown Activity type' + ); + return this.resourceNotFound(resp); + } + }); + } + + _sharedInboxCreateActivity(req, resp, activity) { + // When an object is being delivered to the originating actor's followers, + // a server MAY reduce the number of receiving actors delivered to by + // identifying all followers which share the same sharedInbox who would + // otherwise be individual recipients and instead deliver objects to said + // sharedInbox. Thus in this scenario, the remote/receiving server participates + // in determining targeting and performing delivery to specific inboxes. + let toActors = activity.to; + if (!Array.isArray(toActors)) { + toActors = [toActors]; + } + + const createWhat = _.get(activity, 'object.type'); + switch (createWhat) { + case 'Note': + return this._deliverSharedInboxNote(req, resp, toActors, activity); + + default: + this.log.warn( + { type: createWhat }, + 'Invalid or unsupported "Create" type' + ); + return this.resourceNotFound(resp); + } + } + + _deliverSharedInboxNote(req, resp, toActors, activity) { + async.forEach( + toActors, + (actor, nextActor) => { + if (Collection.PublicCollectionId === actor) { + // Deliver to inbox for "everyone": + // - Add to 'sharedInbox' collection + // + Collection.addPublicInboxItem(activity.object, err => { + if (err) { + return nextActor(err); + } + + return nextActor(null); + }); + } else { + nextActor(null); + } + }, + err => { + if (err) { + return this.webServer.internalServerError(resp, err); + } + + resp.writeHead(202); + return resp.end(''); + } + ); + } + _getCollectionHandler(name, req, resp, signature) { EnigAssert(signature, 'Missing signature!');