social-butterfly
Version:
Incorporate federated social network protocols easily. Used with Hello, world federated blog.
166 lines (136 loc) • 4.99 kB
JavaScript
import { accept, createArticle, findUserRemote, handle } from './activitystreams';
import { buildUrl } from './util/url_factory';
import crypto from 'crypto';
import express from 'express';
import fetch from 'node-fetch';
import forge from 'node-forge';
import magic from 'magic-signatures';
export default (options) => {
const activityPubRouter = express.Router();
activityPubRouter.get('/actor', Actor(options));
activityPubRouter.post('/inbox', Inbox(options));
activityPubRouter.get('/message', Message(options));
return activityPubRouter;
};
const Actor = (options) => async (req, res, next) => {
const resource = req.query.resource;
const user = await options.getLocalUser(resource, req);
if (!user) {
return res.sendStatus(404);
}
const actorUrl = buildUrl({ req, pathname: '/api/social/activitypub/actor', searchParams: { resource } });
const inboxUrl = buildUrl({ req, pathname: '/api/social/activitypub/inbox', searchParams: { resource } });
const json = {
'@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'],
id: actorUrl,
type: 'Person',
preferredUsername: user.username,
inbox: inboxUrl,
url: user.url,
publicKey: {
id: `${actorUrl}#main-key`,
owner: actorUrl,
publicKeyPem: forge.pki.publicKeyToPem(magic.magicToRSA(user.magic_key)),
},
};
res.json(json);
};
const Inbox = (options) => async (req, res, next) => {
if (!req.query.resource) {
return res.sendStatus(400);
}
const user = await options.getLocalUser(req.query.resource, req);
if (!user) {
return res.sendStatus(404);
}
const userRemote = await findUserRemote(options, req.body, res, user);
if (!userRemote) {
console.log('activitypub fail: ', req.body);
return res.sendStatus(401);
}
const success = verifyMessage(req, userRemote);
if (!success) {
return res.sendStatus(401);
}
await handle(req.body.type, options, req, res, req.body, user, userRemote);
accept(req, user, userRemote);
res.sendStatus(204);
};
const Message = (options) => async (req, res, next) => {
const content = await options.getLocalContent(req.query.resource, req);
const user = await options.getLocalUser(req.query.resource, req);
if (!content || !user) {
return res.sendStatus(404);
}
const json = (await createArticle(req, content, user)).object;
json['@context'] = 'https://www.w3.org/ns/activitystreams';
res.json(json);
};
export async function send(req, userRemote, contentOwner, message) {
const { currentDate, signatureHeader } = signMessage(req, contentOwner, userRemote);
const inboxUrl = new URL(userRemote.activitypub_inbox_url);
try {
await fetch(userRemote.activitypub_inbox_url, {
method: 'POST',
body: JSON.stringify(message),
headers: {
Host: inboxUrl.hostname,
Date: currentDate.toUTCString(),
Signature: signatureHeader,
'Content-Type': 'application/ld+json',
},
});
} catch (ex) {
// Not a big deal if this fails.
// TODO(mime): add logging later.
}
}
function signMessage(req, contentOwner, userRemote) {
const currentDate = new Date();
const inboxUrl = new URL(userRemote.activitypub_inbox_url);
const signer = crypto
.createSign('sha256')
.update(`(request-target): post ${inboxUrl.pathname}${inboxUrl.search}\n`)
.update(`host: ${inboxUrl.hostname}\n`)
.update(`date: ${currentDate.toUTCString()}`)
.end();
const signature = signer.sign(contentOwner.private_key).toString('base64');
const actorUrl = buildUrl({
req,
pathname: '/api/social/activitypub/actor',
searchParams: { resource: contentOwner.url },
});
const signatureHeader = `keyId="${actorUrl}",headers="(request-target) host date",signature="${signature}"`;
return { currentDate, signatureHeader };
}
function verifyMessage(req, userRemote) {
try {
const signatureMap = {};
req.headers['signature'].split(',').forEach((keyValue) => {
const keyValuePair = keyValue.split('=');
signatureMap[keyValuePair[0]] = keyValuePair.slice(1).join('=').replace(/^"/, '').replace(/"$/, '');
});
if ((new Date() - new Date(req.headers['Date'])) / 1000 < 60 * 5 /* 5 minutes */) {
// Date is not within a reasonable time frame.
return false;
}
const data = signatureMap['headers']
.split(' ')
.map((header) => {
return header === '(request-target)'
? `(request-target): post ${req.originalUrl}`
: `${header}: ${req.headers[header]}`;
})
.join('\n');
const verify = crypto.createVerify('sha256');
verify.write(data);
verify.end();
let publicKey = userRemote.magic_key;
if (publicKey.startsWith('RSA.')) {
publicKey = forge.pki.publicKeyToPem(magic.magicToRSA(userRemote.magic_key));
}
return verify.verify(publicKey, signatureMap['signature'], 'base64');
} catch (ex) {
return false;
}
}