UNPKG

@fedify/fedify

Version:

An ActivityPub server framework

240 lines (239 loc) 8.06 kB
import "@js-temporal/polyfill"; import "urlpattern-polyfill"; globalThis.addEventListener = () => {}; import { n as version, t as name } from "./deno-C8RG0HM3.mjs"; import { n as getDurationMs, r as getFederationMetrics } from "./metrics-Pue1x4ta.mjs"; import { n as doubleKnock } from "./http-PCt0862M.mjs"; import { getLogger } from "@logtape/logtape"; import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api"; import { FetchError } from "@fedify/vocab-runtime"; //#region src/federation/send.ts /** * Extracts the inbox URLs from recipients. * @param parameters The parameters to extract the inboxes. * See also {@link ExtractInboxesParameters}. * @returns The inboxes as a map of inbox URL to actor URIs. */ function extractInboxes({ recipients, preferSharedInbox, excludeBaseUris }) { const inboxes = {}; for (const recipient of recipients) { let inbox; let sharedInbox = false; if (preferSharedInbox && recipient.endpoints?.sharedInbox != null) { inbox = recipient.endpoints.sharedInbox; sharedInbox = true; } else inbox = recipient.inboxId; if (inbox != null && recipient.id != null) { if (excludeBaseUris != null && excludeBaseUris.some((u) => u.origin === inbox?.origin)) continue; inboxes[inbox.href] ??= { actorIds: /* @__PURE__ */ new Set(), sharedInbox }; inboxes[inbox.href].actorIds.add(recipient.id.href); } } return inboxes; } /** * Sends an {@link Activity} to an inbox. * * @param parameters The parameters for sending the activity. * See also {@link SendActivityParameters}. * @throws {Error} If the activity fails to send. */ function sendActivity(options) { const tracerProvider = options.tracerProvider ?? trace.getTracerProvider(); return tracerProvider.getTracer(name, version).startActiveSpan("activitypub.send_activity", { kind: SpanKind.CLIENT, attributes: { "activitypub.shared_inbox": options.sharedInbox ?? false } }, async (span) => { if (options.activityId != null) span.setAttribute("activitypub.activity.id", options.activityId); if (options.activityType != null) span.setAttribute("activitypub.activity.type", options.activityType); try { await sendActivityInternal({ ...options, tracerProvider }, span); } catch (e) { span.setStatus({ code: SpanStatusCode.ERROR, message: String(e) }); throw e; } finally { span.end(); } }); } const MAX_ERROR_RESPONSE_BODY_BYTES = 1024; function getActivityActorId(activity) { if (!isRecord(activity)) return void 0; return getIdValue(activity.actor); } function getIdValue(value) { if (typeof value === "string" && value !== "") return value; if (value instanceof URL) return value.href; if (Array.isArray(value)) { for (const item of value) { const id = getIdValue(item); if (id != null) return id; } return; } if (isRecord(value)) return getIdValue(value.id); } function isRecord(value) { return typeof value === "object" && value != null; } async function readLimitedResponseBody(response, maxBytes) { if (response.body == null) return ""; const reader = response.body.getReader(); const decoder = new TextDecoder(); const chunks = []; let totalBytes = 0; let truncated = false; try { while (true) { const { done, value } = await reader.read(); if (done) break; if (totalBytes + value.length > maxBytes) { const remaining = maxBytes - totalBytes; if (remaining > 0) chunks.push(decoder.decode(value.slice(0, remaining), { stream: true })); truncated = true; break; } chunks.push(decoder.decode(value, { stream: true })); totalBytes += value.length; } } finally { reader.releaseLock(); } let result = chunks.join(""); if (truncated) result += "… (truncated)"; return result; } async function sendActivityInternal({ activity, activityId, activityType, keys, inbox, headers, specDeterminer, meterProvider, tracerProvider }, span) { const logger = getLogger([ "fedify", "federation", "outbox" ]); const federationMetrics = getFederationMetrics(meterProvider); const started = performance.now(); let deliverySuccess = false; headers = new Headers(headers); headers.set("Content-Type", "application/activity+json"); const request = new Request(inbox, { method: "POST", headers, body: JSON.stringify(activity) }); let rsaKey = null; for (const key of keys) if (key.privateKey.algorithm.name === "RSASSA-PKCS1-v1_5") { rsaKey = key; break; } if (rsaKey == null) logger.warn("No supported key found to sign the request to {inbox}. The request will be sent without a signature. In order to sign the request, at least one RSASSA-PKCS1-v1_5 key must be provided.", { inbox: inbox.href, keys: keys.map((pair) => ({ keyId: pair.keyId.href, privateKey: pair.privateKey })) }); let response; try { response = rsaKey == null ? await fetch(request) : await doubleKnock(request, rsaKey, { tracerProvider, specDeterminer }); } catch (error) { const transportError = error instanceof FetchError ? error : createFetchError(inbox.href, error); logger.error("Failed to send activity {activityId} to {inbox}:\n{error}", { activityId, inbox: inbox.href, error: transportError }); federationMetrics.recordDelivery(inbox, getDurationMs(started), false, activityType); throw transportError; } try { if (!response.ok) { let error; try { error = await readLimitedResponseBody(response, MAX_ERROR_RESPONSE_BODY_BYTES); } catch (_) { error = ""; } logger.error("Failed to send activity {activityId} to {inbox} ({status} {statusText}):\n{error}", { activityId, inbox: inbox.href, status: response.status, statusText: response.statusText, error }); throw new SendActivityError(inbox, response.status, `Failed to send activity ${activityId} to ${inbox.href} (${response.status} ${response.statusText}):\n${error}`, error, response.headers); } deliverySuccess = true; const eventAttributes = { "activitypub.inbox.url": inbox.href, "activitypub.activity.id": activityId ?? "" }; if (activityType != null) eventAttributes["activitypub.activity.type"] = activityType; const actorId = getActivityActorId(activity); if (actorId != null) eventAttributes["activitypub.actor.id"] = actorId; span.addEvent("activitypub.activity.sent", eventAttributes); } finally { federationMetrics.recordDelivery(inbox, getDurationMs(started), deliverySuccess, activityType); } } function createFetchError(url, cause) { const error = new FetchError(url, cause instanceof Error ? cause.message : String(cause)); error.cause = cause; return error; } /** * An error that is thrown when an activity fails to send to a remote inbox. * It contains structured information about the failure, including the HTTP * status code, the inbox URL, and the response body. * @since 2.0.0 */ var SendActivityError = class extends Error { /** * The inbox URL that the activity was being sent to. */ inbox; /** * The HTTP status code returned by the inbox. */ statusCode; /** * The response body from the inbox, if any. Note that this may be * truncated to a maximum of 1 KiB to prevent excessive memory consumption * when remote servers return large error pages (e.g., Cloudflare error pages). * If truncated, the string will end with `"… (truncated)"`. */ responseBody; /** * The response headers from the inbox. * @since 2.3.0 */ responseHeaders; /** * Creates a new {@link SendActivityError}. * @param inbox The inbox URL. * @param statusCode The HTTP status code. * @param message The error message. * @param responseBody The response body. * @param responseHeaders The response headers. */ constructor(inbox, statusCode, message, responseBody, responseHeaders) { super(message); this.name = "SendActivityError"; this.inbox = inbox; this.statusCode = statusCode; this.responseBody = responseBody; this.responseHeaders = new Headers(responseHeaders); } }; //#endregion export { extractInboxes as n, sendActivity as r, SendActivityError as t };