diff --git a/src/activitypub/index.js b/src/activitypub/index.js index f21e7cf10c..57227a2e20 100644 --- a/src/activitypub/index.js +++ b/src/activitypub/index.js @@ -7,6 +7,7 @@ const { createHash, createSign, createVerify } = require('crypto'); const request = require('../request'); const db = require('../database'); const user = require('../user'); +const utils = require('../utils'); const ttl = require('../cache/ttl'); const requestCache = ttl({ ttl: 1000 * 60 * 5 }); // 5 minutes @@ -103,12 +104,18 @@ ActivityPub.fetchPublicKey = async (uri) => { }; ActivityPub.sign = async (uid, url, payload) => { + // Sanity checking + if (!utils.isNumber(uid) || parseInt(uid, 10) < 0) { + throw new Error('[[error:invalid-uid]]'); + } + uid = parseInt(uid, 10); + // Returns string for use in 'Signature' header const { host, pathname } = new URL(url); const date = new Date().toUTCString(); const key = await ActivityPub.getPrivateKey(uid); const userslug = await user.getUserField(uid, 'userslug'); - const keyId = `${nconf.get('url')}/user/${userslug}#key`; + const keyId = `${nconf.get('url')}${uid > 0 ? `/user/${userslug}` : ''}#key`; let digest = null; let headers = '(request-target) host date'; @@ -183,7 +190,7 @@ ActivityPub.get = async (uid, uri) => { return requestCache.get(cacheKey); } - const headers = uid > 0 ? await ActivityPub.sign(uid, uri) : {}; + const headers = uid >= 0 ? await ActivityPub.sign(uid, uri) : {}; winston.verbose(`[activitypub/get] ${uri}`); const { response, body } = await request.get(uri, { headers: { diff --git a/src/controllers/activitypub/actors.js b/src/controllers/activitypub/actors.js new file mode 100644 index 0000000000..342b59c367 --- /dev/null +++ b/src/controllers/activitypub/actors.js @@ -0,0 +1,68 @@ +'use strict'; + +const nconf = require('nconf'); + +const user = require('../../user'); +const meta = require('../../meta'); +const activitypub = require('../../activitypub'); + +const Actors = module.exports; + +Actors.application = async function (req, res) { + const publicKey = await activitypub.getPublicKey(0); + const name = meta.config.title || 'NodeBB'; + + res.status(200).json({ + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + ], + id: `${nconf.get('url')}`, + url: `${nconf.get('url')}`, + inbox: `${nconf.get('url')}/inbox`, + outbox: `${nconf.get('url')}/outbox`, + + type: 'Application', + name, + + publicKey: { + id: `${nconf.get('url')}#key`, + owner: nconf.get('url'), + publicKeyPem: publicKey, + }, + }); +}; + +Actors.user = async function (req, res) { + // todo: view:users priv gate + const { userslug } = req.params; + const { uid } = res.locals; + const { username, displayname: name, aboutme, picture, 'cover:url': cover } = await user.getUserData(uid); + const publicKey = await activitypub.getPublicKey(uid); + + res.status(200).json({ + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + ], + id: `${nconf.get('url')}/user/${userslug}`, + url: `${nconf.get('url')}/user/${userslug}`, + followers: `${nconf.get('url')}/user/${userslug}/followers`, + following: `${nconf.get('url')}/user/${userslug}/following`, + inbox: `${nconf.get('url')}/user/${userslug}/inbox`, + outbox: `${nconf.get('url')}/user/${userslug}/outbox`, + + type: 'Person', + name, + preferredUsername: username, + summary: aboutme, + icon: picture ? `${nconf.get('url')}${picture}` : null, + image: cover ? `${nconf.get('url')}${cover}` : null, + + publicKey: { + id: `${nconf.get('url')}/user/${userslug}#key`, + owner: `${nconf.get('url')}/user/${userslug}`, + publicKeyPem: publicKey, + }, + }); +}; diff --git a/src/controllers/activitypub/index.js b/src/controllers/activitypub/index.js index 9c516f7d01..ae13e3e25e 100644 --- a/src/controllers/activitypub/index.js +++ b/src/controllers/activitypub/index.js @@ -9,43 +9,10 @@ const helpers = require('../helpers'); const Controller = module.exports; +Controller.actors = require('./actors'); Controller.profiles = require('./profiles'); Controller.topics = require('./topics'); -Controller.getActor = async (req, res) => { - // todo: view:users priv gate - const { userslug } = req.params; - const { uid } = res.locals; - const { username, displayname: name, aboutme, picture, 'cover:url': cover } = await user.getUserData(uid); - const publicKey = await activitypub.getPublicKey(uid); - - res.status(200).json({ - '@context': [ - 'https://www.w3.org/ns/activitystreams', - 'https://w3id.org/security/v1', - ], - id: `${nconf.get('url')}/user/${userslug}`, - url: `${nconf.get('url')}/user/${userslug}`, - followers: `${nconf.get('url')}/user/${userslug}/followers`, - following: `${nconf.get('url')}/user/${userslug}/following`, - inbox: `${nconf.get('url')}/user/${userslug}/inbox`, - outbox: `${nconf.get('url')}/user/${userslug}/outbox`, - - type: 'Person', - name, - preferredUsername: username, - summary: aboutme, - icon: picture ? `${nconf.get('url')}${picture}` : null, - image: cover ? `${nconf.get('url')}${cover}` : null, - - publicKey: { - id: `${nconf.get('url')}/user/${userslug}#key`, - owner: `${nconf.get('url')}/user/${userslug}`, - publicKeyPem: publicKey, - }, - }); -}; - Controller.getFollowing = async (req, res) => { const { followingCount: totalItems } = await user.getUserFields(res.locals.uid, ['followingCount']); diff --git a/src/routes/activitypub.js b/src/routes/activitypub.js index 8bd686308f..b96e57f83d 100644 --- a/src/routes/activitypub.js +++ b/src/routes/activitypub.js @@ -14,7 +14,8 @@ module.exports = function (app, middleware, controllers) { const middlewares = [middleware.activitypub.enabled, middleware.activitypub.assertS2S, middleware.exposeUid]; - app.get('/user/:userslug', middlewares, controllers.activitypub.getActor); + app.get('/', middlewares, controllers.activitypub.actors.application); + app.get('/user/:userslug', middlewares, controllers.activitypub.actors.user); app.get('/user/:userslug/inbox', middlewares, controllers.activitypub.getInbox); app.post('/user/:userslug/inbox', [...middlewares, middleware.activitypub.validate], controllers.activitypub.postInbox); diff --git a/src/routes/index.js b/src/routes/index.js index 451d68a9fe..ed9ad8eb5f 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -137,17 +137,17 @@ module.exports = async function (app, middleware) { app.use(middleware.stripLeadingSlashes); + await plugins.reloadRoutes({ router: router }); + await authRoutes.reloadRoutes({ router: router }); + await writeRoutes.reload({ router: router }); + addCoreRoutes(app, router, middleware, mounts); + // handle custom homepage routes router.use('/', controllers.home.rewrite); // homepage handled by `action:homepage.get:[route]` setupPageRoute(router, '/', [], controllers.home.pluginHook); - await plugins.reloadRoutes({ router: router }); - await authRoutes.reloadRoutes({ router: router }); - await writeRoutes.reload({ router: router }); - addCoreRoutes(app, router, middleware, mounts); - winston.info('[router] Routes added'); }; diff --git a/test/activitypub.js b/test/activitypub.js index 770a287c6a..da1a4b8992 100644 --- a/test/activitypub.js +++ b/test/activitypub.js @@ -174,7 +174,7 @@ describe('ActivityPub integration', () => { }); }); - describe('Actor endpoint', () => { + describe('User Actor endpoint', () => { let uid; let slug; @@ -216,6 +216,43 @@ describe('ActivityPub integration', () => { }); }); + describe('Instance Actor endpoint', () => { + let response; + let body; + + before(async () => { + ({ response, body } = await request.get(nconf.get('url'), { + headers: { + Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + }, + })); + }); + + it('should respond properly', async () => { + assert(response); + assert.strictEqual(response.statusCode, 200); + }); + + it('should return a valid ActivityPub Actor JSON-LD payload', async () => { + console.log(body); + assert(body.hasOwnProperty('@context')); + assert(body['@context'].includes('https://www.w3.org/ns/activitystreams')); + + ['id', 'url', 'inbox', 'outbox'].forEach((prop) => { + assert(body.hasOwnProperty(prop)); + assert(body[prop]); + }); + + assert.strictEqual(body.id, body.url); + assert.strictEqual(body.type, 'Application'); + }); + + it('should contain a `publicKey` property with a public key', async () => { + assert(body.hasOwnProperty('publicKey')); + assert(['id', 'owner', 'publicKeyPem'].every(prop => body.publicKey.hasOwnProperty(prop))); + }); + }); + describe('http signature signing and verification', () => { describe('.sign()', () => { let uid; @@ -256,6 +293,33 @@ describe('ActivityPub integration', () => { assert(digest); assert.strictEqual(digest, `sha-256=${checksum}`); }); + + it('should create a key for NodeBB itself if a uid of 0 is passed in', async () => { + const endpoint = `${nconf.get('url')}/user/${username}/inbox`; + await activitypub.sign(0, endpoint); + const { publicKey, privateKey } = await db.getObject(`uid:0:keys`); + + assert(publicKey); + assert(privateKey); + }); + + it('should return headers with an appropriate key id uri', async () => { + const endpoint = `${nconf.get('url')}/user/${username}/inbox`; + const { signature } = await activitypub.sign(uid, endpoint); + const [keyId] = signature.split(','); + + assert(signature); + assert.strictEqual(keyId, `keyId="${nconf.get('url')}/user/${username}#key"`); + }); + + it('should return the instance key id when uid is 0', async () => { + const endpoint = `${nconf.get('url')}/user/${username}/inbox`; + const { signature } = await activitypub.sign(0, endpoint); + const [keyId] = signature.split(','); + + assert(signature); + assert.strictEqual(keyId, `keyId="${nconf.get('url')}#key"`); + }); }); describe('.verify()', () => {