@fedify/fedify
Version:
An ActivityPub server framework
1,337 lines • 146 kB
JavaScript
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 });