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 { 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 { 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: ยง 5.1 // Ref: 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'); }