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