@fedify/fedify
Version:
An ActivityPub server framework
1,344 lines (1,335 loc) • 100 kB
JavaScript
import { Temporal } from "@js-temporal/polyfill";
import { URLPattern } from "urlpattern-polyfill";
globalThis.addEventListener = () => {};
import { deno_default, getDocumentLoader, kvCache } from "./docloader-C8mmLN-Y.js";
import { getNodeInfo } from "./client-rv6n0nKR.js";
import { RouterError } from "./router-D_aVZZUc.js";
import { nodeInfoToJson } from "./types-C7C_l-jz.js";
import { Activity, CryptographicKey, Link, Multikey, Object as Object$1, OrderedCollection, OrderedCollectionPage } from "./vocab-dDpPQ0fF.js";
import { lookupWebFinger } from "./lookup-Ozuw-rxN.js";
import { getTypeId } from "./type-D2s5lmbZ.js";
import { exportJwk, importJwk, validateCryptoKey } from "./key-CPSlDkCj.js";
import { verifyRequest } from "./http-BmfI6U39.js";
import { getAuthenticatedDocumentLoader } from "./authdocloader-Banw__l6.js";
import { detachSignature, hasSignature, signJsonLd, verifyJsonLd } from "./ld-D9kk1Uy2.js";
import { doesActorOwnKey, getKeyOwner } from "./owner-DF6Fg4-s.js";
import { signObject, verifyObject } from "./proof-13QPysOU.js";
import { lookupObject, traverseCollection } from "./lookup-DNXR408t.js";
import { routeActivity } from "./inbox-AwdJVoMZ.js";
import { FederationBuilderImpl } from "./builder-EdzpUb9N.js";
import { buildCollectionSynchronizationHeader } from "./collection-Dfb0TPno.js";
import { KvKeyCache } from "./keycache-BPwY_Up0.js";
import { createExponentialBackoffPolicy } from "./retry-BiIhZWgD.js";
import { extractInboxes, sendActivity } from "./send-CaL5Xv1F.js";
import { getLogger, withContext } from "@logtape/logtape";
import { SpanKind, SpanStatusCode, context, propagation, trace } from "@opentelemetry/api";
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 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
* ```
*
* @typeParam 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$1) {
if (activity.id != null) return activity;
const id = new URL(`/#${activity.constructor.name}/${crypto.randomUUID()}`, context$1.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/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/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.
* @typeParam 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.
* @typeParam TContextData The type of the context data.
* @returns The default activity transformers.
* @since 1.4.0
*/
function getDefaultActivityTransformers() {
return [autoIdAssigner, actorDehydrator];
}
//#endregion
//#region 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: context$1, nodeInfoDispatcher }) {
const promise = nodeInfoDispatcher(context$1);
const nodeInfo = promise instanceof Promise ? await promise : promise;
const json = nodeInfoToJson(nodeInfo);
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$1) {
const links = [];
try {
links.push({
rel: "http://nodeinfo.diaspora.software/ns/schema/2.1",
href: context$1.getNodeInfoUri().href,
type: "application/json; profile=\"http://nodeinfo.diaspora.software/ns/schema/2.1#\""
});
} catch (e) {
if (!(e instanceof RouterError)) throw e;
}
const jrd = { links };
const response = new Response(JSON.stringify(jrd), { headers: { "Content-Type": "application/jrd+json" } });
return Promise.resolve(response);
}
//#endregion
//#region vocab/constants.ts
/**
* The special public collection for [public addressing]. *Do not mutate this
* object.*
*
* [public addressing]: https://www.w3.org/TR/activitypub/#public-addressing
*
* @since 0.7.0
*/
const PUBLIC_COLLECTION = new URL("https://www.w3.org/ns/activitystreams#Public");
//#endregion
//#region webfinger/handler.ts
const logger = getLogger([
"fedify",
"webfinger",
"server"
]);
/**
* Handles a WebFinger request. You would not typically call this function
* directly, but instead use {@link Federation.fetch} method.
* @param request The WebFinger request to handle.
* @param parameters The parameters for handling the request.
* @returns The response to the request.
*/
async function handleWebFinger(request, options) {
if (options.tracer == null) return await handleWebFingerInternal(request, options);
return await options.tracer.startActiveSpan("webfinger.handle", { kind: SpanKind.SERVER }, async (span) => {
try {
const response = await handleWebFingerInternal(request, options);
span.setStatus({ code: response.ok ? SpanStatusCode.UNSET : SpanStatusCode.ERROR });
return response;
} catch (error) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: String(error)
});
throw error;
} finally {
span.end();
}
});
}
async function handleWebFingerInternal(request, { context: context$1, host, actorDispatcher, actorHandleMapper, actorAliasMapper, onNotFound, span }) {
if (actorDispatcher == null) return await onNotFound(request);
const resource = context$1.url.searchParams.get("resource");
if (resource == null) return new Response("Missing resource parameter.", { status: 400 });
span?.setAttribute("webfinger.resource", resource);
let resourceUrl;
try {
resourceUrl = new URL(resource);
} catch (e) {
if (e instanceof TypeError) return new Response("Invalid resource URL.", { status: 400 });
throw e;
}
span?.setAttribute("webfinger.resource.scheme", resourceUrl.protocol.replace(/:$/, ""));
if (actorDispatcher == null) {
logger.error("Actor dispatcher is not set.");
return await onNotFound(request);
}
async function mapUsernameToIdentifier(username) {
if (actorHandleMapper == null) {
logger.error("No actor handle mapper is set; use the WebFinger username {username} as the actor's internal identifier.", { username });
return username;
}
const identifier$1 = await actorHandleMapper(context$1, username);
if (identifier$1 == null) {
logger.error("Actor {username} not found.", { username });
return null;
}
return identifier$1;
}
let identifier = null;
const uriParsed = context$1.parseUri(resourceUrl);
if (uriParsed?.type != "actor") {
const match = /^acct:([^@]+)@([^@]+)$/.exec(resource);
if (match == null) {
const result = await actorAliasMapper?.(context$1, resourceUrl);
if (result == null) return await onNotFound(request);
if ("identifier" in result) identifier = result.identifier;
else identifier = await mapUsernameToIdentifier(result.username);
} else {
const portMatch = /:\d+$/.exec(match[2]);
const normalizedHost = portMatch == null ? domainToASCII(match[2].toLowerCase()) : domainToASCII(match[2].substring(0, portMatch.index).toLowerCase()) + portMatch[0];
if (normalizedHost != context$1.url.host && normalizedHost != host) return await onNotFound(request);
else {
identifier = await mapUsernameToIdentifier(match[1]);
resourceUrl = new URL(`acct:${match[1]}@${normalizedHost}`);
}
}
} else identifier = uriParsed.identifier;
if (identifier == null) return await onNotFound(request);
const actor = await actorDispatcher(context$1, identifier);
if (actor == null) {
logger.error("Actor {identifier} not found.", { identifier });
return await onNotFound(request);
}
const links = [{
rel: "self",
href: context$1.getActorUri(identifier).href,
type: "application/activity+json"
}];
for (const url of actor.urls) if (url instanceof Link && url.href != null) links.push({
rel: url.rel ?? "http://webfinger.net/rel/profile-page",
href: url.href.href,
type: url.mediaType == null ? void 0 : url.mediaType
});
else if (url instanceof URL) links.push({
rel: "http://webfinger.net/rel/profile-page",
href: url.href
});
for await (const image of actor.getIcons()) {
if (image.url?.href == null) continue;
const link = {
rel: "http://webfinger.net/rel/avatar",
href: image.url.href.toString()
};
if (image.mediaType != null) link.type = image.mediaType;
links.push(link);
}
const aliases = [];
if (resourceUrl.protocol != "acct:" && actor.preferredUsername != null) {
aliases.push(`acct:${actor.preferredUsername}@${host ?? context$1.url.host}`);
if (host != null && host !== context$1.url.host) aliases.push(`acct:${actor.preferredUsername}@${context$1.url.host}`);
}
if (resourceUrl.href !== context$1.getActorUri(identifier).href) aliases.push(context$1.getActorUri(identifier).href);
if (resourceUrl.protocol === "acct:" && host != null && host !== context$1.url.host && !resourceUrl.href.endsWith(`@${host}`)) {
const username = resourceUrl.href.replace(/^acct:/, "").replace(/@.*$/, "");
aliases.push(`acct:${username}@${host}`);
}
const jrd = {
subject: resourceUrl.href,
aliases,
links
};
return new Response(JSON.stringify(jrd), { headers: {
"Content-Type": "application/jrd+json",
"Access-Control-Allow-Origin": "*"
} });
}
//#endregion
//#region federation/negotiation.ts
function compareSpecs(a, b) {
return b.q - a.q || (b.s ?? 0) - (a.s ?? 0) || (a.o ?? 0) - (b.o ?? 0) || a.i - b.i || 0;
}
function isQuality(spec) {
return spec.q > 0;
}
const simpleMediaTypeRegExp = /^\s*([^\s\/;]+)\/([^;\s]+)\s*(?:;(.*))?$/;
function splitKeyValuePair(str) {
const [key, value] = str.split("=");
return [key.toLowerCase(), value];
}
function parseMediaType(str, i) {
const match = simpleMediaTypeRegExp.exec(str);
if (!match) return;
const [, type, subtype, parameters] = match;
if (!type || !subtype) return;
const params = Object.create(null);
let q = 1;
if (parameters) {
const kvps = parameters.split(";").map((p) => p.trim()).map(splitKeyValuePair);
for (const [key, val] of kvps) {
const value = val && val[0] === `"` && val[val.length - 1] === `"` ? val.slice(1, val.length - 1) : val;
if (key === "q" && value) {
q = parseFloat(value);
break;
}
params[key] = value;
}
}
return {
type,
subtype,
params,
i,
o: void 0,
q,
s: void 0
};
}
function parseAccept(accept) {
const accepts = accept.split(",").map((p) => p.trim());
const mediaTypes = [];
for (const [index, accept$1] of accepts.entries()) {
const mediaType = parseMediaType(accept$1.trim(), index);
if (mediaType) mediaTypes.push(mediaType);
}
return mediaTypes;
}
function getFullType(spec) {
return `${spec.type}/${spec.subtype}`;
}
function preferredMediaTypes(accept) {
const accepts = parseAccept(accept === void 0 ? "*/*" : accept ?? "");
return accepts.filter(isQuality).sort(compareSpecs).map(getFullType);
}
//#endregion
//#region federation/handler.ts
function acceptsJsonLd(request) {
const accept = request.headers.get("Accept");
const types = accept ? preferredMediaTypes(accept) : ["*/*"];
if (types == null) return true;
if (types[0] === "text/html" || types[0] === "application/xhtml+xml") return false;
return types.includes("application/activity+json") || types.includes("application/ld+json") || types.includes("application/json");
}
async function handleActor(request, { identifier, context: context$1, actorDispatcher, authorizePredicate, onNotFound, onNotAcceptable, onUnauthorized }) {
const logger$2 = getLogger([
"fedify",
"federation",
"actor"
]);
if (actorDispatcher == null) {
logger$2.debug("Actor dispatcher is not set.", { identifier });
return await onNotFound(request);
}
const actor = await actorDispatcher(context$1, identifier);
if (actor == null) {
logger$2.debug("Actor {identifier} not found.", { identifier });
return await onNotFound(request);
}
if (!acceptsJsonLd(request)) return await onNotAcceptable(request);
if (authorizePredicate != null) {
let key = await context$1.getSignedKey();
key = key?.clone({}, { $warning: {
category: [
"fedify",
"federation",
"actor"
],
message: "The third parameter of AuthorizePredicate is deprecated in favor of RequestContext.getSignedKey() method. The third parameter will be removed in a future release."
} }) ?? null;
let keyOwner = await context$1.getSignedKeyOwner();
keyOwner = keyOwner?.clone({}, { $warning: {
category: [
"fedify",
"federation",
"actor"
],
message: "The fourth parameter of AuthorizePredicate is deprecated in favor of RequestContext.getSignedKeyOwner() method. The fourth parameter will be removed in a future release."
} }) ?? null;
if (!await authorizePredicate(context$1, identifier, key, keyOwner)) return await onUnauthorized(request);
}
const jsonLd = await actor.toJsonLd(context$1);
return new Response(JSON.stringify(jsonLd), { headers: {
"Content-Type": "application/activity+json",
Vary: "Accept"
} });
}
async function handleObject(request, { values, context: context$1, objectDispatcher, authorizePredicate, onNotFound, onNotAcceptable, onUnauthorized }) {
if (objectDispatcher == null) return await onNotFound(request);
const object = await objectDispatcher(context$1, values);
if (object == null) return await onNotFound(request);
if (!acceptsJsonLd(request)) return await onNotAcceptable(request);
if (authorizePredicate != null) {
let key = await context$1.getSignedKey();
key = key?.clone({}, { $warning: {
category: [
"fedify",
"federation",
"object"
],
message: "The third parameter of ObjectAuthorizePredicate is deprecated in favor of RequestContext.getSignedKey() method. The third parameter will be removed in a future release."
} }) ?? null;
let keyOwner = await context$1.getSignedKeyOwner();
keyOwner = keyOwner?.clone({}, { $warning: {
category: [
"fedify",
"federation",
"object"
],
message: "The fourth parameter of ObjectAuthorizePredicate is deprecated in favor of RequestContext.getSignedKeyOwner() method. The fourth parameter will be removed in a future release."
} }) ?? null;
if (!await authorizePredicate(context$1, values, key, keyOwner)) return await onUnauthorized(request);
}
const jsonLd = await object.toJsonLd(context$1);
return new Response(JSON.stringify(jsonLd), { headers: {
"Content-Type": "application/activity+json",
Vary: "Accept"
} });
}
async function handleCollection(request, { name, identifier, uriGetter, filter, filterPredicate, context: context$1, collectionCallbacks, tracerProvider, onUnauthorized, onNotFound, onNotAcceptable }) {
const spanName = name.trim().replace(/\s+/g, "_");
tracerProvider = tracerProvider ?? trace.getTracerProvider();
const tracer = tracerProvider.getTracer(deno_default.name, deno_default.version);
const url = new URL(request.url);
const cursor = 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$1, identifier);
const totalItems = filter == null ? await collectionCallbacks.counter?.(context$1, 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$1, 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, filterPredicate)
});
} else {
const lastCursor = await collectionCallbacks.lastCursor?.(context$1, identifier);
const first = new URL(context$1.url);
first.searchParams.set("cursor", firstCursor);
let last = null;
if (lastCursor != null) {
last = new URL(context$1.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}`, {
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$1, 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$1.url);
prev.searchParams.set("cursor", prevCursor);
}
let next = null;
if (nextCursor != null) {
next = new URL(context$1.url);
next.searchParams.set("cursor", nextCursor);
}
const partOf = new URL(context$1.url);
partOf.searchParams.delete("cursor");
collection = new OrderedCollectionPage({
id: uri,
prev,
next,
items: filterCollectionItems(items, name, filterPredicate),
partOf
});
}
if (!acceptsJsonLd(request)) return await onNotAcceptable(request);
if (collectionCallbacks.authorizePredicate != null) {
let key = await context$1.getSignedKey();
key = key?.clone({}, { $warning: {
category: [
"fedify",
"federation",
"collection"
],
message: "The third parameter of AuthorizePredicate is deprecated in favor of RequestContext.getSignedKey() method. The third parameter will be removed in a future release."
} }) ?? null;
let keyOwner = await context$1.getSignedKeyOwner();
keyOwner = keyOwner?.clone({}, { $warning: {
category: [
"fedify",
"federation",
"collection"
],
message: "The fourth parameter of AuthorizePredicate is deprecated in favor of RequestContext.getSignedKeyOwner() method. The fourth parameter will be removed in a future release."
} }) ?? null;
if (!await collectionCallbacks.authorizePredicate(context$1, identifier, key, keyOwner)) return await onUnauthorized(request);
}
const jsonLd = await collection.toJsonLd(context$1);
return new Response(JSON.stringify(jsonLd), { headers: {
"Content-Type": "application/activity+json",
Vary: "Accept"
} });
}
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;
}
async function handleInbox(request, options) {
const tracerProvider = options.tracerProvider ?? trace.getTracerProvider();
const tracer = tracerProvider.getTracer(deno_default.name, deno_default.version);
return await tracer.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();
}
});
}
async function handleInboxInternal(request, { recipient, context: ctx, inboxContextFactory, kv, kvPrefixes, queue, actorDispatcher, inboxListeners, inboxErrorHandler, onNotFound, signatureTimeWindow, skipSignatureVerification, tracerProvider }, span) {
const logger$2 = getLogger([
"fedify",
"federation",
"inbox"
]);
if (actorDispatcher == null) {
logger$2.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) {
logger$2.error("Actor {recipient} not found.", { recipient });
span.setStatus({
code: SpanStatusCode.ERROR,
message: `Actor ${recipient} not found.`
});
return await onNotFound(request);
}
}
if (request.bodyUsed) {
logger$2.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$2.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$2.error("Failed to parse JSON:\n{error}", {
recipient,
error
});
try {
await inboxErrorHandler?.(ctx, error);
} catch (error$1) {
logger$2.error("An unexpected error occurred in inbox error handler:\n{error}", {
error: error$1,
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);
let ldSigVerified;
try {
ldSigVerified = await verifyJsonLd(json, {
contextLoader: ctx.contextLoader,
documentLoader: ctx.documentLoader,
keyCache,
tracerProvider
});
} catch (error) {
if (error instanceof Error && error.name === "jsonld.SyntaxError") {
logger$2.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" }
});
}
ldSigVerified = false;
}
const jsonWithoutSig = detachSignature(json);
let activity = null;
if (ldSigVerified) {
logger$2.debug("Linked Data Signatures are verified.", {
recipient,
json
});
activity = await Activity.fromJsonLd(jsonWithoutSig, ctx);
} else {
logger$2.debug("Linked Data Signatures are not verified.", {
recipient,
json
});
try {
activity = await verifyObject(Activity, jsonWithoutSig, {
contextLoader: ctx.contextLoader,
documentLoader: ctx.documentLoader,
keyCache,
tracerProvider
});
} catch (error) {
logger$2.error("Failed to parse activity:\n{error}", {
recipient,
activity: json,
error
});
try {
await inboxErrorHandler?.(ctx, error);
} catch (error$1) {
logger$2.error("An unexpected error occurred in inbox error handler:\n{error}", {
error: error$1,
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$2.debug("Object Integrity Proofs are not verified.", {
recipient,
activity: json
});
else logger$2.debug("Object Integrity Proofs are verified.", {
recipient,
activity: json
});
}
let httpSigKey = null;
if (activity == null) {
if (!skipSignatureVerification) {
const key = await verifyRequest(request, {
contextLoader: ctx.contextLoader,
documentLoader: ctx.documentLoader,
timeWindow: signatureTimeWindow,
keyCache,
tracerProvider
});
if (key == null) {
logger$2.error("Failed to verify the request's HTTP Signatures.", { recipient });
span.setStatus({
code: SpanStatusCode.ERROR,
message: `Failed to verify the request's HTTP Signatures.`
});
const response = new Response("Failed to verify the request signature.", {
status: 401,
headers: { "Content-Type": "text/plain; charset=utf-8" }
});
return response;
} else logger$2.debug("HTTP Signatures are verified.", { recipient });
httpSigKey = key;
}
activity = await Activity.fromJsonLd(jsonWithoutSig, ctx);
}
if (activity.id != null) span.setAttribute("activitypub.activity.id", activity.id.href);
span.setAttribute("activitypub.activity.type", getTypeId(activity).href);
const routeResult = await routeActivity({
context: ctx,
json,
activity,
recipient,
inboxListeners,
inboxContextFactory,
inboxErrorHandler,
kv,
kvPrefixes,
queue,
span,
tracerProvider
});
if (httpSigKey != null && !await doesActorOwnKey(activity, httpSigKey, ctx)) {
logger$2.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 (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" }
});
}
/**
* Responds with the given object in JSON-LD format.
*
* @param object The object to respond with.
* @param options Options.
* @since 0.3.0
*/
async function respondWithObject(object, options) {
const jsonLd = await object.toJsonLd(options);
return new Response(JSON.stringify(jsonLd), { headers: { "Content-Type": "application/activity+json" } });
}
/**
* Responds with the given object in JSON-LD format if the request accepts
* JSON-LD.
*
* @param object The object to respond with.
* @param request The request to check for JSON-LD acceptability.
* @param options Options.
* @since 0.3.0
*/
async function respondWithObjectIfAcceptable(object, request, options) {
if (!acceptsJsonLd(request)) return null;
const response = await respondWithObject(object, options);
response.headers.set("Vary", "Accept");
return response;
}
//#endregion
//#region federation/middleware.ts
/**
* Create a new {@link Federation} instance.
* @param parameters Parameters for initializing the instance.
* @returns A new {@link Federation} instance.
* @since 0.10.0
*/
function createFederation(options) {
return new FederationImpl(options);
}
var FederationImpl = class extends FederationBuilderImpl {
kv;
kvPrefixes;
inboxQueue;
outboxQueue;
fanoutQueue;
inboxQueueStarted;
outboxQueueStarted;
fanoutQueueStarted;
manuallyStartQueue;
origin;
documentLoaderFactory;
contextLoaderFactory;
authenticatedDocumentLoaderFactory;
allowPrivateAddress;
userAgent;
onOutboxError;
signatureTimeWindow;
skipSignatureVerification;
outboxRetryPolicy;
inboxRetryPolicy;
activityTransformers;
tracerProvider;
firstKnock;
constructor(options) {
super();
const logger$2 = getLogger(["fedify", "federation"]);
this.kv = options.kv;
this.kvPrefixes = {
activityIdempotence: ["_fedify", "activityIdempotence"],
remoteDocument: ["_fedify", "remoteDocument"],
publicKey: ["_fedify", "publicKey"],
httpMessageSignaturesSpec: ["_fedify", "httpMessageSignaturesSpec"],
...options.kvPrefixes ?? {}
};
if (options.queue == null) {
this.inboxQueue = void 0;
this.outboxQueue = void 0;
this.fanoutQueue = void 0;
} else if ("enqueue" in options.queue && "listen" in options.queue) {
this.inboxQueue = options.queue;
this.outboxQueue = options.queue;
this.fanoutQueue = options.queue;
} else {
this.inboxQueue = options.queue.inbox;
this.outboxQueue = options.queue.outbox;
this.fanoutQueue = options.queue.fanout;
}
this.inboxQueueStarted = false;
this.outboxQueueStarted = false;
this.fanoutQueueStarted = false;
this.manuallyStartQueue = options.manuallyStartQueue ?? false;
if (options.origin != null) if (typeof options.origin === "string") {
if (!URL.canParse(options.origin) || !options.origin.match(/^https?:\/\//)) throw new TypeError(`Invalid origin: ${JSON.stringify(options.origin)}`);
const origin = new URL(options.origin);
if (!origin.pathname.match(/^\/*$/) || origin.search !== "" || origin.hash !== "") throw new TypeError(`Invalid origin: ${JSON.stringify(options.origin)}`);
this.origin = {
handleHost: origin.host,
webOrigin: origin.origin
};
} else {
const { handleHost, webOrigin } = options.origin;
if (!URL.canParse(`https://${handleHost}/`) || handleHost.includes("/")) throw new TypeError(`Invalid origin.handleHost: ${JSON.stringify(handleHost)}`);
if (!URL.canParse(webOrigin) || !webOrigin.match(/^https?:\/\//)) throw new TypeError(`Invalid origin.webOrigin: ${JSON.stringify(webOrigin)}`);
const webOriginUrl = new URL(webOrigin);
if (!webOriginUrl.pathname.match(/^\/*$/) || webOriginUrl.search !== "" || webOriginUrl.hash !== "") throw new TypeError(`Invalid origin.webOrigin: ${JSON.stringify(webOrigin)}`);
this.origin = {
handleHost: new URL(`https://${handleHost}/`).host,
webOrigin: webOriginUrl.origin
};
}
this.router.trailingSlashInsensitive = options.trailingSlashInsensitive ?? false;
this._initializeRouter();
if (options.allowPrivateAddress || options.userAgent != null) {
if (options.documentLoader != null) throw new TypeError("Cannot set documentLoader with allowPrivateAddress or userAgent options.");
else if (options.contextLoader != null) throw new TypeError("Cannot set contextLoader with allowPrivateAddress or userAgent options.");
else if (options.authenticatedDocumentLoaderFactory != null) throw new TypeError("Cannot set authenticatedDocumentLoaderFactory with allowPrivateAddress or userAgent options.");
}
const { allowPrivateAddress, userAgent } = options;
this.allowPrivateAddress = allowPrivateAddress ?? false;
if (options.documentLoader != null) {
if (options.documentLoaderFactory != null) throw new TypeError("Cannot set both documentLoader and documentLoaderFactory options at a time; use documentLoaderFactory only.");
this.documentLoaderFactory = () => options.documentLoader;
logger$2.warn("The documentLoader option is deprecated; use documentLoaderFactory option instead.");
} else this.documentLoaderFactory = options.documentLoaderFactory ?? ((opts) => {
return kvCache({
loader: getDocumentLoader({
allowPrivateAddress: opts?.allowPrivateAddress ?? allowPrivateAddress,
userAgent: opts?.userAgent ?? userAgent
}),
kv: options.kv,
prefix: this.kvPrefixes.remoteDocument
});
});
if (options.contextLoader != null) {
if (options.contextLoaderFactory != null) throw new TypeError("Cannot set both contextLoader and contextLoaderFactory options at a time; use contextLoaderFactory only.");
this.contextLoaderFactory = () => options.contextLoader;
logger$2.warn("The contextLoader option is deprecated; use contextLoaderFactory option instead.");
} else this.contextLoaderFactory = options.contextLoaderFactory ?? this.documentLoaderFactory;
this.authenticatedDocumentLoaderFactory = options.authenticatedDocumentLoaderFactory ?? ((identity) => getAuthenticatedDocumentLoader(identity, {
allowPrivateAddress,
userAgent,
specDeterminer: new KvSpecDeterminer(this.kv, this.kvPrefixes.httpMessageSignaturesSpec, options.firstKnock),
tracerProvider: this.tracerProvider
}));
this.userAgent = userAgent;
this.onOutboxError = options.onOutboxError;
this.signatureTimeWindow = options.signatureTimeWindow ?? { hours: 1 };
this.skipSignatureVerification = options.skipSignatureVerification ?? false;
this.outboxRetryPolicy = options.outboxRetryPolicy ?? createExponentialBackoffPolicy();
this.inboxRetryPolicy = options.inboxRetryPolicy ?? createExponentialBackoffPolicy();
this.activityTransformers = options.activityTransformers ?? getDefaultActivityTransformers();
this.tracerProvider = options.tracerProvider ?? trace.getTracerProvider();
this.firstKnock = options.firstKnock;
}
_initializeRouter() {
this.router.add("/.well-known/webfinger", "webfinger");
this.router.add("/.well-known/nodeinfo", "nodeInfoJrd");
}
_getTracer() {
return this.tracerProvider.getTracer(deno_default.name, deno_default.version);
}
async _startQueueInternal(ctxData, signal, queue) {
if (this.inboxQueue == null && this.outboxQueue == null) return;
const logger$2 = getLogger([
"fedify",
"federation",
"queue"
]);
const promises = [];
if (this.inboxQueue != null && (queue == null || queue === "inbox") && !this.inboxQueueStarted) {
logger$2.debug("Starting an inbox task worker.");
this.inboxQueueStarted = true;
promises.push(this.inboxQueue.listen((msg) => this.processQueuedTask(ctxData, msg), { signal }));
}
if (this.outboxQueue != null && this.outboxQueue !== this.inboxQueue && (queue == null || queue === "outbox") && !this.outboxQueueStarted) {
logger$2.debug("Starting an outbox task worker.");
this.outboxQueueStarted = true;
promises.push(this.outboxQueue.listen((msg) => this.processQueuedTask(ctxData, msg), { signal }));
}
if (this.fanoutQueue != null && this.fanoutQueue !== this.inboxQueue && this.fanoutQueue !== this.outboxQueue && (queue == null || queue === "fanout") && !this.fanoutQueueStarted) {
logger$2.debug("Starting a fanout task worker.");
this.fanoutQueueStarted = true;
promises.push(this.fanoutQueue.listen((msg) => this.processQueuedTask(ctxData, msg), { signal }));
}
await Promise.all(promises);
}
processQueuedTask(contextData, message) {
const tracer = this._getTracer();
const extractedContext = propagation.extract(context.active(), message.traceContext);
return withContext({ messageId: message.id }, async () => {
if (message.type === "fanout") await tracer.startActiveSpan("activitypub.fanout", {
kind: SpanKind.CONSUMER,
attributes: { "activitypub.activity.type": message.activityType }
}, extractedContext, async (span) => {
if (message.activityId != null) span.setAttribute("activitypub.activity.id", message.activityId);
try {
await this.#listenFanoutMessage(contextData, message);
} catch (e) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: String(e)
});
throw e;
} finally {
span.end();
}
});
else if (message.type === "outbox") await tracer.startActiveSpan("activitypub.outbox", {
kind: SpanKind.CONSUMER,
attributes: {
"activitypub.activity.type": message.activityType,
"activitypub.activity.retries": message.attempt
}
}, extractedContext, async (span) => {
if (message.activityId != null) span.setAttribute("activitypub.activity.id", message.activityId);
try {
await this.#listenOutboxMessage(contextData, message, span);
} catch (e) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: String(e)
});
throw e;
} finally {
span.end();
}
});
else if (message.type === "inbox") await tracer.startActiveSpan("activitypub.inbox", {
kind: SpanKind.CONSUMER,
attributes: { "activitypub.shared_inbox": message.identifier == null }
}, extractedContext, async (span) => {
try {
await this.#listenInboxMessage(contextData, message, span);
} catch (e) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: String(e)
});
throw e;
} finally {
span.end();
}
});
});
}
async #listenFanoutMessage(data, message) {
const logger$2 = getLogger([
"fedify",
"federation",
"fanout"
]);
logger$2.debug("Fanning out activity {activityId} to {inboxes} inbox(es)...", {
activityId: message.activityId,
inboxes: globalThis.Object.keys(message.inboxes).length
});
const keys = await Promise.all(message.keys.map(async ({ keyId, privateKey }) => ({
keyId: new URL(keyId),
privateKey: await importJwk(privateKey, "private")
})));
const activity = await Activity.fromJsonLd(message.activity, {
contextLoader: this.contextLoaderFactory({
allowPrivateAddress: this.allowPrivateAddress,
userAgent: this.userAgent
}),
documentLoader: this.documentLoaderFactory({
allowPrivateAddress: this.allowPrivateAddress,
userAgent: this.userAgent
}),
tracerProvider: this.tracerProvider
});
const context$1 = this.#createContext(new URL(message.baseUrl), data, { documentLoader: this.documentLoaderFactory({
allowPrivateAddress: this.allowPrivateAddress,
userAgent: this.userAgent
}) });
await this.sendActivity(keys, message.inboxes, activity, {
collectionSync: message.collectionSync,
context: context$1
});
}
async #listenOutboxMessage(_, message, span) {
const logger$2 = getLogger([
"fedify",
"federation",
"outbox"
]);
const logData = {
keyIds: message.keys.map((pair) => pair.keyId),
inbox: message.inbox,
activity: message.activity,
activityId: message.activityId,
attempt: message.attempt,
headers: message.headers
};
const keys = [];
let rsaKeyPair = null;
for (const { keyId, privateKey } of message.keys) {
const pair = {
keyId: new URL(keyId),
privateKey: await importJwk(privateKey, "private")
};
if (rsaKeyPair == null && pair.privateKey.algorithm.name === "RSASSA-PKCS1-v1_5") rsaKeyPair = pair;
keys.push(pair);
}
try {
await sendActivity({
keys,
activity: message.activity,
activityId: message.activityId,
activityType: message.activityType,
inbox: new URL(message.inbox),
sharedInbox: message.sharedInbox,
headers: new Headers(message.headers),
specDeterminer: new KvSpecDeterminer(this.kv, this.kvPrefixes.httpMessageSignaturesSpec, this.firstKnock),
tracerProvider: this.tracerProvider
});
} catch (error) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: String(error)
});
const loaderOptions = this.#getLoaderOptions(message.baseUrl);
const activity = await Activity.fromJsonLd(message.activity, {
contextLoader: this.contextLoaderFactory(loaderOptions),
documentLoader: rsaKeyPair == null ? this.documentLoaderFactory(loaderOptions) : this.authenticatedDocumentLoaderFactory(rsaKeyPair, loaderOptions),
tracerProvider: this.tracerProvider
});
try {
this.onOutboxError?.(error, activity);
} catch (error$1) {
logger$2.error("An unexpected error occurred in onError handler:\n{error}", {
...logData,
error: error$1
});
}
if (this.outboxQueue?.nativeRetrial) {
logger$2.error("Failed to send activity {activityId} to {inbox}; backend will handle retry:\n{error}", {
...logData,
error
});
throw error;
}
const delay = this.outboxRetryPolicy({
elapsedTime: Temporal.Instant.from(message.started).until(Temporal.Now.instant()),
attempts: message.attempt
});
if (delay != null) {
logger$2.error("Failed to send activity {activityId} to {inbox} (attempt #{attempt}); retry...:\n{error}", {
...logData,
error
});
await this.outboxQueue?.enqueue({
...message,
attempt: message.attempt + 1
}, { delay: Temporal.Duration.compare(delay, { seconds: 0 }) < 0 ? Temporal.Duration.from({ seconds: 0 }) : delay });
} else logger$2.error("Failed to send activity {activityId} to {inbox} after {attempt} attempts; giving up:\n{error}", {
...logData,
error
});
return;
}
logger$2.info("Successfully sent activity {activityId} to {inbox}.", { ...logData });
}
async #listenInboxMessage(ctxData, message, span) {
const logger$2 = getLogger([
"fedify",
"federation",
"inbox"
]);
const baseUrl = new URL(message.baseUrl);
let context$1 = this.#createContext(baseUrl, ctxData);
if (message.identifier != null) context$1 = this.#createContext(baseUrl, ctxData, { documentLoader: await context$1.getDocumentLoader({ identifier: message.identifier }) });
else if (this.sharedInboxKeyDispatcher != null) {
const identity = await this.sharedInboxKeyDispatcher(context$1);
if (identity != null) context$1 = this.#createContext(baseUrl, ctxData, { documentLoader: "identifier" in identity || "username" in identity || "handle" in identity ? await context$1.getDocumentLoader(identity) : context$1.getDocumentLoader(identity) });
}
const activity = await Activity.fromJsonLd(message.activity, context$1);
span.setAttribute("activitypub.activity.type", getTypeId(activity).href);
if (activity.id != null) span.setAttribute("activitypub.activity.id", activity.id.href);
const cacheKey = activity.id == null ? null : [
...this.kvPrefixes.activityIdempotence,
context$1.origin,
activity.id.href
];
if (cacheKey != null) {
const cached = await this.kv.get(cacheKey);
if (cached === true) {
logger$2.debug("Activity {activityId} has already been processed.", {
activityId: activity.id?.href,
activity: message.activity,
recipient: message.identifier
});
return;
}
}
await this._getTracer().startActiveSpan("activitypub.dispatch_inbox_listener", { kind: SpanKind.INTERNAL }, async (span$1) => {
const dispatched = this.inboxListeners?.dispatchWithClass(activity);
if (dispatched == null) {
logger$2.error("Unsupported activity type:\n{activity}", {
activityId: activity.id?.href,
activity: message.activity,
recipient: message.identifier,
trial: message.attempt
});
span$1.setStatus({
code: SpanStatusCode.ERROR,
message: `Unsupported activity type: ${getTypeId(activity).href}`
});
span$1.end();
return;
}
const { class: cls, listener } = dispatched;
span$1.updateName(`activitypub.dispatch_inbox_listener ${cls.name}`);
try {
await listener(context$1.toInboxContext(message.identifier, message.activity, activity.id?.href, getTypeId(activity).href), activity);
} catch (error) {
try {
await this.inboxErrorHandler?.(context$1, error);
} catch (error$1) {
logger$2.error("An unexpected error occurred in inbox error handler:\n{error}", {
error: error$1,
trial: message.attempt,
activityId: activity.id?.href,
activity: message.activity,
recipient: message.identifier
});
}
if (this.inboxQueue?.nativeRetrial) {
logger$2.error("Failed to process the incoming activity {activityId}; backend will handle retry:\n{error}", {
error,
activityId: activity.id?.href,
activity: message.activity,
recipient: message.identifier
});
span$1.setStatus({
code: SpanStatusCode.ERROR,
message: String(error)
});
span$1.end();
throw error;
}
const delay = this.inboxRetryPolicy({
elapsedTime: Temporal.Instant.from(message.started).until(Temporal.Now.instant()),
attempts: message.attempt
});
if (delay != null) {
logger$2.error("Failed to process the incoming activity {activityId} (attempt #{attempt}); retry...:\n{error}", {
error,
attempt: message.attempt,
activityId: activity.id?.href,
activity: message.activity,
recipient: message.identifier
});
await this.inboxQueue?.enqueue({
...message,
attempt: message.attempt + 1
}, { delay: Temporal.Duration.compare(delay, { seconds: 0 }) < 0 ? Temporal.Duration.from({ seconds: 0 }) : delay });
} else logger$2.error("Failed to process the incoming activity {activityId} after {trial} attempts; giving up:\n{error}", {
error,
activityId: activity.id?.href,
activity: message.activity,
recipient: message.identifier
});
span$1.setStatus({
code: SpanStatusCode.ERROR,
message: String(error)
});
span$1.end();
return;
}
if (cacheKey != null) await this.kv.set(cacheKey, true, { ttl: Temporal.Duration.from({ days: 1 }) });
logger$2.info("Activity {activityId} has been processed.", {
activityId: activity.id?.href,
activity: message.activity,
recipient: message.identifier
});
span$1.end();
});
}
startQueue(contextData, options = {}) {
return this._startQueueInternal(contextData, options.signal, options.queue);
}
createContext(urlOrRequest, contextData) {
return urlOrRequest instanceof Request ? this.#createContext(urlOrRequest, contextData) : this.#createContext(urlOrRequest, contextData);
}
#createContext(urlOrRequest, contextData, opts = {}) {
const request = urlOrRequest instanceof Request ? urlOrRequest : null;
const url = urlOrRequest instanceof URL ? new URL(urlOrRequest) : new URL(urlOrRequest.url);
if (request == null) {
u