diff --git a/src/activitypub.js b/src/activitypub.js new file mode 100644 index 0000000000..ec25ef600f --- /dev/null +++ b/src/activitypub.js @@ -0,0 +1,42 @@ +'use strict'; + +const { generateKeyPairSync } = require('crypto'); + +const winston = require('winston'); + +const db = require('./database'); + +const ActivityPub = module.exports; + +ActivityPub.getPublicKey = async (uid) => { + let publicKey; + + try { + ({ publicKey } = await db.getObject(`uid:${uid}:keys`)); + } catch (e) { + ({ publicKey } = await generateKeys(uid)); + } + + return publicKey; +}; + +async function generateKeys(uid) { + winston.info(`[activitypub] Generating RSA key-pair for uid ${uid}`); + const { + publicKey, + privateKey, + } = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { + type: 'spki', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + }, + }); + + await db.setObject(`uid:${uid}:keys`, { publicKey, privateKey }); + return { publicKey, privateKey }; +} diff --git a/src/controllers/activitypub.js b/src/controllers/activitypub.js new file mode 100644 index 0000000000..734897380f --- /dev/null +++ b/src/controllers/activitypub.js @@ -0,0 +1,41 @@ +'use strict'; + +const nconf = require('nconf'); + +const user = require('../user'); +const activitypub = require('../activitypub'); + +const Controller = module.exports; + +Controller.getActor = async (req, res) => { + // todo: view:users priv gate + const { userslug } = req.params; + const { uid } = res.locals; + const { username, 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', + 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}`, + owner: `${nconf.get('url')}/user/${userslug}#key`, + publicKeyPem: publicKey, + }, + }); +}; diff --git a/src/controllers/index.js b/src/controllers/index.js index b5dc1373e7..51a1cf87bf 100644 --- a/src/controllers/index.js +++ b/src/controllers/index.js @@ -13,6 +13,7 @@ const Controllers = module.exports; Controllers.ping = require('./ping'); Controllers['well-known'] = require('./well-known'); +Controllers.activitypub = require('./activitypub'); Controllers.home = require('./home'); Controllers.topics = require('./topics'); Controllers.posts = require('./posts'); diff --git a/src/controllers/well-known.js b/src/controllers/well-known.js index c45c6ea8d3..86caae4173 100644 --- a/src/controllers/well-known.js +++ b/src/controllers/well-known.js @@ -16,7 +16,6 @@ Controller.webfinger = async (req, res) => { } const canView = await privileges.global.can('view:users', req.uid); - console.log('canView', canView, req.uid); if (!canView) { return res.sendStatus(403); } @@ -41,6 +40,11 @@ Controller.webfinger = async (req, res) => { type: 'text/html', href: `${nconf.get('url')}/user/${slug}`, }, + { + rel: 'self', + type: 'application/activity+json', + href: `${nconf.get('url')}/user/${slug}`, // actor + }, ], }; diff --git a/src/messaging/uploads.js b/src/messaging/uploads.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/middleware/index.js b/src/middleware/index.js index 80a5f568c6..4a9ebb44f8 100644 --- a/src/middleware/index.js +++ b/src/middleware/index.js @@ -297,3 +297,27 @@ middleware.handleMultipart = (req, res, next) => { multipartMiddleware(req, res, next); }; + +middleware.proceedOnActivityPub = (req, res, next) => { + // For whatever reason, express accepts does not recognize "profile" as a valid differentiator + // Therefore, manual header parsing is used here. + const { accept } = req.headers; + if (!accept) { + return next('route'); + } + + const acceptable = [ + 'application/activity+json', + 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + ]; + const pass = accept.split(',').some((value) => { + const parts = value.split(';').map(v => v.trim()); + return acceptable.includes(value || parts[0]); + }); + + if (!pass) { + return next('route'); + } + + next(); +}; diff --git a/src/routes/activitypub.js b/src/routes/activitypub.js new file mode 100644 index 0000000000..14e840d5b1 --- /dev/null +++ b/src/routes/activitypub.js @@ -0,0 +1,7 @@ +'use strict'; + +module.exports = function (app, middleware, controllers) { + const middlewares = [middleware.proceedOnActivityPub, middleware.exposeUid]; + + app.get('/user/:userslug', middlewares, controllers.activitypub.getActor); +}; diff --git a/src/routes/index.js b/src/routes/index.js index 8def527624..451d68a9fe 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -23,6 +23,7 @@ const _mounts = { admin: require('./admin'), feed: require('./feeds'), 'well-known': require('./well-known'), + activitypub: require('./activitypub'), }; _mounts.main = (app, middleware, controllers) => { @@ -155,6 +156,7 @@ function addCoreRoutes(app, router, middleware, mounts) { _mounts.api(router, middleware, controllers); _mounts.feed(router, middleware, controllers); + _mounts.activitypub(router, middleware, controllers); _mounts.main(router, middleware, controllers); _mounts.mod(router, middleware, controllers); _mounts.globalMod(router, middleware, controllers);