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