diff --git a/core/activitypub/activity.js b/core/activitypub/activity.js index 51aa64f5..ba4de49d 100644 --- a/core/activitypub/activity.js +++ b/core/activitypub/activity.js @@ -151,6 +151,7 @@ module.exports = class Activity { ); } + // :TODO: move to Collection static fromOutboxEntries(owningUser, webServer, cb) { // :TODO: support paging const getOpts = { @@ -183,11 +184,11 @@ module.exports = class Activity { } sendTo(actorUrl, fromUser, webServer, cb) { - const privateKey = fromUser.getProperty(UserProps.PrivateKeyMain); + const privateKey = fromUser.getProperty(UserProps.PrivateActivityPubSigningKey); if (_.isEmpty(privateKey)) { return cb( Errors.MissingProperty( - `User "${fromUser.username}" is missing the '${UserProps.PrivateKeyMain}' property` + `User "${fromUser.username}" is missing the '${UserProps.PrivateActivityPubSigningKey}' property` ) ); } diff --git a/core/activitypub/actor.js b/core/activitypub/actor.js index f098ed0f..82f07e8c 100644 --- a/core/activitypub/actor.js +++ b/core/activitypub/actor.js @@ -14,12 +14,13 @@ const { const Log = require('../logger').log; const { queryWebFinger } = require('../webfinger'); const EnigAssert = require('../enigma_assert'); +const ActivityPubSettings = require('./settings'); // deps const _ = require('lodash'); const https = require('https'); - const isString = require('lodash/isString'); +const mimeTypes = require('mime-types'); // https://www.w3.org/TR/activitypub/#actor-objects module.exports = class Actor { @@ -63,6 +64,21 @@ module.exports = class Actor { // :TODO: from a User object static fromLocalUser(user, webServer, cb) { const userSelfUrl = selfUrl(webServer, user); + const userSettings = ActivityPubSettings.fromUser(user); + + const addImage = (o, t) => { + const url = userSettings[t]; + if (url) { + const mt = mimeTypes.contentType(url); + if (mt) { + o[t] = { + mediaType: mt, + type: 'Image', + url, + }; + } + } + }; const obj = { '@context': [ @@ -82,7 +98,8 @@ module.exports = class Actor { following: makeUserUrl(webServer, user, '/ap/users/') + '/following', summary: user.getProperty(UserProps.AutoSignature) || '', url: webFingerProfileUrl(webServer, user), - + manuallyApprovesFollowers: userSettings.manuallyApprovesFollowers, + discoverable: userSettings.discoverable, // :TODO: we can start to define BBS related stuff with the community perhaps // attachment: [ // { @@ -93,6 +110,9 @@ module.exports = class Actor { // ], }; + addImage('icon'); + addImage('image'); + const publicKeyPem = user.getProperty(UserProps.PublicActivityPubSigningKey); if (!_.isEmpty(publicKeyPem)) { obj.publicKey = { diff --git a/core/activitypub/collection.js b/core/activitypub/collection.js new file mode 100644 index 00000000..5aed8cde --- /dev/null +++ b/core/activitypub/collection.js @@ -0,0 +1,48 @@ +const { ActivityStreamsContext, makeUserUrl } = require('./util'); +const { FollowerEntryStatus, getFollowerEntries } = require('./db'); + +module.exports = class Collection { + constructor(obj) { + this['@context'] = ActivityStreamsContext; + Object.assign(this, obj); + } + + static followers(owningUser, page, webServer, cb) { + if (!page) { + const followersUrl = + makeUserUrl(webServer, owningUser, '/ap/users/') + '/followers'; + + const obj = { + id: followersUrl, + type: 'OrderedCollection', + first: `${followersUrl}?page=1`, + totalItems: 1, + }; + + return cb(null, new Collection(obj)); + } + + // :TODO: actually support paging... + page = parseInt(page); + const getOpts = { + status: FollowerEntryStatus.Accepted, + }; + getFollowerEntries(owningUser, getOpts, (err, followers) => { + if (err) { + return cb(err); + } + + const baseId = makeUserUrl(webServer, owningUser, '/ap/users') + '/followers'; + + const obj = { + id: `${baseId}/page=${page}`, + type: 'OrderedCollectionPage', + totalItems: followers.length, + orderedItems: followers, + partOf: baseId, + }; + + return cb(null, new Collection(obj)); + }); + } +}; diff --git a/core/activitypub/db.js b/core/activitypub/db.js index 711d2f6a..911e51d0 100644 --- a/core/activitypub/db.js +++ b/core/activitypub/db.js @@ -2,6 +2,16 @@ const apDb = require('../database').dbs.activitypub; exports.persistToOutbox = persistToOutbox; exports.getOutboxEntries = getOutboxEntries; +exports.persistFollower = persistFollower; +exports.getFollowerEntries = getFollowerEntries; + +const FollowerEntryStatus = { + Invalid: 0, // Invalid + Requested: 1, // Entry is a *request* to local user + Accepted: 2, // Accepted by local user + Rejected: 3, // Rejected by local user +}; +exports.FollowerEntryStatus = FollowerEntryStatus; function persistToOutbox(activity, fromUser, message, cb) { const activityJson = JSON.stringify(activity); @@ -55,3 +65,36 @@ function getOutboxEntries(owningUser, options, cb) { } ); } + +function persistFollower(localUser, remoteActor, options, cb) { + const status = options.status || FollowerEntryStatus.Requested; + + apDb.run( + `INSERT OR IGNORE INTO followers (user_id, follower_id, status) + VALUES (?, ?, ?);`, + [localUser.userId, remoteActor.id, status], + function res(err) { + // non-arrow for 'this' scope + return cb(err, this.lastID); + } + ); +} + +function getFollowerEntries(localUser, options, cb) { + const status = options.status || FollowerEntryStatus.Accepted; + + apDb.all( + `SELECT follower_id + FROM followers + WHERE user_id = ? AND status = ?;`, + [localUser.userId, status], + (err, rows) => { + if (err) { + return cb(err); + } + + const entries = rows.map(r => r.follower_id); + return cb(null, entries); + } + ); +} diff --git a/core/activitypub/settings.js b/core/activitypub/settings.js index d71a9752..7b09e6ef 100644 --- a/core/activitypub/settings.js +++ b/core/activitypub/settings.js @@ -6,8 +6,8 @@ module.exports = class ActivityPubSettings { this.manuallyApproveFollowers = false; this.hideSocialGraph = false; // followers, following this.showRealName = false; - this.imageUrl = ''; - this.iconUrl = ''; + this.image = ''; + this.icon = ''; if (obj) { Object.assign(this, obj); diff --git a/core/database.js b/core/database.js index 6cc13f66..63e43e11 100644 --- a/core/database.js +++ b/core/database.js @@ -531,6 +531,17 @@ dbs.message.run( ON outbox (json_extract(activity_json, '$.type'));` ); + dbs.activitypub.run( + `CREATE TABLE IF NOT EXISTS followers ( + id INTEGER PRIMARY KEY, -- Local ID + user_id INTEGER NOT NULL, -- Local user ID + follower_id VARCHAR NOT NULL, -- Actor ID of follower + status INTEGER NOT NULL, -- Status: See FollowerEntryStatus + + UNIQUE(user_id, follower_id) + );` + ); + return cb(null); }, }; diff --git a/core/servers/content/web_handlers/activitypub.js b/core/servers/content/web_handlers/activitypub.js index 705ec1d2..5d0311d1 100644 --- a/core/servers/content/web_handlers/activitypub.js +++ b/core/servers/content/web_handlers/activitypub.js @@ -9,11 +9,14 @@ const Config = require('../../../config').get; const Activity = require('../../../activitypub/activity'); const ActivityPubSettings = require('../../../activitypub/settings'); const Actor = require('../../../activitypub/actor'); +const Collection = require('../../../activitypub/collection'); +const { persistFollower, FollowerEntryStatus } = require('../../../activitypub/db'); // deps const _ = require('lodash'); const enigma_assert = require('../../../enigma_assert'); const httpSignature = require('http-signature'); +const async = require('async'); exports.moduleInfo = { name: 'ActivityPub', @@ -49,10 +52,16 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { this.webServer.addRoute({ method: 'GET', - path: /^\/_enig\/ap\/users\/.+\/outbox(\?page=true)?$/, + path: /^\/_enig\/ap\/users\/.+\/outbox$/, handler: this._outboxGetHandler.bind(this), }); + this.webServer.addRoute({ + method: 'GET', + path: /^\/_enig\/ap\/users\/.+\/followers(\?page=[0-9]+)?$/, + handler: this._followersGetHandler.bind(this), + }); + // :TODO: NYI // this.webServer.addRoute({ // method: 'GET', @@ -164,12 +173,11 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { // /_enig/ap/users/SomeName/outbox -> SomeName const url = new URL(req.url, `https://${req.headers.host}`); - const m = url.pathname.match(/^\/_enig\/ap\/users\/(.+)\/outbox$/); - if (!m || !m[1]) { + const accountName = this._accountNameFromUserPath(url, 'outbox'); + if (!accountName) { return this.webServer.resourceNotFound(resp); } - const accountName = m[1]; userFromAccount(accountName, (err, user) => { if (err) { this.log.info( @@ -197,6 +205,61 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { }); } + _accountNameFromUserPath(url, suffix) { + const re = new RegExp(`^/_enig/ap/users/(.+)/${suffix}(\\?page=[0-9]+)?$`); + const m = url.pathname.match(re); + if (!m || !m[1]) { + return null; + } + return m[1]; + } + + _followersGetHandler(req, resp) { + this.log.trace({ url: req.url }, 'Request for "followers"'); + + // :TODO: dry this stuff.. + + // the request must be signed, and the signature must be valid + const signature = this._parseAndValidateSignature(req); + if (!signature) { + return this.webServer.accessDenied(resp); + } + + // /_enig/ap/users/SomeName/outbox -> SomeName + const url = new URL(req.url, `https://${req.headers.host}`); + const accountName = this._accountNameFromUserPath(url, 'followers'); + if (!accountName) { + return this.webServer.resourceNotFound(resp); + } + + userFromAccount(accountName, (err, user) => { + if (err) { + this.log.info( + { reason: err.message, accountName: accountName }, + `No user "${accountName}" for "self"` + ); + return this.webServer.resourceNotFound(resp); + } + + const page = url.searchParams.get('page'); + Collection.followers(user, page, this.webServe, (err, collection) => { + if (err) { + // :TODO: LOG ME + return this.webServer.internalServerError(resp); + } + + const body = JSON.stringify(collection); + const headers = { + 'Content-Type': ActivityJsonMime, + 'Content-Length': body.length, + }; + + resp.writeHead(200, headers); + return resp.end(body); + }); + }); + } + _parseAndValidateSignature(req) { let signature; try { @@ -285,53 +348,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { // :TODO: Implement the queue const activityPubSettings = ActivityPubSettings.fromUser(user); if (!activityPubSettings.manuallyApproveFollowers) { - Actor.fromLocalUser(user, this.webServer, (err, localActor) => { - if (err) { - return this.log.warn( - { inbox: actor.inbox, error: err.message }, - 'Failed to load local Actor for "Accept"' - ); - } - - const accept = Activity.makeAccept( - this.webServer, - localActor, - activity - ); - - accept.sendTo( - actor.inbox, - user, - this.webServer, - (err, respBody, res) => { - if (err) { - return this.log.warn( - { - inbox: actor.inbox, - statusCode: res.statusCode, - error: err.message, - }, - 'Failed POSTing "Accept" to inbox' - ); - } - - if (res.statusCode !== 202 && res.statusCode !== 200) { - return this.log.warn( - { - inbox: actor.inbox, - statusCode: res.statusCode, - }, - 'Unexpected status code' - ); - } - - this.log.trace( - { inbox: actor.inbox }, - 'Remote server received our "Accept" successfully' - ); - } - ); - }); + this._recordAcceptedFollowRequest(user, actor, activity); } resp.writeHead(200, { 'Content-Type': 'text/html' }); @@ -340,6 +357,80 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { }); } + _recordAcceptedFollowRequest(localUser, remoteActor, requestActivity) { + async.series( + [ + callback => { + const persistOpts = { + status: FollowerEntryStatus.Accepted, + }; + return persistFollower(localUser, remoteActor, persistOpts, callback); + }, + callback => { + Actor.fromLocalUser(localUser, this.webServer, (err, localActor) => { + if (err) { + this.log.warn( + { inbox: remoteActor.inbox, error: err.message }, + 'Failed to load local Actor for "Accept"' + ); + return callback(err); + } + + const accept = Activity.makeAccept( + this.webServer, + localActor, + requestActivity + ); + + accept.sendTo( + remoteActor.inbox, + localUser, + this.webServer, + (err, respBody, res) => { + if (err) { + this.log.warn( + { + inbox: remoteActor.inbox, + error: err.message, + }, + 'Failed POSTing "Accept" to inbox' + ); + return callback(null); // just a warning + } + + if (res.statusCode !== 202 && res.statusCode !== 200) { + this.log.warn( + { + inbox: remoteActor.inbox, + statusCode: res.statusCode, + }, + 'Unexpected status code' + ); + return callback(null); // just a warning + } + + this.log.trace( + { inbox: remoteActor.inbox }, + 'Remote server received our "Accept" successfully' + ); + + return callback(null); + } + ); + }); + }, + ], + err => { + if (err) { + this.log.error( + { error: err.message }, + 'Failed processing Follow request' + ); + } + } + ); + } + _authorizeInteractionHandler(req, resp) { console.log(req); console.log(resp);