UNPKG

@fedify/fedify

Version:

An ActivityPub server framework

1,098 lines • 116 kB
import * as dntShim from "../_dnt.shims.js"; import { getLogger, withContext } from "@logtape/logtape"; import { context, propagation, SpanKind, SpanStatusCode, 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 { getDefaultActivityTransformers } from "../compat/transformers.js"; import metadata from "../deno.js"; import { getNodeInfo } from "../nodeinfo/client.js"; import { handleNodeInfo, handleNodeInfoJrd } from "../nodeinfo/handler.js"; import { getAuthenticatedDocumentLoader, getDocumentLoader, kvCache, } from "../runtime/docloader.js"; import { verifyRequest } from "../sig/http.js"; import { exportJwk, importJwk, validateCryptoKey } from "../sig/key.js"; import { hasSignature, signJsonLd } from "../sig/ld.js"; import { getKeyOwner } from "../sig/owner.js"; import { signObject, verifyObject } from "../sig/proof.js"; import { lookupObject, traverseCollection, } from "../vocab/lookup.js"; import { getTypeId } from "../vocab/type.js"; import { Activity, CryptographicKey, Multikey, } from "../vocab/vocab.js"; import { handleWebFinger } from "../webfinger/handler.js"; import { buildCollectionSynchronizationHeader } from "./collection.js"; import { handleActor, handleCollection, handleInbox, handleObject, } from "./handler.js"; import { InboxListenerSet, routeActivity } from "./inbox.js"; import { KvKeyCache } from "./keycache.js"; import { createExponentialBackoffPolicy } from "./retry.js"; import { Router, RouterError } from "./router.js"; import { extractInboxes, sendActivity } from "./send.js"; /** * Create a new {@link Federation} instance. * @param parameters Parameters for initializing the instance. * @returns A new {@link Federation} instance. * @since 0.10.0 */ export function createFederation(options) { return new FederationImpl(options); } export class FederationImpl { kv; kvPrefixes; inboxQueue; outboxQueue; fanoutQueue; inboxQueueStarted; outboxQueueStarted; fanoutQueueStarted; manuallyStartQueue; origin; router; nodeInfoDispatcher; actorCallbacks; objectCallbacks; objectTypeIds; inboxPath; inboxCallbacks; outboxCallbacks; followingCallbacks; followersCallbacks; likedCallbacks; featuredCallbacks; featuredTagsCallbacks; inboxListeners; inboxErrorHandler; sharedInboxKeyDispatcher; documentLoaderFactory; contextLoaderFactory; authenticatedDocumentLoaderFactory; allowPrivateAddress; userAgent; onOutboxError; signatureTimeWindow; skipSignatureVerification; outboxRetryPolicy; inboxRetryPolicy; activityTransformers; tracerProvider; constructor(options) { const logger = getLogger(["fedify", "federation"]); this.kv = options.kv; this.kvPrefixes = { ...{ activityIdempotence: ["_fedify", "activityIdempotence"], remoteDocument: ["_fedify", "remoteDocument"], publicKey: ["_fedify", "publicKey"], }, ...(options.kvPrefixes ?? {}), }; if (options.queue == null) { this.inboxQueue = undefined; this.outboxQueue = undefined; this.fanoutQueue = undefined; } 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 = new Router({ trailingSlashInsensitive: options.trailingSlashInsensitive, }); this.router.add("/.well-known/webfinger", "webfinger"); this.router.add("/.well-known/nodeinfo", "nodeInfoJrd"); this.objectCallbacks = {}; this.objectTypeIds = {}; 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.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.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, })); 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(); } #getTracer() { return this.tracerProvider.getTracer(metadata.name, metadata.version); } async _startQueueInternal(ctxData, signal, queue) { if (this.inboxQueue == null && this.outboxQueue == null) return; const logger = getLogger(["fedify", "federation", "queue"]); const promises = []; if (this.inboxQueue != null && (queue == null || queue === "inbox") && !this.inboxQueueStarted) { logger.debug("Starting an inbox task worker."); this.inboxQueueStarted = true; promises.push(this.inboxQueue.listen((msg) => this.#listenQueue(ctxData, msg), { signal })); } if (this.outboxQueue != null && this.outboxQueue !== this.inboxQueue && (queue == null || queue === "outbox") && !this.outboxQueueStarted) { logger.debug("Starting an outbox task worker."); this.outboxQueueStarted = true; promises.push(this.outboxQueue.listen((msg) => this.#listenQueue(ctxData, msg), { signal })); } if (this.fanoutQueue != null && this.fanoutQueue !== this.inboxQueue && this.fanoutQueue !== this.outboxQueue && (queue == null || queue === "fanout") && !this.fanoutQueueStarted) { logger.debug("Starting a fanout task worker."); this.fanoutQueueStarted = true; promises.push(this.fanoutQueue.listen((msg) => this.#listenQueue(ctxData, msg), { signal })); } await Promise.all(promises); } #listenQueue(ctxData, 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(ctxData, 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(ctxData, 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(ctxData, message, span); } catch (e) { span.setStatus({ code: SpanStatusCode.ERROR, message: String(e), }); throw e; } finally { span.end(); } }); } }); } async #listenFanoutMessage(data, message) { const logger = getLogger(["fedify", "federation", "fanout"]); logger.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 = 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, }); } async #listenOutboxMessage(_, message, span) { const logger = 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), 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) { logger.error("An unexpected error occurred in onError handler:\n{error}", { ...logData, error }); } const delay = this.outboxRetryPolicy({ elapsedTime: dntShim.Temporal.Instant.from(message.started).until(dntShim.Temporal.Now.instant()), attempts: message.attempt, }); if (delay != null) { logger.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: dntShim.Temporal.Duration.compare(delay, { seconds: 0 }) < 0 ? dntShim.Temporal.Duration.from({ seconds: 0 }) : delay, }); } else { logger.error("Failed to send activity {activityId} to {inbox} after {attempt} " + "attempts; giving up:\n{error}", { ...logData, error }); } return; } logger.info("Successfully sent activity {activityId} to {inbox}.", { ...logData }); } async #listenInboxMessage(ctxData, message, span) { const logger = getLogger(["fedify", "federation", "inbox"]); const baseUrl = new URL(message.baseUrl); let context = this.#createContext(baseUrl, ctxData); if (message.identifier != null) { context = this.#createContext(baseUrl, ctxData, { documentLoader: await context.getDocumentLoader({ identifier: message.identifier, }), }); } else if (this.sharedInboxKeyDispatcher != null) { const identity = await this.sharedInboxKeyDispatcher(context); if (identity != null) { context = this.#createContext(baseUrl, ctxData, { documentLoader: "identifier" in identity || "username" in identity || "handle" in identity ? await context.getDocumentLoader(identity) : context.getDocumentLoader(identity), }); } } const activity = await Activity.fromJsonLd(message.activity, context); 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.origin, activity.id.href, ]; if (cacheKey != null) { const cached = await this.kv.get(cacheKey); if (cached === true) { logger.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) => { const dispatched = this.inboxListeners?.dispatchWithClass(activity); if (dispatched == null) { logger.error("Unsupported activity type:\n{activity}", { activityId: activity.id?.href, activity: message.activity, recipient: message.identifier, trial: message.attempt, }); span.setStatus({ code: SpanStatusCode.ERROR, message: `Unsupported activity type: ${getTypeId(activity).href}`, }); span.end(); return; } const { class: cls, listener } = dispatched; span.updateName(`activitypub.dispatch_inbox_listener ${cls.name}`); try { await listener(context.toInboxContext(message.identifier, message.activity, activity.id?.href, getTypeId(activity).href), activity); } catch (error) { try { await this.inboxErrorHandler?.(context, error); } catch (error) { logger.error("An unexpected error occurred in inbox error handler:\n{error}", { error, trial: message.attempt, activityId: activity.id?.href, activity: message.activity, recipient: message.identifier, }); } const delay = this.inboxRetryPolicy({ elapsedTime: dntShim.Temporal.Instant.from(message.started).until(dntShim.Temporal.Now.instant()), attempts: message.attempt, }); if (delay != null) { logger.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: dntShim.Temporal.Duration.compare(delay, { seconds: 0 }) < 0 ? dntShim.Temporal.Duration.from({ seconds: 0 }) : delay, }); } else { logger.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.setStatus({ code: SpanStatusCode.ERROR, message: String(error), }); span.end(); return; } if (cacheKey != null) { await this.kv.set(cacheKey, true, { ttl: dntShim.Temporal.Duration.from({ days: 1 }), }); } logger.info("Activity {activityId} has been processed.", { activityId: activity.id?.href, activity: message.activity, recipient: message.identifier, }); span.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) { url.pathname = "/"; url.hash = ""; url.search = ""; } const loaderOptions = this.#getLoaderOptions(url.origin); const ctxOptions = { url, federation: this, data: contextData, documentLoader: opts.documentLoader ?? this.documentLoaderFactory(loaderOptions), contextLoader: this.contextLoaderFactory(loaderOptions), }; if (request == null) return new ContextImpl(ctxOptions); return new RequestContextImpl({ ...ctxOptions, request, invokedFromActorDispatcher: opts.invokedFromActorDispatcher, invokedFromObjectDispatcher: opts.invokedFromObjectDispatcher, }); } #getLoaderOptions(origin) { origin = typeof origin === "string" ? new URL(origin).origin : origin.origin; return { allowPrivateAddress: this.allowPrivateAddress, userAgent: typeof this.userAgent === "string" ? this.userAgent : { url: origin, ...this.userAgent, }, }; } setNodeInfoDispatcher(path, dispatcher) { if (this.router.has("nodeInfo")) { throw new RouterError("NodeInfo dispatcher already set."); } const variables = this.router.add(path, "nodeInfo"); if (variables.size !== 0) { throw new RouterError("Path for NodeInfo dispatcher must have no variables."); } this.nodeInfoDispatcher = dispatcher; } setActorDispatcher(path, dispatcher) { if (this.router.has("actor")) { throw new RouterError("Actor dispatcher already set."); } const variables = this.router.add(path, "actor"); if (variables.size !== 1 || !(variables.has("identifier") || variables.has("handle"))) { throw new RouterError("Path for actor dispatcher must have one variable: {identifier}"); } if (variables.has("handle")) { getLogger(["fedify", "federation", "actor"]).warn("The {{handle}} variable in the actor dispatcher path is deprecated. " + "Use {{identifier}} instead."); } const callbacks = { dispatcher: async (context, identifier) => { const actor = await this.#getTracer().startActiveSpan("activitypub.dispatch_actor", { kind: SpanKind.SERVER, attributes: { "fedify.actor.identifier": identifier }, }, async (span) => { try { const actor = await dispatcher(context, identifier); span.setAttribute("activitypub.actor.id", (actor?.id ?? context.getActorUri(identifier)).href); if (actor == null) { span.setStatus({ code: SpanStatusCode.ERROR }); } else { span.setAttribute("activitypub.actor.type", getTypeId(actor).href); } return actor; } catch (error) { span.setStatus({ code: SpanStatusCode.ERROR, message: String(error), }); throw error; } finally { span.end(); } }); if (actor == null) return null; const logger = getLogger(["fedify", "federation", "actor"]); if (actor.id == null) { logger.warn("Actor dispatcher returned an actor without an id property. " + "Set the property with Context.getActorUri(identifier)."); } else if (actor.id.href != context.getActorUri(identifier).href) { logger.warn("Actor dispatcher returned an actor with an id property that " + "does not match the actor URI. Set the property with " + "Context.getActorUri(identifier)."); } if (this.followingCallbacks != null && this.followingCallbacks.dispatcher != null) { if (actor.followingId == null) { logger.warn("You configured a following collection dispatcher, but the " + "actor does not have a following property. Set the property " + "with Context.getFollowingUri(identifier)."); } else if (actor.followingId.href != context.getFollowingUri(identifier).href) { logger.warn("You configured a following collection dispatcher, but the " + "actor's following property does not match the following " + "collection URI. Set the property with " + "Context.getFollowingUri(identifier)."); } } if (this.followersCallbacks != null && this.followersCallbacks.dispatcher != null) { if (actor.followersId == null) { logger.warn("You configured a followers collection dispatcher, but the " + "actor does not have a followers property. Set the property " + "with Context.getFollowersUri(identifier)."); } else if (actor.followersId.href != context.getFollowersUri(identifier).href) { logger.warn("You configured a followers collection dispatcher, but the " + "actor's followers property does not match the followers " + "collection URI. Set the property with " + "Context.getFollowersUri(identifier)."); } } if (this.outboxCallbacks != null && this.outboxCallbacks.dispatcher != null) { if (actor?.outboxId == null) { logger.warn("You configured an outbox collection dispatcher, but the " + "actor does not have an outbox property. Set the property " + "with Context.getOutboxUri(identifier)."); } else if (actor.outboxId.href != context.getOutboxUri(identifier).href) { logger.warn("You configured an outbox collection dispatcher, but the " + "actor's outbox property does not match the outbox collection " + "URI. Set the property with Context.getOutboxUri(identifier)."); } } if (this.likedCallbacks != null && this.likedCallbacks.dispatcher != null) { if (actor?.likedId == null) { logger.warn("You configured a liked collection dispatcher, but the " + "actor does not have a liked property. Set the property " + "with Context.getLikedUri(identifier)."); } else if (actor.likedId.href != context.getLikedUri(identifier).href) { logger.warn("You configured a liked collection dispatcher, but the " + "actor's liked property does not match the liked collection " + "URI. Set the property with Context.getLikedUri(identifier)."); } } if (this.featuredCallbacks != null && this.featuredCallbacks.dispatcher != null) { if (actor?.featuredId == null) { logger.warn("You configured a featured collection dispatcher, but the " + "actor does not have a featured property. Set the property " + "with Context.getFeaturedUri(identifier)."); } else if (actor.featuredId.href != context.getFeaturedUri(identifier).href) { logger.warn("You configured a featured collection dispatcher, but the " + "actor's featured property does not match the featured collection " + "URI. Set the property with Context.getFeaturedUri(identifier)."); } } if (this.featuredTagsCallbacks != null && this.featuredTagsCallbacks.dispatcher != null) { if (actor?.featuredTagsId == null) { logger.warn("You configured a featured tags collection dispatcher, but the " + "actor does not have a featuredTags property. Set the property " + "with Context.getFeaturedTagsUri(identifier)."); } else if (actor.featuredTagsId.href != context.getFeaturedTagsUri(identifier).href) { logger.warn("You configured a featured tags collection dispatcher, but the " + "actor's featuredTags property does not match the featured tags " + "collection URI. Set the property with " + "Context.getFeaturedTagsUri(identifier)."); } } if (this.router.has("inbox")) { if (actor.inboxId == null) { logger.warn("You configured inbox listeners, but the actor does not " + "have an inbox property. Set the property with " + "Context.getInboxUri(identifier)."); } else if (actor.inboxId.href != context.getInboxUri(identifier).href) { logger.warn("You configured inbox listeners, but the actor's inbox " + "property does not match the inbox URI. Set the property " + "with Context.getInboxUri(identifier)."); } if (actor.endpoints == null || actor.endpoints.sharedInbox == null) { logger.warn("You configured inbox listeners, but the actor does not have " + "a endpoints.sharedInbox property. Set the property with " + "Context.getInboxUri()."); } else if (actor.endpoints.sharedInbox.href != context.getInboxUri().href) { logger.warn("You configured inbox listeners, but the actor's " + "endpoints.sharedInbox property does not match the shared inbox " + "URI. Set the property with Context.getInboxUri()."); } } if (callbacks.keyPairsDispatcher != null) { if (actor.publicKeyId == null) { logger.warn("You configured a key pairs dispatcher, but the actor does " + "not have a publicKey property. Set the property with " + "Context.getActorKeyPairs(identifier)."); } if (actor.assertionMethodId == null) { logger.warn("You configured a key pairs dispatcher, but the actor does " + "not have an assertionMethod property. Set the property " + "with Context.getActorKeyPairs(identifier)."); } } return actor; }, }; this.actorCallbacks = callbacks; const setters = { setKeyPairsDispatcher: (dispatcher) => { callbacks.keyPairsDispatcher = (ctx, identifier) => this.#getTracer().startActiveSpan("activitypub.dispatch_actor_key_pairs", { kind: SpanKind.SERVER, attributes: { "activitypub.actor.id": ctx.getActorUri(identifier).href, "fedify.actor.identifier": identifier, }, }, async (span) => { try { return await dispatcher(ctx, identifier); } catch (e) { span.setStatus({ code: SpanStatusCode.ERROR, message: String(e), }); throw e; } finally { span.end(); } }); return setters; }, mapHandle(mapper) { callbacks.handleMapper = mapper; return setters; }, mapAlias(mapper) { callbacks.aliasMapper = mapper; return setters; }, authorize(predicate) { callbacks.authorizePredicate = predicate; return setters; }, }; return setters; } setObjectDispatcher( // deno-lint-ignore no-explicit-any cls, path, dispatcher) { const routeName = `object:${cls.typeId.href}`; if (this.router.has(routeName)) { throw new RouterError(`Object dispatcher for ${cls.name} already set.`); } const variables = this.router.add(path, routeName); if (variables.size < 1) { throw new RouterError("Path for object dispatcher must have at least one variable."); } const callbacks = { dispatcher: (ctx, values) => { const tracer = this.#getTracer(); return tracer.startActiveSpan("activitypub.dispatch_object", { kind: SpanKind.SERVER, attributes: { "fedify.object.type": cls.typeId.href, ...globalThis.Object.fromEntries(globalThis.Object.entries(values).map(([k, v]) => [ `fedify.object.values.${k}`, v, ])), }, }, async (span) => { try { const object = await dispatcher(ctx, values); span.setAttribute("activitypub.object.id", (object?.id ?? ctx.getObjectUri(cls, values)).href); if (object == null) { span.setStatus({ code: SpanStatusCode.ERROR }); } else { span.setAttribute("activitypub.object.type", getTypeId(object).href); } return object; } catch (e) { span.setStatus({ code: SpanStatusCode.ERROR, message: String(e), }); throw e; } finally { span.end(); } }); }, parameters: variables, }; this.objectCallbacks[cls.typeId.href] = callbacks; this.objectTypeIds[cls.typeId.href] = cls; const setters = { authorize(predicate) { callbacks.authorizePredicate = predicate; return setters; }, }; return setters; } setInboxDispatcher(path, dispatcher) { if (this.inboxCallbacks != null) { throw new RouterError("Inbox dispatcher already set."); } if (this.router.has("inbox")) { if (this.inboxPath !== path) { throw new RouterError("Inbox dispatcher path must match inbox listener path."); } } else { const variables = this.router.add(path, "inbox"); if (variables.size !== 1 || !(variables.has("identifier") || variables.has("handle"))) { throw new RouterError("Path for inbox dispatcher must have one variable: {identifier}"); } if (variables.has("handle")) { getLogger(["fedify", "federation", "inbox"]).warn("The {{handle}} variable in the inbox dispatcher path is deprecated. " + "Use {{identifier}} instead."); } this.inboxPath = path; } const callbacks = { dispatcher }; this.inboxCallbacks = callbacks; const setters = { setCounter(counter) { callbacks.counter = counter; return setters; }, setFirstCursor(cursor) { callbacks.firstCursor = cursor; return setters; }, setLastCursor(cursor) { callbacks.lastCursor = cursor; return setters; }, authorize(predicate) { callbacks.authorizePredicate = predicate; return setters; }, }; return setters; } setOutboxDispatcher(path, dispatcher) { if (this.router.has("outbox")) { throw new RouterError("Outbox dispatcher already set."); } const variables = this.router.add(path, "outbox"); if (variables.size !== 1 || !(variables.has("identifier") || variables.has("handle"))) { throw new RouterError("Path for outbox dispatcher must have one variable: {identifier}"); } if (variables.has("handle")) { getLogger(["fedify", "federation", "outbox"]).warn("The {{handle}} variable in the outbox dispatcher path is deprecated. " + "Use {{identifier}} instead."); } const callbacks = { dispatcher }; this.outboxCallbacks = callbacks; const setters = { setCounter(counter) { callbacks.counter = counter; return setters; }, setFirstCursor(cursor) { callbacks.firstCursor = cursor; return setters; }, setLastCursor(cursor) { callbacks.lastCursor = cursor; return setters; }, authorize(predicate) { callbacks.authorizePredicate = predicate; return setters; }, }; return setters; } setFollowingDispatcher(path, dispatcher) { if (this.router.has("following")) { throw new RouterError("Following collection dispatcher already set."); } const variables = this.router.add(path, "following"); if (variables.size !== 1 || !(variables.has("identifier") || variables.has("handle"))) { throw new RouterError("Path for following collection dispatcher must have one variable: " + "{identifier}"); } if (variables.has("handle")) { getLogger(["fedify", "federation", "collection"]).warn("The {{handle}} variable in the following collection dispatcher path " + "is deprecated. Use {{identifier}} instead."); } const callbacks = { dispatcher }; this.followingCallbacks = callbacks; const setters = { setCounter(counter) { callbacks.counter = counter; return setters; }, setFirstCursor(cursor) { callbacks.firstCursor = cursor; return setters; }, setLastCursor(cursor) { callbacks.lastCursor = cursor; return setters; }, authorize(predicate) { callbacks.authorizePredicate = predicate; return setters; }, }; return setters; } setFollowersDispatcher(path, dispatcher) { if (this.router.has("followers")) { throw new RouterError("Followers collection dispatcher already set."); } const variables = this.router.add(path, "followers"); if (variables.size !== 1 || !(variables.has("identifier") || variables.has("handle"))) { throw new RouterError("Path for followers collection dispatcher must have one variable: " + "{identifier}"); } if (variables.has("handle")) { getLogger(["fedify", "federation", "collection"]).warn("The {{handle}} variable in the followers collection dispatcher path " + "is deprecated. Use {{identifier}} instead."); } const callbacks = { dispatcher }; this.followersCallbacks = callbacks; const setters = { setCounter(counter) { callbacks.counter = counter; return setters; }, setFirstCursor(cursor) { callbacks.firstCursor = cursor; return setters; }, setLastCursor(cursor) { callbacks.lastCursor = cursor; return setters; }, authorize(predicate) { callbacks.authorizePredicate = predicate; return setters; }, }; return setters; } setLikedDispatcher(path, dispatcher) { if (this.router.has("liked")) { throw new RouterError("Liked collection dispatcher already set."); } const variables = this.router.add(path, "liked"); if (variables.size !== 1 || !(variables.has("identifier") || variables.has("handle"))) { throw new RouterError("Path for liked collection dispatcher must have one variable: " + "{identifier}"); } if (variables.has("handle")) { getLogger(["fedify", "federation", "collection"]).warn("The {{handle}} variable in the liked collection dispatcher path " + "is deprecated. Use {{identifier}} instead."); } const callbacks = { dispatcher }; this.likedCallbacks = callbacks; const setters = { setCounter(counter) { callbacks.counter = counter; return setters; }, setFirstCursor(cursor) { callbacks.firstCursor = cursor; return setters; }, setLastCursor(cursor) { callbacks.lastCursor = cursor; return setters; }, authorize(predicate) { callbacks.authorizePredicate = predicate; return setters; }, }; return setters; } setFeaturedDispatcher(path, dispatcher) { if (this.router.has("featured")) { throw new RouterError("Featured collection dispatcher already set."); } const variables = this.router.add(path, "featured"); if (variables.size !== 1 || !(variables.has("identifier") || variables.has("handle"))) { throw new RouterError("Path for featured collection dispatcher must have one variable: " + "{identifier}"); } if (variables.has("handle")) { getLogger(["fedify", "federation", "collection"]).warn("The {{handle}} variable in the featured collection dispatcher path " + "is deprecated. Use {{identifier}} instead."); } const callbacks = { dispatcher }; this.featuredCallbacks = callbacks; const setters = { setCounter(counter) { callbacks.counter = counter; return setters; }, setFirstCursor(cursor) { callbacks.firstCursor = cursor; return setters;