2023-05-17 13:13:30 -04:00
|
|
|
'use strict';
|
|
|
|
|
|
2023-06-19 17:29:22 -04:00
|
|
|
const nconf = require('nconf');
|
2024-01-05 22:45:33 -05:00
|
|
|
const winston = require('winston');
|
2023-06-19 17:29:22 -04:00
|
|
|
const { createHash, createSign, createVerify } = require('crypto');
|
2023-05-17 13:13:30 -04:00
|
|
|
|
2023-12-19 14:33:38 -05:00
|
|
|
const request = require('../request');
|
2023-05-29 17:42:44 -04:00
|
|
|
const db = require('../database');
|
2023-06-19 17:29:22 -04:00
|
|
|
const user = require('../user');
|
2023-06-23 14:59:47 -04:00
|
|
|
const ttl = require('../cache/ttl');
|
2023-05-17 13:13:30 -04:00
|
|
|
|
2024-01-12 11:27:55 -05:00
|
|
|
const requestCache = ttl({ ttl: 1000 * 60 * 5 }); // 5 minutes
|
2023-06-23 14:59:47 -04:00
|
|
|
const actorCache = ttl({ ttl: 1000 * 60 * 60 * 24 }); // 24 hours
|
2023-05-17 13:13:30 -04:00
|
|
|
const ActivityPub = module.exports;
|
|
|
|
|
|
2023-06-02 14:22:43 -04:00
|
|
|
ActivityPub.helpers = require('./helpers');
|
2023-06-28 14:59:39 -04:00
|
|
|
ActivityPub.inbox = require('./inbox');
|
2024-01-10 14:19:57 -05:00
|
|
|
ActivityPub.mocks = require('./mocks');
|
2024-01-11 10:05:02 -05:00
|
|
|
ActivityPub.notes = require('./notes');
|
2023-06-02 14:22:43 -04:00
|
|
|
|
2024-01-05 22:45:33 -05:00
|
|
|
ActivityPub.getActor = async (uid, input) => {
|
2023-12-13 13:14:51 -05:00
|
|
|
// Can be a webfinger id, uri, or object, handle as appropriate
|
|
|
|
|
let uri;
|
2024-01-04 16:23:09 -05:00
|
|
|
if (ActivityPub.helpers.isUri(input)) {
|
2023-12-13 13:14:51 -05:00
|
|
|
uri = input;
|
|
|
|
|
} else if (input.indexOf('@') !== -1) { // Webfinger
|
|
|
|
|
({ actorUri: uri } = await ActivityPub.helpers.query(input));
|
|
|
|
|
} else {
|
|
|
|
|
throw new Error('[[error:invalid-data]]');
|
2023-06-23 14:59:47 -04:00
|
|
|
}
|
|
|
|
|
|
2023-12-14 13:47:28 -05:00
|
|
|
if (!uri) {
|
|
|
|
|
throw new Error('[[error:invalid-uid]]');
|
|
|
|
|
}
|
|
|
|
|
|
2023-12-13 13:14:51 -05:00
|
|
|
if (actorCache.has(uri)) {
|
|
|
|
|
return actorCache.get(uri);
|
2023-05-29 17:42:44 -04:00
|
|
|
}
|
|
|
|
|
|
2024-01-08 14:30:09 -05:00
|
|
|
try {
|
|
|
|
|
const actor = await ActivityPub.get(uid, uri);
|
|
|
|
|
|
|
|
|
|
// Follow counts
|
|
|
|
|
const [followers, following] = await Promise.all([
|
|
|
|
|
actor.followers ? ActivityPub.get(uid, actor.followers) : { totalItems: 0 },
|
|
|
|
|
actor.following ? ActivityPub.get(uid, actor.following) : { totalItems: 0 },
|
|
|
|
|
]);
|
|
|
|
|
actor.followerCount = followers.totalItems;
|
|
|
|
|
actor.followingCount = following.totalItems;
|
|
|
|
|
|
2024-01-08 14:45:56 -05:00
|
|
|
actor.hostname = new URL(uri).hostname;
|
|
|
|
|
|
2024-01-08 14:30:09 -05:00
|
|
|
actorCache.set(uri, actor);
|
|
|
|
|
return actor;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
winston.warn(`[activitypub/getActor] Unable to retrieve actor "${uri}", error: ${e.message}`);
|
2024-01-05 15:31:09 -05:00
|
|
|
return null;
|
|
|
|
|
}
|
2023-05-29 17:42:44 -04:00
|
|
|
};
|
|
|
|
|
|
2024-01-05 22:45:33 -05:00
|
|
|
ActivityPub.resolveInboxes = async (uid, ids) => await Promise.all(ids.map(async (id) => {
|
|
|
|
|
const actor = await ActivityPub.getActor(uid, id);
|
2023-06-23 14:59:47 -04:00
|
|
|
return actor.inbox;
|
|
|
|
|
}));
|
|
|
|
|
|
2023-05-17 13:13:30 -04:00
|
|
|
ActivityPub.getPublicKey = async (uid) => {
|
|
|
|
|
let publicKey;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
({ publicKey } = await db.getObject(`uid:${uid}:keys`));
|
|
|
|
|
} catch (e) {
|
2023-06-19 17:29:22 -04:00
|
|
|
({ publicKey } = await ActivityPub.helpers.generateKeys(uid));
|
2023-05-17 13:13:30 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return publicKey;
|
|
|
|
|
};
|
|
|
|
|
|
2023-06-19 17:29:22 -04:00
|
|
|
ActivityPub.getPrivateKey = async (uid) => {
|
|
|
|
|
let privateKey;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
({ privateKey } = await db.getObject(`uid:${uid}:keys`));
|
|
|
|
|
} catch (e) {
|
|
|
|
|
({ privateKey } = await ActivityPub.helpers.generateKeys(uid));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return privateKey;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
ActivityPub.fetchPublicKey = async (uri) => {
|
|
|
|
|
// Used for retrieving the public key from the passed-in keyId uri
|
2023-12-21 14:38:16 -05:00
|
|
|
const { body } = await request.get(uri, {
|
2023-06-19 17:29:22 -04:00
|
|
|
headers: {
|
|
|
|
|
Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
2023-05-17 13:13:30 -04:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2023-12-19 14:33:38 -05:00
|
|
|
return body.publicKey;
|
2023-06-19 17:29:22 -04:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
ActivityPub.sign = async (uid, url, payload) => {
|
|
|
|
|
// 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`;
|
|
|
|
|
let digest = null;
|
|
|
|
|
|
|
|
|
|
let headers = '(request-target) host date';
|
|
|
|
|
let signed_string = `(request-target): ${payload ? 'post' : 'get'} ${pathname}\nhost: ${host}\ndate: ${date}`;
|
|
|
|
|
|
|
|
|
|
// Calculate payload hash if payload present
|
|
|
|
|
if (payload) {
|
|
|
|
|
const payloadHash = createHash('sha256');
|
|
|
|
|
payloadHash.update(JSON.stringify(payload));
|
2023-06-23 14:59:47 -04:00
|
|
|
digest = `sha-256=${payloadHash.digest('base64')}`;
|
2023-06-19 17:29:22 -04:00
|
|
|
headers += ' digest';
|
|
|
|
|
signed_string += `\ndigest: ${digest}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Sign string using private key
|
|
|
|
|
let signature = createSign('sha256');
|
2023-12-22 12:58:46 -05:00
|
|
|
signature.update(signed_string);
|
2023-06-19 17:29:22 -04:00
|
|
|
signature.end();
|
2023-12-22 12:58:46 -05:00
|
|
|
signature = signature.sign(key, 'base64');
|
2023-06-19 17:29:22 -04:00
|
|
|
|
|
|
|
|
// Construct signature header
|
|
|
|
|
return {
|
|
|
|
|
date,
|
|
|
|
|
digest,
|
|
|
|
|
signature: `keyId="${keyId}",headers="${headers}",signature="${signature}"`,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
ActivityPub.verify = async (req) => {
|
|
|
|
|
// Break the signature apart
|
|
|
|
|
const { keyId, headers, signature } = req.headers.signature.split(',').reduce((memo, cur) => {
|
|
|
|
|
const split = cur.split('="');
|
|
|
|
|
const key = split.shift();
|
|
|
|
|
const value = split.join('="');
|
|
|
|
|
memo[key] = value.slice(0, -1);
|
|
|
|
|
return memo;
|
|
|
|
|
}, {});
|
|
|
|
|
|
|
|
|
|
// Retrieve public key from remote instance
|
|
|
|
|
const { publicKeyPem } = await ActivityPub.fetchPublicKey(keyId);
|
|
|
|
|
|
|
|
|
|
// Re-construct signature string
|
|
|
|
|
const signed_string = headers.split(' ').reduce((memo, cur) => {
|
|
|
|
|
if (cur === '(request-target)') {
|
2023-08-08 15:33:35 -04:00
|
|
|
memo.push(`${cur}: ${String(req.method).toLowerCase()} ${req.baseUrl}${req.path}`);
|
2023-06-19 17:29:22 -04:00
|
|
|
} else if (req.headers.hasOwnProperty(cur)) {
|
|
|
|
|
memo.push(`${cur}: ${req.headers[cur]}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return memo;
|
|
|
|
|
}, []).join('\n');
|
|
|
|
|
|
|
|
|
|
// Verify the signature string via public key
|
|
|
|
|
try {
|
|
|
|
|
const verify = createVerify('sha256');
|
2023-12-22 12:58:46 -05:00
|
|
|
verify.update(signed_string);
|
2023-06-19 17:29:22 -04:00
|
|
|
verify.end();
|
2023-12-22 12:58:46 -05:00
|
|
|
const verified = verify.verify(publicKeyPem, signature, 'base64');
|
2023-06-19 17:29:22 -04:00
|
|
|
return verified;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2024-01-05 22:45:33 -05:00
|
|
|
ActivityPub.get = async (uid, uri) => {
|
2024-01-12 11:27:55 -05:00
|
|
|
const cacheKey = [uid, uri].join(';');
|
|
|
|
|
if (requestCache.has(cacheKey)) {
|
|
|
|
|
return requestCache.get(cacheKey);
|
|
|
|
|
}
|
|
|
|
|
|
2024-01-05 22:45:33 -05:00
|
|
|
const headers = uid > 0 ? await ActivityPub.sign(uid, uri) : {};
|
2024-01-13 22:27:02 -05:00
|
|
|
winston.verbose(`[activitypub/get] ${uri}`);
|
2024-01-05 22:45:33 -05:00
|
|
|
const { response, body } = await request.get(uri, {
|
2024-01-05 11:39:17 -05:00
|
|
|
headers: {
|
2024-01-05 22:45:33 -05:00
|
|
|
...headers,
|
2024-01-05 11:39:17 -05:00
|
|
|
Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2024-01-05 22:45:33 -05:00
|
|
|
if (!String(response.statusCode).startsWith('2')) {
|
|
|
|
|
winston.error(`[activitypub/get] Received ${response.statusCode} when querying ${uri}`);
|
|
|
|
|
if (body.hasOwnProperty('error')) {
|
|
|
|
|
winston.error(`[activitypub/get] Error received: ${body.error}`);
|
|
|
|
|
}
|
2024-01-08 14:30:09 -05:00
|
|
|
|
|
|
|
|
throw new Error(`[[error:activitypub.get-failed]]`);
|
2024-01-05 22:45:33 -05:00
|
|
|
}
|
|
|
|
|
|
2024-01-12 11:27:55 -05:00
|
|
|
requestCache.set(cacheKey, body);
|
2024-01-05 11:39:17 -05:00
|
|
|
return body;
|
|
|
|
|
};
|
|
|
|
|
|
2023-06-23 14:59:47 -04:00
|
|
|
ActivityPub.send = async (uid, targets, payload) => {
|
|
|
|
|
if (!Array.isArray(targets)) {
|
|
|
|
|
targets = [targets];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const userslug = await user.getUserField(uid, 'userslug');
|
2024-01-05 22:45:33 -05:00
|
|
|
const inboxes = await ActivityPub.resolveInboxes(uid, targets);
|
2023-06-23 14:59:47 -04:00
|
|
|
|
|
|
|
|
payload = {
|
2023-12-13 13:15:03 -05:00
|
|
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
2023-12-22 13:56:18 -05:00
|
|
|
actor: `${nconf.get('url')}/user/${userslug}`,
|
2023-06-23 14:59:47 -04:00
|
|
|
...payload,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await Promise.all(inboxes.map(async (uri) => {
|
2023-12-22 15:53:04 -05:00
|
|
|
const headers = await ActivityPub.sign(uid, uri, payload);
|
2023-12-21 14:46:03 -05:00
|
|
|
const { response } = await request.post(uri, {
|
2023-06-23 14:59:47 -04:00
|
|
|
headers: {
|
2023-12-22 15:53:04 -05:00
|
|
|
...headers,
|
2023-06-23 15:25:00 -04:00
|
|
|
'content-type': 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
2023-06-23 14:59:47 -04:00
|
|
|
},
|
|
|
|
|
body: payload,
|
|
|
|
|
});
|
|
|
|
|
|
2023-12-22 16:02:27 -05:00
|
|
|
if (!String(response.statusCode).startsWith('2')) {
|
2023-06-26 16:15:25 -04:00
|
|
|
// todo: i18n this
|
|
|
|
|
throw new Error('activity-failed');
|
|
|
|
|
}
|
2023-06-23 14:59:47 -04:00
|
|
|
}));
|
|
|
|
|
};
|