UNPKG

@fedify/fedify

Version:

An ActivityPub server framework

1,337 lines • 146 kB
import { Temporal } from "@js-temporal/polyfill"; import "urlpattern-polyfill"; globalThis.addEventListener = () => {}; import { n as RouterError } from "./router-CrMLXoOr.mjs"; import { n as version, t as name } from "./deno-DMg4SgCb.mjs"; import { t as formatAcceptSignature } from "./accept-CPkZzmGN.mjs"; import { a as importJwk, o as validateCryptoKey, t as exportJwk } from "./key-BAQuZEU1.mjs"; import { l as verifyRequest, o as parseRfc9421SignatureInput, u as verifyRequestDetailed } from "./http-C_edJspG.mjs"; import { t as getAuthenticatedDocumentLoader } from "./docloader-Da15YRxG.mjs"; import { n as kvCache } from "./kv-cache-DYsF2MhP.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-tusP_XxG.mjs"; import { n as getKeyOwner, t as doesActorOwnKey } from "./owner-DRHNR5YO.mjs"; import { r as normalizeOutgoingActivityJsonLd } from "./outgoing-jsonld-CNmZLixq.mjs"; import { i as verifyObject, n as hasProofLike, r as signObject } from "./proof-DLhLRv3m.mjs"; import { t as getNodeInfo } from "./client-D_1QpnWt.mjs"; import { t as nodeInfoToJson } from "./types-J53Kw7so.mjs"; import { t as FederationBuilderImpl } from "./builder-CaVN56-q.mjs"; import { t as buildCollectionSynchronizationHeader } from "./collection-D-HqUuA2.mjs"; import { t as KvKeyCache } from "./keycache-EGATflN-.mjs"; import { t as acceptsJsonLd } from "./negotiation-SQvQgUqe.mjs"; import { t as hasMalformedKnownTemporalLiteral } from "./temporal-LL61Ddf2.mjs"; import { t as createExponentialBackoffPolicy } from "./retry-v_sGLH1d.mjs"; import { n as extractInboxes, r as sendActivity, t as SendActivityError } from "./send-C7tim5U9.mjs"; 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, propagation, trace } from "@opentelemetry/api"; import { uniq } from "es-toolkit"; import { FetchError, getDocumentLoader } from "@fedify/vocab-runtime"; import { getLogger, withContext } from "@logtape/logtape"; 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 { 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/inbox.ts async function routeActivity({ context: ctx, json, originalJson, normalizedActivity, ldSignatureVerified, activity, recipient, inboxListeners, inboxContextFactory, listenerInboxContextFactory, inboxErrorHandler, kv, kvPrefixes, queue, span, 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.` }); return "alreadyProcessed"; } } if (activity.actorId == null) { logger.error("Missing actor.", { activity: json }); span.setStatus({ code: SpanStatusCode.ERROR, message: "Missing actor." }); 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; } 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}` }); span.end(); return "unsupportedActivity"; } const { class: cls, listener } = dispatched; span.updateName(`activitypub.dispatch_inbox_listener ${cls.name}`); try { const contextFactory = listenerInboxContextFactory ?? inboxContextFactory; await listener(contextFactory(recipient, contextFactory === inboxContextFactory ? json : originalJson ?? json, activity?.id?.href, getTypeId(activity).href), activity); } 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) }); span.end(); return "error"; } 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:)/.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" } }); } /** * 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, 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"); if (collectionCallbacks == null) return await onNotFound(request); 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; 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 (totalItems != null) span.setAttribute("activitypub.collection.total_items", Number(totalItems)); try { const page = await collectionCallbacks.dispatcher(context, identifier, null, filter); if (page == null) { span.setStatus({ code: SpanStatusCode.ERROR }); return await onNotFound(request); } const { items } = page; span.setAttribute("fedify.collection.items", items.length); return items; } catch (e) { span.setStatus({ code: SpanStatusCode.ERROR, message: String(e) }); throw e; } finally { span.end(); } }); if (itemsOrResponse instanceof Response) return itemsOrResponse; collection = new OrderedCollection({ id: baseUri, totalItems: totalItems == null ? null : Number(totalItems), items: filterCollectionItems(itemsOrResponse, name$1, filterPredicate) }); } 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: totalItems == null ? null : Number(totalItems), 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) => { try { const page = await collectionCallbacks.dispatcher(context, identifier, cursor, filter); if (page == null) { span.setStatus({ code: SpanStatusCode.ERROR }); return await onNotFound(request); } span.setAttribute("fedify.collection.items", page.items.length); return page; } catch (e) { span.setStatus({ code: SpanStatusCode.ERROR, message: String(e) }); throw e; } finally { span.end(); } }); if (pageOrResponse instanceof Response) return pageOrResponse; 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: filterCollectionItems(items, name$1, filterPredicate), partOf }); } if (collectionCallbacks.authorizePredicate != null) { if (!await collectionCallbacks.authorizePredicate(context, identifier)) return await onUnauthorized(request); } const jsonLd = await collection.toJsonLd(context); return new Response(JSON.stringify(jsonLd), { headers: { "Content-Type": "application/activity+json", Vary: "Accept" } }); } /** * 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, 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, 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, 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, tracerProvider }); if (verification.verified === false) { if (deferredLdSignatureError != null) throw deferredLdSignatureError; const reason = verification.reason; 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.verified": ldSigVerified, "http_signatures.verified": false, "http_signatures.key_id": "keyId" in reason ? reason.keyId?.href ?? "" : "", "http_signatures.failure_reason": reason.type }; if (reason.type === "keyFetchError") if ("status" in reason.result) eventAttributes["http_signatures.key_fetch_status"] = reason.result.status; else eventAttributes["http_signatures.key_fetch_error"] = reason.result.error.name || reason.result.error.constructor.name || "Error"; span.addEvent("activitypub.activity.received", eventAttributes); let response; try { response = await unverifiedActivityHandler(ctx, activity, reason); } catch (error) { logger.error("An unexpected error occurred in unverified activity handler:\n{error}", { error, activity: json, recipient }); try { await inboxErrorHandler?.(ctx, error); } catch (error) { logger.error("An unexpected error occurred in inbox error handler:\n{error}", { error, activity: json, recipient }); } return await getFailedSignatureResponse(inboxChallengePolicy, kv, kvPrefixes); } if (response instanceof Response) return response; return await getFailedSignatureResponse(inboxChallengePolicy, kv, kvPrefixes); } else { if (inboxChallengePolicy?.enabled && inboxChallengePolicy.requestNonce) pendingNonceLabel = verification.signatureLabel; logger.debug("HTTP Signatures are verified.", { recipient }); activityVerified = true; } httpSigKey = verification.key; } try { activity = await Activity.fromJsonLd(jsonWithoutSig, { ...ctx, contextLoader: wrapContextLoaderForJsonLd(ctx.contextLoader) }); } catch (error) { if (!isPermanentActivityParseError(error)) throw error; return await respondInvalidActivity(error); } } if (activity.id != null) span.setAttribute("activitypub.activity.id", activity.id.href); span.setAttribute("activitypub.activity.type", getTypeId(activity).href); span.addEvent("activitypub.activity.received", { "activitypub.activity.json": JSON.stringify(json), "activitypub.activity.verified": activityVerified, "ld_signatures.verified": ldSigVerified, "http_signatures.verified": httpSigKey != null, "http_signatures.key_id": httpSigKey?.id?.href ?? "" }); if (httpSigKey != null && !await doesActorOwnKey(activity, httpSigKey, ctx)) { if (deferredLdSignatureError != null) throw deferredLdSignatureError; logger.error("The signer ({keyId}) and the actor ({actorId}) do not match.", { activity: json, recipient, keyId: httpSigKey.id?.href, actorId: activity.actorId?.href }); span.setStatus({ code: SpanStatusCode.ERROR, message: `The signer (${httpSigKey.id?.href}) and the actor (${activity.actorId?.href}) do not match.` }); return new Response("The signer and the actor do not match.", { status: 401, headers: { "Content-Type": "text/plain; charset=utf-8" } }); } if (pendingNonceLabel != null) { if (!await verifySignatureNonce(request, kv, kvPrefixes.acceptSignatureNonce, pendingNonceLabel)) { logger.error("Signature nonce verification failed (missing, expired, or replayed).", { recipient }); return await getFailedSignatureResponse(inboxChallengePolicy, kv, kvPrefixes); } } const routeResult = await routeActivity({ context: ctx, json, originalJson: json, normalizedActivity: hasLdSignature && compactedJson !== json ? compactedJson : void 0, ldSignatureVerified: hasLdSignature ? ldSigVerified : void 0, activity, recipient, inboxListeners, inboxContextFactory, listenerInboxContextFactory: ldSigVerified ? inboxContextFactory[rawInboxContextFactorySymbol] : void 0, inboxErrorHandler, kv, kvPrefixes, queue, span, tracerProvider, idempotencyStrategy: parameters.idempotencyStrategy }); if (routeResult === "alreadyProcessed") return new Response(`Activity <${activity.id}> has already been processed.`, { status: 202, headers: { "Content-Type": "text/plain; charset=utf-8" } }); else if (routeResult === "missingActor") return new Response("Missing actor.", { status: 400, headers: { "Content-Type": "text/plain; charset=utf-8" } }); else if (routeResult === "enqueued") return new Response("Activity is enqueued.", { status: 202, headers: { "Content-Type": "text/plain; charset=utf-8" } }); else if (routeResult === "unsupportedActivity") return new Response("", { status: 202, headers: { "Content-Type": "text/plain; charset=utf-8" } }); else if (routeResult === "error") return new Response("Internal server error.", { status: 500, headers: { "Content-Type": "text/plain; charset=utf-8" } }); else return new Response("", { status: 202, headers: { "Content-Type": "text/plain; charset=utf-8" } }); } /** * Handles a custom collection request. * @template TItem The type of items in the collection. * @template TParam The parameter names of the requested URL. * @template TContext The type of the context, extending {@link RequestContext}. * @template TContextData The context data to pass to the `TContext`. * @param request The HTTP request. * @param handleParams Parameters for handling the collection. * @returns A promise that resolves to an HTTP response. * @since 1.8.0 */ const handleCustomCollection = exceptWrapper(_handleCustomCollection); async function _handleCustomCollection(request, { name, values, context, tracerProvider, collectionCallbacks: callbacks, filterPredicate }) { verifyDefined(callbacks); await authIfNeeded(context, values, callbacks); const cursor = new URL(request.url).searchParams.get("cursor"); return await new CustomCollectionHandler(name, values, context, callbacks, tracerProvider, Collection, CollectionPage, filterPredicate).fetchCollection(cursor).toJsonLd().then(respondAsActivity); } /** * Handles an ordered collection request. * @template TItem The type of items in the collection. * @template TParam The parameter names of the requested URL. * @template TContext The type of the context, extending {@link RequestContext}. * @template TContextData The context data to pass to the `TContext`. * @param request The HTTP request. * @param handleParams Parameters for handling the collection. * @returns A promise that resolves to an HTTP response. * @since 1.8.0 */ const handleOrderedCollection = exceptWrapper(_handleOrderedCollection); async function _handleOrderedCollection(request, { name, values, context, tracerProvider, collectionCallbacks: callbacks, filterPredicate }) { verifyDefined(callbacks); await authIfNeeded(context, values, callbacks); const cursor = new URL(request.url).searchParams.get("cursor"); return await new CustomCollectionHandler(name, values, context, callbacks, tracerProvider, OrderedCollection, OrderedCollectionPage, filterPredicate).fetchCollection(cursor).toJsonLd().then(respondAsActivity); } /** * Handling custom collections with support for pagination and filtering. * The main flow is on `getCollection`, `dispatch`. * * @template TItem The type of items in the collection. * @template TParam The parameter names of the requested URL. * @template TContext The type of the context. {@link Context} or {@link RequestContext}. * @template TContextData The context data to pass to the `TContext`. * @template TCollection The type of the collection, extending {@link Collection}. * @template TCollectionPage The type of the collection page, extending {@link CollectionPage}. * @since 1.8.0 */ var CustomCollectionHandler = class { /** * The tracer for telemetry. * @type {Tracer} */ #tracer; /** * The ID of the collection. * @type {URL} */ #id; /** * Store total count of items in the collection. * Use `this.totalItems` to access the total items count. * It is a promise because it may require an asynchronous operation to count items. * @type {Promise<number | null> | undefined} */ #totalItems = void 0; /** * The first cursor for pagination. * It is a promise because it may require an asynchronous operation to get the first cursor. * @type {Promise<string | null> | undefined} */ #dispatcher; #collection = null; /** * Creates a new CustomCollection instance. * @param name The name of the collection. * @param values The parameter values for the collection. * @param context The request context. * @param callbacks The collection callbacks. * @param tracerProvider The tracer provider for telemetry. * @param Collection The Collection constructor. * @param CollectionPage The CollectionPage constructor. * @param filterPredicate Optional filter predicate for items. */ constructor(name$2, values, context, callbacks, tracerProvider = trace.getTracerProvider(), Collection, CollectionPage, filterPredicate) { this.name = name$2; this.values = values; this.context = context; this.callbacks = callbacks; this.tracerProvider = tracerProvider; this.Collection = Collection; this.CollectionPage = CollectionPage; this.filterPredicate = filterPredicate; this.name = this.name.trim().replace(/\s+/g, "_"); this.#tracer = this.tracerProvider.getTracer(name, version); this.#id = new URL(this.context.url); this.#dispatcher = callbacks.dispatcher.bind(callbacks); } /** * Converts the collection to JSON-LD format. * @returns A promise that resolves to the JSON-LD representation. */ async toJsonLd() { return (await this.collection).toJsonLd(this.context); } /** * Fetches the collection with optional cursor for pagination. * This method is defined for method chaining and to show processing flow properly. * So it is no problem to call `toJsonLd` directly on the instance. * @param cursor The cursor for pagination, or null for the first page. * @returns The CustomCollection instance for method chaining. */ fetchCollection(cursor = null) { this.#collection = this.getCollection(cursor); return this; } /** * Gets the collection or collection page based on the cursor. * @param {string | null} cursor The cursor for pagination, or null for the main collection. * @returns {Promise<TCollection | TCollectionPage>} A promise that resolves to a Collection or CollectionPage. */ async getCollection(cursor = null) { if (cursor !== null) { const props = await this.getPageProps(cursor); return new this.CollectionPage(props); } const firstCursor = await this.firstCursor; const props = typeof firstCursor === "string" ? await this.getProps(firstCursor) : await this.getPropsWithoutCursor(); return new this.Collection(props); } /** * Gets the properties for a collection page. * Returns the page properties including items, previous and next cursors. * @param {string} cursor The cursor for the page. * @returns A promise that resolves to the page properties. */ async getPageProps(cursor) { const id = this.#id; const pages = await this.getPages({ cursor });