feat: application actor + public key for uid 0 signs, moved homepage route to after core routes are generated

This commit is contained in:
Julian Lam
2024-01-22 16:18:49 -05:00
parent 403bf3e1a8
commit 9885f94a2b
6 changed files with 150 additions and 43 deletions

View File

@@ -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: {

View File

@@ -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,
},
});
};

View File

@@ -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']);

View File

@@ -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);

View File

@@ -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');
};

View File

@@ -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()', () => {