UNPKG

@fedify/fedify

Version:

An ActivityPub server framework

1,344 lines (1,335 loc) • 100 kB
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