UNPKG

@fedify/fedify

Version:

An ActivityPub server framework

1,362 lines • 188 kB
import { Temporal } from "@js-temporal/polyfill"; import "urlpattern-polyfill"; globalThis.addEventListener = () => {}; import { n as version, t as name } from "./deno-C8RG0HM3.mjs"; import { a as instrumentDocumentLoader, b as recordWebFingerHandle, c as recordCircuitBreakerStateChange, d as recordCollectionRequest, f as recordCollectionTotalItems, g as recordInboxActivity, h as recordFanoutRecipients, i as getRemoteHost, l as recordCollectionDispatchDuration, n as getDurationMs, o as isAbortError, r as getFederationMetrics, u as recordCollectionPageItems, v as recordOutboxActivity, x as registerQueueDepthGauge, y as recordOutboxEnqueue } from "./metrics-Pue1x4ta.mjs"; import { t as formatAcceptSignature } from "./accept-CPkZzmGN.mjs"; import { a as importJwk, o as validateCryptoKey, t as exportJwk } from "./key-BKYwmbur.mjs"; import { l as verifyRequest, o as parseRfc9421SignatureInput, u as verifyRequestDetailed } from "./http-PCt0862M.mjs"; import { t as getAuthenticatedDocumentLoader } from "./docloader-YZA0m_3O.mjs"; import { n as kvCache } from "./kv-cache-Ceg0c1wh.mjs"; import { _ as wrapContextLoaderForJsonLd, a as compactJsonLd, c as getNormalizationContextLoader, d as isClearlyMalformedContextReference, f as isInvalidUrlTypeError, l as hasSignature, m as verifyCompactJsonLd, p as signJsonLd, r as assertSafeJsonLd, s as detachSignature, t as InvalidContextReferenceError, u as hasSignatureLike } from "./ld-Dcw2skfn.mjs"; import { n as getKeyOwner, t as doesActorOwnKey } from "./owner-pVJ0Twi7.mjs"; import { r as normalizeOutgoingActivityJsonLd } from "./outgoing-jsonld-L_DbOaFe.mjs"; import { i as verifyObject, n as hasProofLike, r as signObject } from "./proof-pXYODmNV.mjs"; import { t as getNodeInfo } from "./client-ByXmQhYD.mjs"; import { t as nodeInfoToJson } from "./types-J53Kw7so.mjs"; import { n as extractInboxes, r as sendActivity, t as SendActivityError } from "./send-BfAp60BJ.mjs"; import { n as FederationBuilderImpl, t as ACTOR_ALIAS_PREFIX } from "./builder-hivpmhlD.mjs"; import { t as CircuitBreaker } from "./circuit-breaker-CSWsyoef.mjs"; import { t as buildCollectionSynchronizationHeader } from "./collection-Cc3DVAhE.mjs"; import { t as KvKeyCache } from "./keycache-BeU0LCII.mjs"; import { t as acceptsJsonLd } from "./negotiation-DDstyBvc.mjs"; import { t as hasMalformedKnownTemporalLiteral } from "./temporal-HZHBbbk4.mjs"; import { t as createExponentialBackoffPolicy } from "./retry-CXg_MBI-.mjs"; import { getLogger, withContext } from "@logtape/logtape"; import { RouterError } from "@fedify/uri-template"; import { Activity, Collection, CollectionPage, CryptographicKey, Link, Multikey, Object as Object$1, OrderedCollection, OrderedCollectionPage, Tombstone, getTypeId, lookupObject, traverseCollection } from "@fedify/vocab"; import { lookupWebFinger } from "@fedify/webfinger"; import { SpanKind, SpanStatusCode, context, metrics, propagation, trace } from "@opentelemetry/api"; import { FetchError, getDocumentLoader } from "@fedify/vocab-runtime"; import { ATTR_HTTP_REQUEST_HEADER, ATTR_HTTP_REQUEST_METHOD, ATTR_HTTP_RESPONSE_HEADER, ATTR_HTTP_RESPONSE_STATUS_CODE, ATTR_URL_FULL } from "@opentelemetry/semantic-conventions"; import { uniq } from "es-toolkit"; import { DataPointType, MeterProvider as MeterProvider$1, MetricReader } from "@opentelemetry/sdk-metrics"; import { domainToASCII } from "node:url"; //#region src/compat/transformers.ts const logger$1 = getLogger([ "fedify", "compat", "transformers" ]); /** * An activity transformer that assigns a new random ID to an activity if it * does not already have one. This is useful for ensuring that activities * have an ID before they are sent to other servers. * * The generated ID is an origin URI with a fragment which contains an activity * type name with a random UUID: * * ``` * https://example.com/#Follow/12345678-1234-5678-1234-567812345678 * ``` * * @template TContextData The type of the context data. * @param activity The activity to assign an ID to. * @param context The context of the activity. * @return The activity with an ID assigned. * @since 1.4.0 */ function autoIdAssigner(activity, context) { if (activity.id != null) return activity; const id = new URL(`/#${activity.constructor.name}/${crypto.randomUUID()}`, context.origin); logger$1.warn("As the activity to send does not have an id, a new id {id} has been generated for it. However, it is recommended to explicitly set the id for the activity.", { id: id.href }); return activity.clone({ id }); } /** * An activity transformer that dehydrates the actor property of an activity * so that it only contains the actor's URI. For example, suppose we have an * activity like this: * * ```typescript * import { Follow, Person } from "@fedify/vocab"; * const input = new Follow({ * id: new URL("http://example.com/activities/1"), * actor: new Person({ * id: new URL("http://example.com/actors/1"), * name: "Alice", * preferredUsername: "alice", * }), * object: new Person({ * id: new URL("http://example.com/actors/2"), * name: "Bob", * preferredUsername: "bob", * }), * }); * ``` * * The result of applying this transformer would be: * * ```typescript * import { Follow, Person } from "@fedify/vocab"; * const output = new Follow({ * id: new URL("http://example.com/activities/1"), * actor: new URL("http://example.com/actors/1"), * object: new Person({ * id: new URL("http://example.com/actors/2"), * name: "Bob", * preferredUsername: "bob", * }), * }); * ``` * * As some ActivityPub implementations like Threads fail to deal with inlined * actor objects, this transformer can be used to work around this issue. * @template TContextData The type of the context data. * @param activity The activity to dehydrate the actor property of. * @param context The context of the activity. * @returns The dehydrated activity. * @since 1.4.0 */ function actorDehydrator(activity, _context) { if (activity.actorIds.length < 1) return activity; return activity.clone({ actors: activity.actorIds }); } /** * Gets the default activity transformers that are applied to all outgoing * activities. * @template TContextData The type of the context data. * @returns The default activity transformers. * @since 1.4.0 */ function getDefaultActivityTransformers() { return [autoIdAssigner, actorDehydrator]; } //#endregion //#region src/nodeinfo/handler.ts /** * Handles a NodeInfo request. You would not typically call this function * directly, but instead use {@link Federation.handle} method. * @param request The NodeInfo request to handle. * @param parameters The parameters for handling the request. * @returns The response to the request. */ async function handleNodeInfo(_request, { context, nodeInfoDispatcher }) { const promise = nodeInfoDispatcher(context); const json = nodeInfoToJson(promise instanceof Promise ? await promise : promise); return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json; profile=\"http://nodeinfo.diaspora.software/ns/schema/2.1#\"" } }); } /** * Handles a request to `/.well-known/nodeinfo`. You would not typically call * this function directly, but instead use {@link Federation.handle} method. * @param request The request to handle. * @param context The request context. * @returns The response to the request. */ function handleNodeInfoJrd(_request, context) { const links = []; try { links.push({ rel: "http://nodeinfo.diaspora.software/ns/schema/2.1", href: context.getNodeInfoUri().href, type: "application/json; profile=\"http://nodeinfo.diaspora.software/ns/schema/2.1#\"" }); } catch (e) { if (!(e instanceof RouterError)) throw e; } const response = new Response(JSON.stringify({ links }), { headers: { "Content-Type": "application/jrd+json" } }); return Promise.resolve(response); } //#endregion //#region src/federation/bench.ts /** * Metric reader owned by `benchmarkMode`. * @since 2.3.0 */ var BenchmarkMetricReader = class extends MetricReader { onShutdown() { return Promise.resolve(); } onForceFlush() { return Promise.resolve(); } }; /** * Creates the in-process OpenTelemetry meter provider used by benchmark mode. * @returns The meter provider and the metric reader attached to it. * @since 2.3.0 */ function createBenchmarkMeterProvider() { const reader = new BenchmarkMetricReader(); return { meterProvider: new MeterProvider$1({ readers: [reader] }), reader }; } /** * Collects and serializes benchmark-mode metrics from a benchmark reader. * @param reader The benchmark metric reader to collect from. * @returns A server metric snapshot with any collection errors stringified. * @since 2.3.0 */ async function collectBenchmarkMetrics(reader) { const result = await reader.collect(); return { version: 1, source: "server", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), scopeMetrics: serializeScopeMetrics(result.resourceMetrics), errors: result.errors.map((error) => String(error)) }; } /** * Handles `GET /.well-known/fedify/bench/stats`. * @param request The HTTP request to handle. * @param reader The benchmark metric reader to collect from. * @returns A JSON metric snapshot response, or `405 Method Not Allowed`. * @since 2.3.0 */ async function handleBenchmarkStats(request, reader) { if (request.method !== "GET") return new Response("Method not allowed", { status: 405, headers: { "Allow": "GET" } }); return jsonResponse(await collectBenchmarkMetrics(reader)); } /** * Handles `POST /.well-known/fedify/bench/trigger`. * * The handler validates a benchmark trigger request, checks recipients against * server-controlled trigger options, and calls `Context.sendActivity()` to use * the target's normal outbox path. * @param request The HTTP request to handle. * @param context The Fedify context used to resolve actors and send activity. * @param options Server-controlled benchmark trigger delivery options. * @returns A JSON response describing the sent activity, or a validation error. * @since 2.3.0 */ async function handleBenchmarkTrigger(request, context, options = {}) { if (request.method !== "POST") return new Response("Method not allowed", { status: 405, headers: { "Allow": "POST" } }); let json; try { json = await request.json(); } catch { return jsonResponse({ error: "Invalid JSON request body." }, 400); } try { const body = asRecord(json, "request body"); const sender = parseSender(body.sender); const recipients = await parseRecipients(body.recipients, context); const activity = await parseActivity(body.activity, context); if (activity.id == null) throw new BenchmarkTriggerError("activity must have an id."); const activityId = activity.id.href; const inboxes = extractInboxes({ recipients }); const inboxUrls = Object.keys(inboxes); if (inboxUrls.length < 1) throw new BenchmarkTriggerError("No valid recipient inboxes found. The recipients list must not be empty."); const unsafeInboxes = options.allowUnsafeRecipients ? [] : inboxUrls.filter((inbox) => !options.sinks?.has(inbox)); if (unsafeInboxes.length > 0) return jsonResponse({ error: "unsafe_recipient", unsafeInboxes }, 403); await context.sendActivity(sender, recipients, activity); return jsonResponse({ version: 1, activityId, queueCorrelationId: activityId, recipientCount: recipients.length, inboxCount: inboxUrls.length }, 202); } catch (error) { if (error instanceof BenchmarkTriggerError) return jsonResponse({ error: error.message }, error.status); throw error; } } var BenchmarkTriggerError = class extends Error { status; constructor(message, status = 400) { super(message); this.status = status; } }; function parseSender(value) { const sender = asRecord(value, "sender"); if (typeof sender.identifier === "string") return { identifier: sender.identifier }; if (typeof sender.username === "string") return { username: sender.username }; throw new BenchmarkTriggerError("sender must be { identifier } or { username }."); } async function parseRecipients(value, context) { if (!Array.isArray(value)) throw new BenchmarkTriggerError("recipients must be an array."); return await Promise.all(value.map(async (item) => { let object; try { object = await Object$1.fromJsonLd(item, { documentLoader: context.documentLoader, contextLoader: context.contextLoader }); } catch (error) { throw new BenchmarkTriggerError(`Invalid ActivityPub recipient: ${error}`); } if (!isRecipient(object)) throw new BenchmarkTriggerError("each recipient must be an ActivityPub actor."); const recipient = object; if (recipient.id == null || recipient.inboxId == null) throw new BenchmarkTriggerError("each recipient must have id and inbox properties."); return recipient; })); } function isRecipient(value) { return value != null && typeof value === "object" && "id" in value && "inboxId" in value; } async function parseActivity(value, context) { try { return await Activity.fromJsonLd(value, { documentLoader: context.documentLoader, contextLoader: context.contextLoader }); } catch (error) { throw new BenchmarkTriggerError(`Invalid ActivityPub activity: ${error}`); } } function asRecord(value, name) { if (value == null || typeof value !== "object" || Array.isArray(value)) throw new BenchmarkTriggerError(`${name} must be an object.`); return value; } function jsonResponse(body, status = 200) { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); } function serializeScopeMetrics(resourceMetrics) { return resourceMetrics.scopeMetrics.map(serializeScope); } function serializeScope(scopeMetrics) { return { scope: { name: scopeMetrics.scope.name, version: scopeMetrics.scope.version }, metrics: scopeMetrics.metrics.map(serializeMetric) }; } function serializeMetric(metric) { return { name: metric.descriptor.name, description: metric.descriptor.description, unit: metric.descriptor.unit, dataPointType: serializeDataPointType(metric.dataPointType), dataPoints: metric.dataPoints.map((point) => ({ attributes: { ...point.attributes }, startTime: point.startTime, endTime: point.endTime, value: point.value })) }; } function serializeDataPointType(dataPointType) { switch (dataPointType) { case DataPointType.HISTOGRAM: return "histogram"; case DataPointType.EXPONENTIAL_HISTOGRAM: return "exponential_histogram"; case DataPointType.GAUGE: return "gauge"; case DataPointType.SUM: return "sum"; } } //#endregion //#region src/federation/inbox.ts async function routeActivity({ context: ctx, json, originalJson, normalizedActivity, ldSignatureVerified, activity, recipient, inboxListeners, inboxContextFactory, listenerInboxContextFactory, inboxErrorHandler, kv, kvPrefixes, queue, span, meterProvider, tracerProvider, idempotencyStrategy }) { const logger = getLogger([ "fedify", "federation", "inbox" ]); let cacheKey = null; if (activity.id != null) { const inboxContext = inboxContextFactory(recipient, json, activity.id?.href, getTypeId(activity).href); const strategy = idempotencyStrategy ?? "per-inbox"; let keyString; if (typeof strategy === "function") keyString = await strategy(inboxContext, activity); else switch (strategy) { case "global": keyString = activity.id.href; break; case "per-origin": keyString = `${ctx.origin}\n${activity.id.href}`; break; case "per-inbox": keyString = `${ctx.origin}\n${activity.id.href}\n${recipient == null ? "sharedInbox" : `inbox\n${recipient}`}`; break; default: keyString = `${ctx.origin}\n${activity.id.href}`; } if (keyString != null) cacheKey = [...kvPrefixes.activityIdempotence, keyString]; } if (cacheKey != null) { if (await kv.get(cacheKey) === true) { logger.debug("Activity {activityId} has already been processed.", { activityId: activity.id?.href, activity: json, recipient }); span.setStatus({ code: SpanStatusCode.UNSET, message: `Activity ${activity.id?.href} has already been processed.` }); recordInboxActivity(meterProvider, "rejected", getTypeId(activity).href); return "alreadyProcessed"; } } if (activity.actorId == null) { logger.error("Missing actor.", { activity: json }); span.setStatus({ code: SpanStatusCode.ERROR, message: "Missing actor." }); recordInboxActivity(meterProvider, "rejected", getTypeId(activity).href); return "missingActor"; } span.setAttribute("activitypub.actor.id", activity.actorId.href); if (queue != null) { const carrier = {}; propagation.inject(context.active(), carrier); try { await queue.enqueue({ type: "inbox", id: crypto.randomUUID(), baseUrl: ctx.origin, activity: originalJson ?? json, ...normalizedActivity == null ? {} : { normalizedActivity }, ...ldSignatureVerified == null ? {} : { ldSignatureVerified }, identifier: recipient, attempt: 0, started: (/* @__PURE__ */ new Date()).toISOString(), traceContext: carrier }); } catch (error) { logger.error("Failed to enqueue the incoming activity {activityId}:\n{error}", { error, activityId: activity.id?.href, activity: json, recipient }); span.setStatus({ code: SpanStatusCode.ERROR, message: `Failed to enqueue the incoming activity ${activity.id?.href}.` }); throw error; } getFederationMetrics(meterProvider).recordQueueTaskEnqueued({ role: "inbox", queue, activityType: getTypeId(activity).href }, 0); recordInboxActivity(meterProvider, "queued", getTypeId(activity).href); logger.info("Activity {activityId} is enqueued.", { activityId: activity.id?.href, activity: json, recipient }); return "enqueued"; } tracerProvider = tracerProvider ?? trace.getTracerProvider(); return await tracerProvider.getTracer(name, version).startActiveSpan("activitypub.dispatch_inbox_listener", { kind: SpanKind.INTERNAL }, async (span) => { const dispatched = inboxListeners?.dispatchWithClass(activity); if (dispatched == null) { logger.error("Unsupported activity type:\n{activity}", { activity: json, recipient }); span.setStatus({ code: SpanStatusCode.UNSET, message: `Unsupported activity type: ${getTypeId(activity).href}` }); recordInboxActivity(meterProvider, "rejected", getTypeId(activity).href); span.end(); return "unsupportedActivity"; } const { class: cls, listener } = dispatched; span.updateName(`activitypub.dispatch_inbox_listener ${cls.name}`); try { const activityType = getTypeId(activity).href; const started = performance.now(); try { const contextFactory = listenerInboxContextFactory ?? inboxContextFactory; await listener(contextFactory(recipient, contextFactory === inboxContextFactory ? json : originalJson ?? json, activity.id?.href, activityType), activity); } finally { getFederationMetrics(meterProvider).recordInboxProcessingDuration(activityType, getDurationMs(started)); } } catch (error) { try { await inboxErrorHandler?.(ctx, error); } catch (error) { logger.error("An unexpected error occurred in inbox error handler:\n{error}", { error, activityId: activity.id?.href, activity: json, recipient }); } logger.error("Failed to process the incoming activity {activityId}:\n{error}", { error, activityId: activity.id?.href, activity: json, recipient }); span.setStatus({ code: SpanStatusCode.ERROR, message: String(error) }); recordInboxActivity(meterProvider, "rejected", getTypeId(activity).href); span.end(); return "error"; } recordInboxActivity(meterProvider, "processed", getTypeId(activity).href); if (cacheKey != null) await kv.set(cacheKey, true, { ttl: Temporal.Duration.from({ days: 1 }) }); logger.info("Activity {activityId} has been processed.", { activityId: activity.id?.href, activity: json, recipient }); span.end(); return "success"; }); } //#endregion //#region src/federation/handler.ts const rawInboxContextFactorySymbol = Symbol("fedify.rawInboxContextFactory"); function isRemoteContextLoadingFailure$1(error) { return error instanceof Error && typeof error.details === "object" && error.details != null && error.details.code === "loading remote context failed"; } function isPermanentRemoteContextError$1(error) { if (!(error instanceof Error) || error.name !== "jsonld.InvalidUrl") return false; const details = error.details; if (details?.code === "invalid remote context") return true; return isRemoteContextLoadingFailure$1(error) && typeof details?.url === "string" && !URL.canParse(details.url) && isClearlyMalformedContextReference(details.url); } function isInvalidJsonLdError(error) { if (!(error instanceof Error)) return false; const name = error.name; return name === "UnsafeJsonLdError" || error instanceof InvalidContextReferenceError || isPermanentRemoteContextError$1(error) || name === "jsonld.SyntaxError" && !isRemoteContextLoadingFailure$1(error); } function isValidationTypeError(error) { return error instanceof TypeError && (/^(Invalid JSON-LD:|Invalid type:|Unexpected type:|Invalid @id:)/.test(error.message) || isInvalidUrlTypeError(error)); } function isPermanentActivityParseError(error) { return isInvalidJsonLdError(error) || isValidationTypeError(error); } function hasHttpSignatureHeaders(request) { return request.headers.has("Signature") || request.headers.has("Signature-Input"); } function hasObjectIntegrityProof(json) { return typeof json === "object" && json != null && "proof" in json; } /** * Handles an actor request. * @template TContextData The context data to pass to the context. * @param request The HTTP request. * @param parameters The parameters for handling the actor. * @returns A promise that resolves to an HTTP response. */ async function handleActor(request, { identifier, context, actorDispatcher, authorizePredicate, onNotFound, onUnauthorized }) { const logger = getLogger([ "fedify", "federation", "actor" ]); if (actorDispatcher == null) { logger.debug("Actor dispatcher is not set.", { identifier }); return await onNotFound(request); } const actor = await actorDispatcher(context, identifier); if (actor == null) { logger.debug("Actor {identifier} not found.", { identifier }); return await onNotFound(request); } if (authorizePredicate != null) { if (!await authorizePredicate(context, identifier)) return await onUnauthorized(request); } if (actor instanceof Tombstone) { const jsonLd = await actor.toJsonLd(context); return new Response(JSON.stringify(jsonLd), { status: 410, headers: { "Content-Type": "application/activity+json", Vary: "Accept" } }); } const jsonLd = await actor.toJsonLd(context); return new Response(JSON.stringify(jsonLd), { headers: { "Content-Type": "application/activity+json", Vary: "Accept" } }); } /** * Handles an object request. * @template TContextData The context data to pass to the context. * @param request The HTTP request. * @param parameters The parameters for handling the object. * @returns A promise that resolves to an HTTP response. */ async function handleObject(request, { values, context, objectDispatcher, authorizePredicate, onNotFound, onUnauthorized }) { if (objectDispatcher == null) return await onNotFound(request); const object = await objectDispatcher(context, values); if (object == null) return await onNotFound(request); if (authorizePredicate != null) { if (!await authorizePredicate(context, values)) return await onUnauthorized(request); } const jsonLd = await object.toJsonLd(context); return new Response(JSON.stringify(jsonLd), { headers: { "Content-Type": "application/activity+json", Vary: "Accept" } }); } const BUILT_IN_COLLECTION_METRIC_KINDS = new Set([ "inbox", "outbox", "following", "followers", "liked", "featured", "featured_tags" ]); function getCollectionMetricKind(name) { const normalized = name.trim().replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().replace(/\s+/g, "_"); return BUILT_IN_COLLECTION_METRIC_KINDS.has(normalized) ? normalized : "custom"; } function collectionAttributes(base, result, response) { return { ...base, result, ...response == null ? {} : { statusCode: response.status } }; } function recordCollectionMetrics(meterProvider, base, result, options = {}) { const attrs = collectionAttributes(base, result, options.response); recordCollectionRequest(meterProvider, attrs); if (options.dispatchDurationMs != null) recordCollectionDispatchDuration(meterProvider, options.dispatchDurationMs, attrs); if (options.itemCount != null) recordCollectionPageItems(meterProvider, options.itemCount, attrs); if (options.totalItems != null) recordCollectionTotalItems(meterProvider, options.totalItems, attrs); } /** * Handles a collection request. * @template TItem The type of items in the collection. * @template TContext The type of the context, extending {@link RequestContext}. * @template TContextData The context data to pass to the `TContext`. * @template TFilter The type of the filter. * @param request The HTTP request. * @param parameters The parameters for handling the collection. * @returns A promise that resolves to an HTTP response. */ async function handleCollection(request, { name: name$1, identifier, uriGetter, filter, filterPredicate, context, collectionCallbacks, tracerProvider, meterProvider, onUnauthorized, onNotFound }) { const spanName = name$1.trim().replace(/\s+/g, "_"); tracerProvider = tracerProvider ?? trace.getTracerProvider(); const tracer = tracerProvider.getTracer(name, version); const cursor = new URL(request.url).searchParams.get("cursor"); const metricBase = { kind: getCollectionMetricKind(name$1), page: cursor != null, dispatcher: "built_in" }; let dispatchDurationMs; let itemCount; let totalItemCount; const finish = (response, result) => { recordCollectionMetrics(meterProvider, metricBase, result, { response, dispatchDurationMs, itemCount, totalItems: totalItemCount }); return response; }; try { if (collectionCallbacks == null) return finish(await onNotFound(request), "not_found"); let collection; const baseUri = uriGetter(identifier); if (cursor == null) { const firstCursor = await collectionCallbacks.firstCursor?.(context, identifier); const totalItems = filter == null ? await collectionCallbacks.counter?.(context, identifier) : void 0; totalItemCount = totalItems == null ? void 0 : Number(totalItems); if (firstCursor == null) { const itemsOrResponse = await tracer.startActiveSpan(`activitypub.dispatch_collection ${spanName}`, { kind: SpanKind.SERVER, attributes: { "activitypub.collection.id": baseUri.href, "activitypub.collection.type": OrderedCollection.typeId.href } }, async (span) => { if (totalItemCount != null) span.setAttribute("activitypub.collection.total_items", totalItemCount); const started = performance.now(); try { const page = await collectionCallbacks.dispatcher(context, identifier, null, filter); dispatchDurationMs = getDurationMs(started); if (page == null) { span.setStatus({ code: SpanStatusCode.ERROR }); return await onNotFound(request); } const items = filterCollectionItems(page.items, name$1, filterPredicate); itemCount = items.length; span.setAttribute("fedify.collection.items", itemCount); return items; } catch (e) { if (dispatchDurationMs == null) dispatchDurationMs = getDurationMs(started); span.setStatus({ code: SpanStatusCode.ERROR, message: String(e) }); throw e; } finally { span.end(); } }); if (itemsOrResponse instanceof Response) return finish(itemsOrResponse, "not_found"); collection = new OrderedCollection({ id: baseUri, totalItems: totalItemCount ?? null, items: itemsOrResponse }); } else { const lastCursor = await collectionCallbacks.lastCursor?.(context, identifier); const first = new URL(context.url); first.searchParams.set("cursor", firstCursor); let last = null; if (lastCursor != null) { last = new URL(context.url); last.searchParams.set("cursor", lastCursor); } collection = new OrderedCollection({ id: baseUri, totalItems: totalItemCount ?? null, first, last }); } } else { const uri = new URL(baseUri); uri.searchParams.set("cursor", cursor); const pageOrResponse = await tracer.startActiveSpan(`activitypub.dispatch_collection_page ${name$1}`, { kind: SpanKind.SERVER, attributes: { "activitypub.collection.id": uri.href, "activitypub.collection.type": OrderedCollectionPage.typeId.href, "fedify.collection.cursor": cursor } }, async (span) => { const started = performance.now(); try { const page = await collectionCallbacks.dispatcher(context, identifier, cursor, filter); dispatchDurationMs = getDurationMs(started); if (page == null) { span.setStatus({ code: SpanStatusCode.ERROR }); return await onNotFound(request); } const items = filterCollectionItems(page.items, name$1, filterPredicate); itemCount = items.length; span.setAttribute("fedify.collection.items", itemCount); return { ...page, items }; } catch (e) { if (dispatchDurationMs == null) dispatchDurationMs = getDurationMs(started); span.setStatus({ code: SpanStatusCode.ERROR, message: String(e) }); throw e; } finally { span.end(); } }); if (pageOrResponse instanceof Response) return finish(pageOrResponse, "not_found"); const { items, prevCursor, nextCursor } = pageOrResponse; let prev = null; if (prevCursor != null) { prev = new URL(context.url); prev.searchParams.set("cursor", prevCursor); } let next = null; if (nextCursor != null) { next = new URL(context.url); next.searchParams.set("cursor", nextCursor); } const partOf = new URL(context.url); partOf.searchParams.delete("cursor"); collection = new OrderedCollectionPage({ id: uri, prev, next, items, partOf }); } if (collectionCallbacks.authorizePredicate != null) { if (!await collectionCallbacks.authorizePredicate(context, identifier)) return finish(await onUnauthorized(request), "unauthorized"); } const jsonLd = await collection.toJsonLd(context); return finish(new Response(JSON.stringify(jsonLd), { headers: { "Content-Type": "application/activity+json", Vary: "Accept" } }), "served"); } catch (e) { recordCollectionMetrics(meterProvider, metricBase, "error", { dispatchDurationMs, itemCount, totalItems: totalItemCount }); throw e; } } /** * Filters collection items based on the provided predicate. * @template TItem The type of items to filter. * @param items The items to filter. * @param collectionName The name of the collection for logging purposes. * @param filterPredicate Optional predicate function to filter items. * @returns The filtered items as Objects, Links, or URLs. */ function filterCollectionItems(items, collectionName, filterPredicate) { const result = []; let logged = false; for (const item of items) { let mappedItem; if (item instanceof Object$1 || item instanceof Link || item instanceof URL) mappedItem = item; else if (item.id == null) continue; else mappedItem = item.id; if (filterPredicate != null && !filterPredicate(item)) { if (!logged) { getLogger([ "fedify", "federation", "collection" ]).warn(`The ${collectionName} collection apparently does not implement filtering. This may result in a large response payload. Please consider implementing filtering for the collection. See also: https://fedify.dev/manual/collections#filtering-by-server`); logged = true; } continue; } result.push(mappedItem); } return result; } function summarizeJsonActivity(json) { if (json == null || typeof json !== "object") return {}; const activity = json; return { activityId: typeof activity.id === "string" ? activity.id : void 0, activityType: typeof activity.type === "string" ? activity.type : void 0 }; } /** * Handles an outbox POST request. * @template TContextData The context data to pass to the context. * @param request The HTTP request. * @param parameters The parameters for handling the request. * @returns A promise that resolves to an HTTP response. * @since 2.2.0 */ async function handleOutbox(request, { identifier, context: ctx, outboxContextFactory, actorDispatcher, authorizePredicate, outboxListeners, outboxErrorHandler, onUnauthorized, onNotFound }) { const logger = getLogger([ "fedify", "federation", "outbox" ]); if (request.bodyUsed) { logger.error("Request body has already been read.", { identifier }); return new Response("Internal server error.", { status: 500, headers: { "Content-Type": "text/plain; charset=utf-8" } }); } else if (request.body?.locked) { logger.error("Request body is locked.", { identifier }); return new Response("Internal server error.", { status: 500, headers: { "Content-Type": "text/plain; charset=utf-8" } }); } if (actorDispatcher == null) { logger.error("Actor dispatcher is not set.", { identifier }); return await onNotFound(request); } if (authorizePredicate != null) { const authorizeContext = ctx.clone(ctx.data); authorizeContext.request = request.clone(); const requestForUnauthorized = authorizeContext.request.clone(); if (!await authorizePredicate(authorizeContext, identifier)) return await onUnauthorized(requestForUnauthorized); } const actor = await actorDispatcher(ctx, identifier); if (actor == null || actor instanceof Tombstone) { logger.error("Actor {identifier} not found.", { identifier }); return await onNotFound(request); } const requestForParsing = request.clone(); let json; try { json = await requestForParsing.json(); } catch (error) { logger.error("Failed to parse JSON:\n{error}", { identifier, error }); const outboxContext = outboxContextFactory(identifier, null, void 0, ""); try { await outboxErrorHandler?.(outboxContext, error); } catch (error) { logger.error("An unexpected error occurred in outbox error handler:\n{error}", { error, identifier }); } return new Response("Invalid JSON.", { status: 400, headers: { "Content-Type": "text/plain; charset=utf-8" } }); } let activity; try { activity = await Activity.fromJsonLd(json, ctx); } catch (error) { const summary = summarizeJsonActivity(json); logger.error("Failed to parse activity:\n{error}", { identifier, ...summary, error }); const outboxContext = outboxContextFactory(identifier, json, summary.activityId, summary.activityType ?? ""); try { await outboxErrorHandler?.(outboxContext, error); } catch (error) { logger.error("An unexpected error occurred in outbox error handler:\n{error}", { error, identifier, ...summary }); } return new Response("Invalid activity.", { status: 400, headers: { "Content-Type": "text/plain; charset=utf-8" } }); } const outboxContext = outboxContextFactory(identifier, json, activity.id?.href, getTypeId(activity).href); const expectedActorId = actor.id ?? ctx.getActorUri(identifier); if (activity.actorIds.length < 1) { const error = /* @__PURE__ */ new Error("The posted activity has no actor."); logger.error("The posted activity has no actor for outbox {identifier}.", { identifier, activityId: activity.id?.href, expectedActorId: expectedActorId.href }); try { await outboxErrorHandler?.(outboxContext, error); } catch (error) { logger.error("An unexpected error occurred in outbox error handler:\n{error}", { error, activityId: activity.id?.href, activityType: getTypeId(activity).href, identifier }); } return new Response(error.message, { status: 400, headers: { "Content-Type": "text/plain; charset=utf-8" } }); } if (!activity.actorIds.every((actorId) => actorId.href === expectedActorId.href)) { const error = /* @__PURE__ */ new Error("The activity actor does not match the outbox owner."); logger.error("The posted activity actor does not match outbox owner {identifier}.", { identifier, activityId: activity.id?.href, expectedActorId: expectedActorId.href, actorIds: activity.actorIds.map((actorId) => actorId.href) }); try { await outboxErrorHandler?.(outboxContext, error); } catch (error) { logger.error("An unexpected error occurred in outbox error handler:\n{error}", { error, activityId: activity.id?.href, activityType: getTypeId(activity).href, identifier }); } return new Response(error.message, { status: 400, headers: { "Content-Type": "text/plain; charset=utf-8" } }); } const dispatched = outboxListeners?.dispatchWithClass(activity); if (dispatched == null) { logger.debug("Unsupported activity type {activityType}.", { identifier, activityId: activity.id?.href, activityType: getTypeId(activity).href }); return new Response("", { status: 202, headers: { "Content-Type": "text/plain; charset=utf-8" } }); } try { await dispatched.listener(outboxContext, activity); } catch (error) { try { await outboxErrorHandler?.(outboxContext, error); } catch (error) { logger.error("An unexpected error occurred in outbox error handler:\n{error}", { error, activityId: activity.id?.href, activityType: getTypeId(activity).href, identifier }); } logger.error("Failed to process the incoming activity {activityId}:\n{error}", { error, activityId: activity.id?.href, activityType: getTypeId(activity).href, identifier }); return new Response("Internal server error.", { status: 500, headers: { "Content-Type": "text/plain; charset=utf-8" } }); } if (!outboxContext.hasDeliveredActivity()) logger.warn("Outbox listener for {identifier} returned without delivering the posted activity; ctx.sendActivity() or ctx.forwardActivity() may have been skipped or resulted in no delivery.", { identifier, activityId: activity.id?.href, activityType: getTypeId(activity).href }); logger.info("Activity {activityId} has been processed in outbox listener.", { activityId: activity.id?.href, activityType: getTypeId(activity).href, identifier }); return new Response("", { status: 202, headers: { "Content-Type": "text/plain; charset=utf-8" } }); } /** * Handles an inbox request for ActivityPub activities. * @template TContextData The context data to pass to the context. * @param request The HTTP request. * @param options The parameters for handling the inbox. * @returns A promise that resolves to an HTTP response. */ async function handleInbox(request, options) { return await (options.tracerProvider ?? trace.getTracerProvider()).getTracer(name, version).startActiveSpan("activitypub.inbox", { kind: options.queue == null ? SpanKind.SERVER : SpanKind.PRODUCER, attributes: { "activitypub.shared_inbox": options.recipient == null } }, async (span) => { if (options.recipient != null) span.setAttribute("fedify.inbox.recipient", options.recipient); try { return await handleInboxInternal(request, options, span); } catch (e) { span.setStatus({ code: SpanStatusCode.ERROR, message: String(e) }); throw e; } finally { span.end(); } }); } /** * Internal function for handling inbox requests with detailed processing. * @template TContextData The context data to pass to the context. * @param request The HTTP request. * @param options The parameters for handling the inbox. * @param span The OpenTelemetry span for tracing. * @returns A promise that resolves to an HTTP response. */ async function handleInboxInternal(request, parameters, span) { const { recipient, context: ctx, inboxContextFactory, kv, kvPrefixes, queue, actorDispatcher, inboxListeners, inboxErrorHandler, unverifiedActivityHandler, onNotFound, signatureTimeWindow, skipSignatureVerification, inboxChallengePolicy, meterProvider, tracerProvider } = parameters; const logger = getLogger([ "fedify", "federation", "inbox" ]); if (actorDispatcher == null) { logger.error("Actor dispatcher is not set.", { recipient }); span.setStatus({ code: SpanStatusCode.ERROR, message: "Actor dispatcher is not set." }); return await onNotFound(request); } else if (recipient != null) { const actor = await actorDispatcher(ctx, recipient); if (actor == null || actor instanceof Tombstone) { logger.error("Actor {recipient} not found.", { recipient }); span.setStatus({ code: SpanStatusCode.ERROR, message: `Actor ${recipient} not found.` }); return await onNotFound(request); } } if (request.bodyUsed) { logger.error("Request body has already been read.", { recipient }); span.setStatus({ code: SpanStatusCode.ERROR, message: "Request body has already been read." }); return new Response("Internal server error.", { status: 500, headers: { "Content-Type": "text/plain; charset=utf-8" } }); } else if (request.body?.locked) { logger.error("Request body is locked.", { recipient }); span.setStatus({ code: SpanStatusCode.ERROR, message: "Request body is locked." }); return new Response("Internal server error.", { status: 500, headers: { "Content-Type": "text/plain; charset=utf-8" } }); } let json; try { json = await request.clone().json(); } catch (error) { logger.error("Failed to parse JSON:\n{error}", { recipient, error }); try { await inboxErrorHandler?.(ctx, error); } catch (error) { logger.error("An unexpected error occurred in inbox error handler:\n{error}", { error, activity: json, recipient }); } span.setStatus({ code: SpanStatusCode.ERROR, message: `Failed to parse JSON:\n${error}` }); return new Response("Invalid JSON.", { status: 400, headers: { "Content-Type": "text/plain; charset=utf-8" } }); } const keyCache = new KvKeyCache(kv, kvPrefixes.publicKey, ctx); const jsonWithoutSig = detachSignature(json); const hasLdSignature = hasSignature(json); const canAttemptAlternateAuthAfterLdSignatureFailure = skipSignatureVerification || hasHttpSignatureHeaders(request) || hasObjectIntegrityProof(jsonWithoutSig); let deferredLdSignatureError = void 0; const respondInvalidActivity = async (error) => { logger.error("Failed to parse activity:\n{error}", { recipient, activity: json, error }); try { await inboxErrorHandler?.(ctx, error); } catch (error) { logger.error("An unexpected error occurred in inbox error handler:\n{error}", { error, activity: json, recipient }); } span.setStatus({ code: SpanStatusCode.ERROR, message: `Failed to parse activity:\n${error}` }); return new Response("Invalid activity.", { status: 400, headers: { "Content-Type": "text/plain; charset=utf-8" } }); }; let compactedJson = json; let compactedJsonWithoutSig = jsonWithoutSig; let ldSigVerified = false; if (hasLdSignature) { try { compactedJson = await compactJsonLd(json, ctx.contextLoader); } catch (error) { if (isInvalidJsonLdError(error)) { logger.error("Failed to parse JSON-LD:\n{error}", { recipient, error }); return new Response("Invalid JSON-LD.", { status: 400, headers: { "Content-Type": "text/plain; charset=utf-8" } }); } if (!canAttemptAlternateAuthAfterLdSignatureFailure) throw error; if (!skipSignatureVerification) deferredLdSignatureError = error; logger.debug("Failed to normalize JSON-LD for Linked Data Signatures; deferring to another authentication path only if it verifies:\n{error}", { recipient, error }); } if (compactedJson !== json) { compactedJsonWithoutSig = detachSignature(compactedJson); try { ldSigVerified = await verifyCompactJsonLd(compactedJson, { contextLoader: ctx.contextLoader, documentLoader: ctx.documentLoader, keyCache, meterProvider, tracerProvider }); } catch (error) { if (error instanceof RangeError && await hasMalformedKnownTemporalLiteral(compactedJsonWithoutSig, ctx.contextLoader)) return await respondInvalidActivity(error); if (isInvalidJsonLdError(error)) { logger.error("Failed to parse JSON-LD:\n{error}", { recipient, error }); return new Response("Invalid JSON-LD.", { status: 400, headers: { "Content-Type": "text/plain; charset=utf-8" } }); } if (!canAttemptAlternateAuthAfterLdSignatureFailure) throw error; if (!skipSignatureVerification) try { await Object$1.fromJsonLd(compactedJson, { contextLoader: getNormalizationContextLoader(ctx.contextLoader), documentLoader: ctx.documentLoader, tracerProvider }); } catch (parseError) { if (parseError instanceof RangeError && await hasMalformedKnownTemporalLiteral(compactedJsonWithoutSig, ctx.contextLoader)) return await respondInvalidActivity(parseError); if (isInvalidJsonLdError(parseError)) { logger.error("Failed to parse JSON-LD:\n{error}", { recipient, error: parseError }); return new Response("Invalid JSON-LD.", { status: 400, headers: { "Content-Type": "text/plain; charset=utf-8" } }); } deferredLdSignatureError = parseError; } ldSigVerified = false; } } } let activity = null; let activityVerified = false; if (ldSigVerified) { logger.debug("Linked Data Signatures are verified.", { recipient, json }); try { activity = await Activity.fromJsonLd(compactedJsonWithoutSig, { ...ctx, contextLoader: getNormalizationContextLoader(ctx.contextLoader) }); } catch (error) { if (error instanceof RangeError && await hasMalformedKnownTemporalLiteral(compactedJsonWithoutSig, ctx.contextLoader)) return await respondInvalidActivity(error); if (!isPermanentActivityParseError(error)) throw error; return await respondInvalidActivity(error); } activityVerified = true; } else { logger.debug("Linked Data Signatures are not verified.", { recipient, json }); try { activity = await verifyObject(Activity, jsonWithoutSig, { contextLoader: wrapContextLoaderForJsonLd(ctx.contextLoader), documentLoader: ctx.documentLoader, keyCache, meterProvider, tracerProvider }); } catch (error) { if (error instanceof RangeError && await hasMalformedKnownTemporalLiteral(jsonWithoutSig, ctx.contextLoader)) return await respondInvalidActivity(error); if (deferredLdSignatureError != null) { logger.debug("Object Integrity Proof fallback did not supersede a deferred Linked Data Signature failure:\n{error}", { recipient, error }); activity = null; } if (!isPermanentActivityParseError(error)) throw error; logger.error("Failed to parse activity:\n{error}", { recipient, activity: json, error }); try { await inboxErrorHandler?.(ctx, error); } catch (error) { logger.error("An unexpected error occurred in inbox error handler:\n{error}", { error, activity: json, recipient }); } span.setStatus({ code: SpanStatusCode.ERROR, message: `Failed to parse activity:\n${error}` }); return new Response("Invalid activity.", { status: 400, headers: { "Content-Type": "text/plain; charset=utf-8" } }); } if (activity == null) logger.debug("Object Integrity Proofs are not verified.", { recipient, activity: json }); else { logger.debug("Object Integrity Proofs are verified.", { recipient, activity: json }); activityVerified = true; } } let httpSigKey = null; let pendingNonceLabel; if (activity == null) { if (!skipSignatureVerification) { const verification = await verifyRequestDetailed(request, { contextLoader: ctx.contextLoader, documentLoader: ctx.documentLoader, timeWindow: signatureTimeWindow, keyCache, meterProvider, tracerProvider }); if (verification.verified === false) { if (deferredLdSignatureError != null) throw deferredLdSignatureError; const reason = verification.reason; const remoteHost = "keyId" in reason && reason.keyId != null ? getRemoteHost(reason.keyId) : void 0; getFederationMetrics(parameters.meterProvider).recordSignatureVerificationFailure(reason.type, remoteHost); logger.error("Failed to verify the request's HTTP Signatures.", { recipient, reason: reason.type, keyId: "keyId" in reason ? reason.keyId?.href : void 0 }); span.setStatus({ code: SpanStatusCode.ERROR, message: `Failed to verify the request's HTTP Signatures.` }); if (unverifiedActivityHandler == null) return await getFailedSignatureResponse(inboxChallengePolicy, kv, kvPrefixes); try { activity = await Activity.fromJsonLd(jsonWithoutSig, ctx); } catch (error) { logger.error("Failed to parse activity:\n{error}", { recipient, activity: json, error }); try { await inboxErrorHandler?.(ctx, error); } catch (error) { logger.error("An unexpected error occurred in inbox error handler:\n{error}", { error, activity: json, recipient }); } return new Response("Invalid activity.", { status: 400, headers: { "Content-Type": "text/plain; charset=utf-8" } }); } if (activity.id != null) span.setAttribute("activitypub.activity.id", activity.id.href); span.setAttribute("activitypub.activity.type", getTypeId(activity).href); const eventAttributes = { "activitypub.activity.json": JSON.stringify(json), "activitypub.activity.verified": false, "ld_signatures.verifi