FoundKey/packages/backend/src/remote/activitypub/request.ts

132 lines
3.9 KiB
TypeScript

import { URL } from 'node:url';
import config from '@/config/index.js';
import { getUserKeypair } from '@/misc/keypair-store.js';
import { User } from '@/models/entities/user.js';
import { UserKeypair } from '@/models/entities/user-keypair.js';
import { getResponse } from '@/misc/fetch.js';
import { createSignedPost, createSignedGet } from './ap-request.js';
import { apRequestChart, federationChart, instanceChart } from '@/services/chart/index.js';
/**
* Post an activity to an inbox. Automatically updates the statistics
* on succeeded or failed delivery attempts.
*
* @param user http-signature user
* @param url The URL of the inbox.
* @param object The Activity or other object to be posted to the inbox.
*/
export async function request(user: { id: User['id'] }, url: string, object: any, keypair: UserKeypair): Promise<void> {
const body = JSON.stringify(object);
const req = createSignedPost({
key: {
privateKeyPem: keypair.privateKey,
keyId: `${config.url}/users/${user.id}#main-key`,
},
url,
body,
additionalHeaders: {
'User-Agent': config.userAgent,
},
});
const { host } = new URL(url);
try {
await getResponse({
url,
method: req.request.method,
headers: req.request.headers,
body,
// don't allow redirects on the inbox
redirect: 'error',
});
instanceChart.requestSent(host, true);
apRequestChart.deliverSucc();
federationChart.deliverd(host, true);
} catch (err) {
instanceChart.requestSent(host, false);
apRequestChart.deliverFail();
federationChart.deliverd(host, false);
throw err;
}
}
/**
* Get AP object with http-signature
* @param user http-signature user
* @param url URL to fetch
*/
export async function signedGet(_url: string, user: { id: User['id'] }): Promise<any> {
let url = _url;
const keypair = await getUserKeypair(user.id);
for (let redirects = 0; redirects < 3; redirects++) {
const req = createSignedGet({
key: {
privateKeyPem: keypair.privateKey,
keyId: `${config.url}/users/${user.id}#main-key`,
},
url,
additionalHeaders: {
'User-Agent': config.userAgent,
},
});
const res = await getResponse({
url,
method: req.request.method,
headers: req.request.headers,
redirect: 'manual',
});
if (res.status >= 300 && res.status < 400) {
// Have been redirected, need to make a new signature.
// Use Location header and fetched URL as the base URL.
url = new URL(res.headers.get('Location'), url).href;
} else {
// Determine that the content type we got back actually is an activitypub object.
//
// This defends against the possibility of a user of a remote instance uploading
// something that looks like an ActivityPub object and thus masquerading as any
// other user on that same instance. It in turn depends on the server not
// returning that content as an ActivityPub MIME type.
//
// Ref: GHSA-jhrq-qvrm-qr36
const isActivitypub = (_contentType) => {
const contentType = _contentType.toLowerCase()
if (contentType.startsWith('application/activity+json')) {
return true;
}
if (contentType.startsWith('application/ld+json')) {
// oh lord, actually parsing the MIME type
// Ref: <urn:ietf:rfc:2045> § 5.1
// Ref: <https://www.iana.org/assignments/media-types/application/ld+json>
let start = contentType.indexOf('profile="');
if (start === -1) return false; // profile is required for our purposes
start += 'profile="'.length;
let end = contentType.indexOf('"', start);
if (end === -1) return false; // malformed MIME type
let profiles = contentType.substring(start, end).split(/\s+/);
if (profiles.includes('https://www.w3.org/ns/activitystreams')) {
return true;
}
}
return false;
};
if (!isActivitypub(res.headers.get('Content-Type'))) {
throw new Error('invalid response content type');
}
return await res.json();
}
}
throw new Error('too many redirects');
}