2023-05-29 17:42:44 -04:00
|
|
|
'use strict';
|
|
|
|
|
|
2023-06-23 14:59:47 -04:00
|
|
|
const { generateKeyPairSync } = require('crypto');
|
2023-06-28 14:59:39 -04:00
|
|
|
const nconf = require('nconf');
|
2023-12-11 14:35:04 -05:00
|
|
|
const validator = require('validator');
|
2024-04-29 16:16:07 -04:00
|
|
|
const cheerio = require('cheerio');
|
2023-05-29 17:42:44 -04:00
|
|
|
|
2024-04-29 16:16:07 -04:00
|
|
|
const meta = require('../meta');
|
2024-04-09 23:58:00 +02:00
|
|
|
const posts = require('../posts');
|
2024-04-10 18:50:41 +02:00
|
|
|
const categories = require('../categories');
|
2023-12-19 14:33:38 -05:00
|
|
|
const request = require('../request');
|
2023-06-19 17:29:22 -04:00
|
|
|
const db = require('../database');
|
2023-06-16 11:26:25 -04:00
|
|
|
const ttl = require('../cache/ttl');
|
2023-06-28 14:59:39 -04:00
|
|
|
const user = require('../user');
|
2024-04-08 20:06:26 +02:00
|
|
|
const activitypub = require('.');
|
2023-06-16 11:26:25 -04:00
|
|
|
|
2024-06-07 12:13:28 -04:00
|
|
|
const webfingerRegex = /^(@|acct:)?[\w-]+@.+$/;
|
2024-06-04 12:31:13 -04:00
|
|
|
const webfingerCache = ttl({
|
|
|
|
|
max: 5000,
|
|
|
|
|
ttl: 1000 * 60 * 60 * 24, // 24 hours
|
|
|
|
|
});
|
2023-06-16 11:26:25 -04:00
|
|
|
|
2023-05-29 17:42:44 -04:00
|
|
|
const Helpers = module.exports;
|
|
|
|
|
|
2024-01-05 11:38:26 -05:00
|
|
|
Helpers.isUri = (value) => {
|
|
|
|
|
if (typeof value !== 'string') {
|
|
|
|
|
value = String(value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return validator.isURL(value, {
|
|
|
|
|
require_protocol: true,
|
|
|
|
|
require_host: true,
|
2024-04-25 20:05:53 +02:00
|
|
|
protocols: activitypub._constants.acceptedProtocols,
|
2024-01-05 11:38:26 -05:00
|
|
|
require_valid_protocol: true,
|
|
|
|
|
require_tld: false, // temporary — for localhost
|
|
|
|
|
});
|
|
|
|
|
};
|
2024-01-04 16:23:09 -05:00
|
|
|
|
2024-05-07 10:11:36 -04:00
|
|
|
Helpers.isWebfinger = (value) => {
|
|
|
|
|
// N.B. returns normalized handle, so truthy check!
|
|
|
|
|
if (webfingerRegex.test(value) && !Helpers.isUri(value)) {
|
|
|
|
|
if (value.startsWith('@')) {
|
|
|
|
|
return value.slice(1);
|
|
|
|
|
} else if (value.startsWith('acct:')) {
|
|
|
|
|
return value.slice(5);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
|
2023-05-29 17:42:44 -04:00
|
|
|
Helpers.query = async (id) => {
|
2024-04-25 12:59:05 +02:00
|
|
|
const isUri = Helpers.isUri(id);
|
2024-04-25 17:16:30 +02:00
|
|
|
// username@host ids use acct: URI schema
|
|
|
|
|
const uri = isUri ? new URL(id) : new URL(`acct:${id}`);
|
|
|
|
|
// JS doesn't parse anything other than protocol and pathname from acct: URIs, so we need to just split id manually
|
|
|
|
|
const [username, hostname] = isUri ? [uri.pathname || uri.href, uri.host] : id.split('@');
|
|
|
|
|
if (!username || !hostname) {
|
2023-05-29 17:42:44 -04:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2024-06-04 12:31:13 -04:00
|
|
|
const cached = webfingerCache.get(id);
|
|
|
|
|
if (cached !== undefined) {
|
|
|
|
|
return cached;
|
2023-06-16 11:26:25 -04:00
|
|
|
}
|
|
|
|
|
|
2024-04-25 17:16:30 +02:00
|
|
|
const query = new URLSearchParams({ resource: uri });
|
2024-04-25 12:59:05 +02:00
|
|
|
|
2023-05-29 17:42:44 -04:00
|
|
|
// Make a webfinger query to retrieve routing information
|
2024-03-11 14:41:05 -04:00
|
|
|
let response;
|
|
|
|
|
let body;
|
|
|
|
|
try {
|
2024-04-25 13:16:05 +02:00
|
|
|
({ response, body } = await request.get(`https://${hostname}/.well-known/webfinger?${query}`));
|
2024-03-11 14:41:05 -04:00
|
|
|
} catch (e) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2023-05-29 17:42:44 -04:00
|
|
|
|
2023-12-19 14:33:38 -05:00
|
|
|
if (response.statusCode !== 200 || !body.hasOwnProperty('links')) {
|
2023-05-29 17:42:44 -04:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Parse links to find actor endpoint
|
2024-04-08 20:06:26 +02:00
|
|
|
let actorUri = body.links.filter(link => activitypub._constants.acceptableTypes.includes(link.type) && link.rel === 'self');
|
2023-05-29 17:42:44 -04:00
|
|
|
if (actorUri.length) {
|
|
|
|
|
actorUri = actorUri.pop();
|
|
|
|
|
({ href: actorUri } = actorUri);
|
|
|
|
|
}
|
|
|
|
|
|
2024-03-05 09:56:15 -05:00
|
|
|
const { subject, publicKey } = body;
|
|
|
|
|
const payload = { subject, username, hostname, actorUri, publicKey };
|
2023-06-19 17:29:22 -04:00
|
|
|
|
2024-04-25 17:16:30 +02:00
|
|
|
const claimedId = new URL(subject).pathname;
|
2024-03-05 09:56:15 -05:00
|
|
|
webfingerCache.set(claimedId, payload);
|
|
|
|
|
if (claimedId !== id) {
|
|
|
|
|
webfingerCache.set(id, payload);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return payload;
|
2023-06-19 17:29:22 -04:00
|
|
|
};
|
|
|
|
|
|
2024-02-05 16:57:17 -05:00
|
|
|
Helpers.generateKeys = async (type, id) => {
|
2024-06-07 11:56:58 -04:00
|
|
|
// winston.verbose(`[activitypub] Generating RSA key-pair for ${type} ${id}`);
|
2023-06-19 17:29:22 -04:00
|
|
|
const {
|
|
|
|
|
publicKey,
|
|
|
|
|
privateKey,
|
|
|
|
|
} = generateKeyPairSync('rsa', {
|
|
|
|
|
modulusLength: 2048,
|
|
|
|
|
publicKeyEncoding: {
|
|
|
|
|
type: 'spki',
|
|
|
|
|
format: 'pem',
|
|
|
|
|
},
|
|
|
|
|
privateKeyEncoding: {
|
|
|
|
|
type: 'pkcs8',
|
|
|
|
|
format: 'pem',
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2024-02-05 16:57:17 -05:00
|
|
|
await db.setObject(`${type}:${id}:keys`, { publicKey, privateKey });
|
2023-06-19 17:29:22 -04:00
|
|
|
return { publicKey, privateKey };
|
2023-05-29 17:42:44 -04:00
|
|
|
};
|
2023-06-28 14:59:39 -04:00
|
|
|
|
2024-02-05 16:57:17 -05:00
|
|
|
Helpers.resolveLocalId = async (input) => {
|
2024-01-04 16:23:09 -05:00
|
|
|
if (Helpers.isUri(input)) {
|
2024-04-09 23:58:00 +02:00
|
|
|
const { host, pathname, hash } = new URL(input);
|
2023-12-11 14:35:04 -05:00
|
|
|
|
|
|
|
|
if (host === nconf.get('url_parsed').host) {
|
2024-02-05 16:57:17 -05:00
|
|
|
const [prefix, value] = pathname.replace(nconf.get('relative_path'), '').split('/').filter(Boolean);
|
|
|
|
|
|
2024-04-09 23:58:00 +02:00
|
|
|
let activityData = {};
|
|
|
|
|
if (hash.startsWith('#activity')) {
|
2024-06-11 22:55:45 +02:00
|
|
|
const [, activity, data, timestamp] = hash.split('/', 4);
|
|
|
|
|
activityData = { activity, data, timestamp };
|
2024-04-09 23:58:00 +02:00
|
|
|
}
|
|
|
|
|
|
2024-04-15 14:40:26 -04:00
|
|
|
// https://bb.devnull.land/cid/2#activity/follow/activitypub@community.nodebb.org│
|
2024-02-05 16:57:17 -05:00
|
|
|
switch (prefix) {
|
|
|
|
|
case 'uid':
|
2024-04-09 23:58:00 +02:00
|
|
|
return { type: 'user', id: value, ...activityData };
|
2024-02-05 16:57:17 -05:00
|
|
|
|
|
|
|
|
case 'post':
|
2024-04-09 23:58:00 +02:00
|
|
|
return { type: 'post', id: value, ...activityData };
|
2024-02-05 16:57:17 -05:00
|
|
|
|
2024-04-15 14:40:26 -04:00
|
|
|
case 'cid':
|
2024-02-05 16:57:17 -05:00
|
|
|
case 'category':
|
2024-04-09 23:58:00 +02:00
|
|
|
return { type: 'category', id: value, ...activityData };
|
2024-02-05 16:57:17 -05:00
|
|
|
|
|
|
|
|
case 'user': {
|
|
|
|
|
const uid = await user.getUidByUserslug(value);
|
2024-04-09 23:58:00 +02:00
|
|
|
return { type: 'user', id: uid, ...activityData };
|
2024-02-05 16:57:17 -05:00
|
|
|
}
|
2024-01-29 16:59:13 -05:00
|
|
|
}
|
|
|
|
|
|
2024-04-09 23:58:00 +02:00
|
|
|
return { type: null, id: null, ...activityData };
|
2023-12-11 14:35:04 -05:00
|
|
|
}
|
2024-02-09 11:15:03 -05:00
|
|
|
|
|
|
|
|
return { type: null, id: null };
|
2024-02-28 12:54:54 -05:00
|
|
|
} else if (String(input).indexOf('@') !== -1) { // Webfinger
|
2024-04-26 11:30:08 -04:00
|
|
|
input = decodeURIComponent(input);
|
2024-02-05 16:57:17 -05:00
|
|
|
const [slug] = input.replace(/^acct:/, '').split('@');
|
|
|
|
|
const uid = await user.getUidByUserslug(slug);
|
|
|
|
|
return { type: 'user', id: uid };
|
2024-02-01 15:59:29 -05:00
|
|
|
}
|
|
|
|
|
|
2024-02-09 11:15:03 -05:00
|
|
|
return { type: null, id: null };
|
2024-02-01 15:59:29 -05:00
|
|
|
};
|
2024-04-09 23:58:00 +02:00
|
|
|
|
|
|
|
|
Helpers.resolveActor = (type, id) => {
|
|
|
|
|
switch (type) {
|
|
|
|
|
case 'user':
|
|
|
|
|
case 'uid': {
|
|
|
|
|
return `${nconf.get('url')}${id > 0 ? `/uid/${id}` : '/actor'}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case 'category':
|
|
|
|
|
case 'cid': {
|
|
|
|
|
return `${nconf.get('url')}/category/${id}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
throw new Error('[[error:activitypub.invalid-id]]');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Helpers.resolveActivity = async (activity, data, id, resolved) => {
|
|
|
|
|
switch (activity.toLowerCase()) {
|
|
|
|
|
case 'follow': {
|
|
|
|
|
const actor = await Helpers.resolveActor(resolved.type, resolved.id);
|
|
|
|
|
const { actorUri: targetUri } = await Helpers.query(data);
|
|
|
|
|
return {
|
|
|
|
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
|
|
|
actor,
|
|
|
|
|
id,
|
|
|
|
|
type: 'Follow',
|
|
|
|
|
object: targetUri,
|
|
|
|
|
};
|
|
|
|
|
}
|
2024-04-17 19:19:09 +02:00
|
|
|
case 'announce':
|
|
|
|
|
case 'create': {
|
|
|
|
|
const object = await Helpers.resolveObjects(resolved.id);
|
|
|
|
|
// local create activities are assumed to come from the user who created the underlying object
|
|
|
|
|
const actor = object.attributedTo || object.actor;
|
|
|
|
|
return {
|
|
|
|
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
|
|
|
actor,
|
|
|
|
|
id,
|
|
|
|
|
type: 'Create',
|
|
|
|
|
object,
|
|
|
|
|
};
|
|
|
|
|
}
|
2024-04-09 23:58:00 +02:00
|
|
|
default: {
|
|
|
|
|
throw new Error('[[error:activitypub.not-implemented]]');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2024-04-14 02:42:30 +02:00
|
|
|
Helpers.mapToLocalType = (type) => {
|
|
|
|
|
if (type === 'Person') {
|
|
|
|
|
return 'user';
|
|
|
|
|
}
|
|
|
|
|
if (type === 'Group') {
|
|
|
|
|
return 'category';
|
|
|
|
|
}
|
|
|
|
|
if (type === 'Hashtag') {
|
|
|
|
|
return 'tag';
|
|
|
|
|
}
|
|
|
|
|
if (activitypub._constants.acceptedPostTypes.includes(type)) {
|
|
|
|
|
return 'post';
|
|
|
|
|
}
|
|
|
|
|
};
|
2024-04-09 23:58:00 +02:00
|
|
|
|
|
|
|
|
Helpers.resolveObjects = async (ids) => {
|
|
|
|
|
if (!Array.isArray(ids)) {
|
|
|
|
|
ids = [ids];
|
|
|
|
|
}
|
|
|
|
|
const objects = await Promise.all(ids.map(async (id) => {
|
2024-04-17 19:19:09 +02:00
|
|
|
// try to get a local ID first
|
2024-04-09 23:58:00 +02:00
|
|
|
const { type, id: resolvedId, activity, data: activityData } = await Helpers.resolveLocalId(id);
|
2024-04-17 19:19:09 +02:00
|
|
|
// activity data is only resolved for local IDs - so this will be false for remote posts
|
2024-04-09 23:58:00 +02:00
|
|
|
if (activity) {
|
|
|
|
|
return Helpers.resolveActivity(activity, activityData, id, { type, id: resolvedId });
|
|
|
|
|
}
|
|
|
|
|
switch (type) {
|
|
|
|
|
case 'user': {
|
2024-04-10 18:50:41 +02:00
|
|
|
if (!await user.exists(resolvedId)) {
|
|
|
|
|
throw new Error('[[error:activitypub.invalid-id]]');
|
|
|
|
|
}
|
2024-04-09 23:58:00 +02:00
|
|
|
return activitypub.mocks.actors.user(resolvedId);
|
|
|
|
|
}
|
|
|
|
|
case 'post': {
|
|
|
|
|
const post = (await posts.getPostSummaryByPids(
|
|
|
|
|
[resolvedId],
|
|
|
|
|
activitypub._constants.uid,
|
|
|
|
|
{ stripTags: false }
|
|
|
|
|
)).pop();
|
|
|
|
|
if (!post) {
|
2024-04-10 18:50:41 +02:00
|
|
|
throw new Error('[[error:activitypub.invalid-id]]');
|
2024-04-09 23:58:00 +02:00
|
|
|
}
|
|
|
|
|
return activitypub.mocks.note(post);
|
|
|
|
|
}
|
|
|
|
|
case 'category': {
|
2024-04-10 18:50:41 +02:00
|
|
|
if (!await categories.exists(resolvedId)) {
|
|
|
|
|
throw new Error('[[error:activitypub.invalid-id]]');
|
|
|
|
|
}
|
|
|
|
|
return activitypub.mocks.actors.category(resolvedId);
|
2024-04-09 23:58:00 +02:00
|
|
|
}
|
2024-04-17 19:19:09 +02:00
|
|
|
// if the type is not recognized, assume it's not a local ID and fetch the object from its origin
|
2024-04-09 23:58:00 +02:00
|
|
|
default: {
|
|
|
|
|
return activitypub.get('uid', 0, id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}));
|
|
|
|
|
return objects.length === 1 ? objects[0] : objects;
|
|
|
|
|
};
|
2024-04-29 16:16:07 -04:00
|
|
|
|
|
|
|
|
Helpers.generateTitle = (html) => {
|
|
|
|
|
// Given an html string, generates a more appropriate title if possible
|
|
|
|
|
const $ = cheerio.load(html);
|
|
|
|
|
let title;
|
|
|
|
|
|
|
|
|
|
// Try the first paragraph element
|
|
|
|
|
title = $('h1, h2, h3, h4, h5, h6, title, p, span').first().text();
|
|
|
|
|
|
|
|
|
|
// Fall back to newline splitting (i.e. if no paragraph elements)
|
2024-04-30 11:41:34 -04:00
|
|
|
title = title || html.split('\n').filter(Boolean).shift();
|
2024-04-29 16:16:07 -04:00
|
|
|
|
|
|
|
|
// Split sentences and use only first one
|
2024-04-30 11:41:34 -04:00
|
|
|
const sentences = title
|
|
|
|
|
.split(/(\.|\?|!)\s/)
|
|
|
|
|
.reduce((memo, cur, idx, sentences) => {
|
|
|
|
|
if (idx % 2) {
|
|
|
|
|
memo.push(`${sentences[idx - 1]}${cur}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return memo;
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
if (sentences.length > 1) {
|
|
|
|
|
title = sentences.shift();
|
2024-04-29 16:16:07 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Truncate down if too long
|
|
|
|
|
if (title.length > meta.config.maximumTitleLength) {
|
|
|
|
|
title = `${title.slice(0, meta.config.maximumTitleLength - 3)}...`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return title;
|
|
|
|
|
};
|