@fedify/fedify
Version:
An ActivityPub server framework
1,362 lines (1,353 loc) • 132 kB
JavaScript
import { Temporal } from "@js-temporal/polyfill";
import { URLPattern } from "urlpattern-polyfill";
import { getDefaultActivityTransformers } from "./transformers-ghwJuzGY.js";
import { deno_default, getDocumentLoader, kvCache } from "./docloader-b8Rvguvo.js";
import { Activity, CryptographicKey, Link, Multikey, Object as Object$1, OrderedCollection, OrderedCollectionPage, getTypeId } from "./actor-E2ennwBD.js";
import { lookupWebFinger } from "./lookup-Q9UB5zay.js";
import { exportJwk, importJwk, validateCryptoKey } from "./key-BNa6EpJl.js";
import { doubleKnock, verifyRequest } from "./http-DLI_ZQS-.js";
import { detachSignature, doesActorOwnKey, getKeyOwner, hasSignature, signJsonLd, signObject, verifyJsonLd, verifyObject } from "./proof-Blo9MEVW.js";
import { getNodeInfo, nodeInfoToJson } from "./types-CGJsS8Cg.js";
import { getAuthenticatedDocumentLoader } from "./authdocloader-CFeH0dJ-.js";
import { lookupObject, traverseCollection } from "./vocab-C3GyhklV.js";
import { getLogger, withContext } from "@logtape/logtape";
import { SpanKind, SpanStatusCode, context, propagation, trace } from "@opentelemetry/api";
import { encodeHex } from "byte-encodings/hex";
import { cloneDeep } from "@es-toolkit/es-toolkit";
import { Router } from "uri-template-router";
import { parseTemplate } from "url-template";
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 federation/inbox.ts
var InboxListenerSet = class InboxListenerSet {
#listeners;
constructor() {
this.#listeners = /* @__PURE__ */ new Map();
}
clone() {
const clone = new InboxListenerSet();
clone.#listeners = new Map(this.#listeners);
return clone;
}
add(type, listener) {
if (this.#listeners.has(type)) throw new TypeError("Listener already set for this type.");
this.#listeners.set(type, listener);
}
dispatchWithClass(activity) {
let cls = activity.constructor;
const inboxListeners = this.#listeners;
if (inboxListeners == null) return null;
while (true) {
if (inboxListeners.has(cls)) break;
if (cls === Activity) return null;
cls = globalThis.Object.getPrototypeOf(cls);
}
const listener = inboxListeners.get(cls);
return {
class: cls,
listener
};
}
dispatch(activity) {
return this.dispatchWithClass(activity)?.listener ?? null;
}
};
async function routeActivity({ context: ctx, json, activity, recipient, inboxListeners, inboxContextFactory, inboxErrorHandler, kv, kvPrefixes, queue, span, tracerProvider }) {
const logger$1 = getLogger([
"fedify",
"federation",
"inbox"
]);
const cacheKey = activity.id == null ? null : [
...kvPrefixes.activityIdempotence,
ctx.origin,
activity.id.href
];
if (cacheKey != null) {
const cached = await kv.get(cacheKey);
if (cached === true) {
logger$1.debug("Activity {activityId} has already been processed.", {
activityId: activity.id?.href,
activity: json,
recipient
});
span.setStatus({
code: SpanStatusCode.UNSET,
message: `Activity ${activity.id?.href} has already been processed.`
});
return "alreadyProcessed";
}
}
if (activity.actorId == null) {
logger$1.error("Missing actor.", { activity: json });
span.setStatus({
code: SpanStatusCode.ERROR,
message: "Missing actor."
});
return "missingActor";
}
span.setAttribute("activitypub.actor.id", activity.actorId.href);
if (queue != null) {
const carrier = {};
propagation.inject(context.active(), carrier);
try {
await queue.enqueue({
type: "inbox",
id: crypto.randomUUID(),
baseUrl: ctx.origin,
activity: json,
identifier: recipient,
attempt: 0,
started: (/* @__PURE__ */ new Date()).toISOString(),
traceContext: carrier
});
} catch (error) {
logger$1.error("Failed to enqueue the incoming activity {activityId}:\n{error}", {
error,
activityId: activity.id?.href,
activity: json,
recipient
});
span.setStatus({
code: SpanStatusCode.ERROR,
message: `Failed to enqueue the incoming activity ${activity.id?.href}.`
});
throw error;
}
logger$1.info("Activity {activityId} is enqueued.", {
activityId: activity.id?.href,
activity: json,
recipient
});
return "enqueued";
}
tracerProvider = tracerProvider ?? trace.getTracerProvider();
const tracer = tracerProvider.getTracer(deno_default.name, deno_default.version);
return await tracer.startActiveSpan("activitypub.dispatch_inbox_listener", { kind: SpanKind.INTERNAL }, async (span$1) => {
const dispatched = inboxListeners?.dispatchWithClass(activity);
if (dispatched == null) {
logger$1.error("Unsupported activity type:\n{activity}", {
activity: json,
recipient
});
span$1.setStatus({
code: SpanStatusCode.UNSET,
message: `Unsupported activity type: ${getTypeId(activity).href}`
});
span$1.end();
return "unsupportedActivity";
}
const { class: cls, listener } = dispatched;
span$1.updateName(`activitypub.dispatch_inbox_listener ${cls.name}`);
try {
await listener(inboxContextFactory(recipient, json, activity?.id?.href, getTypeId(activity).href), activity);
} catch (error) {
try {
await inboxErrorHandler?.(ctx, error);
} catch (error$1) {
logger$1.error("An unexpected error occurred in inbox error handler:\n{error}", {
error: error$1,
activityId: activity.id?.href,
activity: json,
recipient
});
}
logger$1.error("Failed to process the incoming activity {activityId}:\n{error}", {
error,
activityId: activity.id?.href,
activity: json,
recipient
});
span$1.setStatus({
code: SpanStatusCode.ERROR,
message: String(error)
});
span$1.end();
return "error";
}
if (cacheKey != null) await kv.set(cacheKey, true, { ttl: Temporal.Duration.from({ days: 1 }) });
logger$1.info("Activity {activityId} has been processed.", {
activityId: activity.id?.href,
activity: json,
recipient
});
span$1.end();
return "success";
});
}
//#endregion
//#region federation/router.ts
function cloneInnerRouter(router) {
const clone = new Router();
clone.nid = router.nid;
clone.fsm = cloneDeep(router.fsm);
clone.routeSet = new Set(router.routeSet);
clone.templateRouteMap = new Map(router.templateRouteMap);
clone.valueRouteMap = new Map(router.valueRouteMap);
clone.hierarchy = cloneDeep(router.hierarchy);
return clone;
}
/**
* URL router and constructor based on URI Template
* ([RFC 6570](https://tools.ietf.org/html/rfc6570)).
*/
var Router$1 = class Router$1 {
#router;
#templates;
#templateStrings;
/**
* Whether to ignore trailing slashes when matching paths.
* @since 1.6.0
*/
trailingSlashInsensitive;
/**
* Create a new {@link Router}.
* @param options Options for the router.
*/
constructor(options = {}) {
this.#router = new Router();
this.#templates = {};
this.#templateStrings = {};
this.trailingSlashInsensitive = options.trailingSlashInsensitive ?? false;
}
clone() {
const clone = new Router$1({ trailingSlashInsensitive: this.trailingSlashInsensitive });
clone.#router = cloneInnerRouter(this.#router);
clone.#templates = { ...this.#templates };
clone.#templateStrings = { ...this.#templateStrings };
return clone;
}
/**
* Checks if a path name exists in the router.
* @param name The name of the path.
* @returns `true` if the path name exists, otherwise `false`.
*/
has(name) {
return name in this.#templates;
}
/**
* Adds a new path rule to the router.
* @param template The path pattern.
* @param name The name of the path.
* @returns The names of the variables in the path pattern.
*/
add(template, name) {
if (!template.startsWith("/")) throw new RouterError("Path must start with a slash.");
const rule = this.#router.addTemplate(template, {}, name);
this.#templates[name] = parseTemplate(template);
this.#templateStrings[name] = template;
return new Set(rule.variables.map((v) => v.varname));
}
/**
* Resolves a path name and values from a URL, if any match.
* @param url The URL to resolve.
* @returns The name of the path and its values, if any match. Otherwise,
* `null`.
*/
route(url) {
let match = this.#router.resolveURI(url);
if (match == null) {
if (!this.trailingSlashInsensitive) return null;
url = url.endsWith("/") ? url.replace(/\/+$/, "") : `${url}/`;
match = this.#router.resolveURI(url);
if (match == null) return null;
}
return {
name: match.matchValue,
template: this.#templateStrings[match.matchValue],
values: match.params
};
}
/**
* Constructs a URL/path from a path name and values.
* @param name The name of the path.
* @param values The values to expand the path with.
* @returns The URL/path, if the name exists. Otherwise, `null`.
*/
build(name, values) {
if (name in this.#templates) return this.#templates[name].expand(values);
return null;
}
};
/**
* An error thrown by the {@link Router}.
*/
var RouterError = class extends Error {
/**
* Create a new {@link RouterError}.
* @param message The error message.
*/
constructor(message) {
super(message);
this.name = "RouterError";
}
};
//#endregion
//#region federation/builder.ts
var FederationBuilderImpl = class {
router;
actorCallbacks;
nodeInfoDispatcher;
objectCallbacks;
objectTypeIds;
inboxPath;
inboxCallbacks;
outboxCallbacks;
followingCallbacks;
followersCallbacks;
likedCallbacks;
featuredCallbacks;
featuredTagsCallbacks;
inboxListeners;
inboxErrorHandler;
sharedInboxKeyDispatcher;
constructor() {
this.router = new Router$1();
this.objectCallbacks = {};
this.objectTypeIds = {};
}
async build(options) {
const { FederationImpl: FederationImpl$1 } = await import("./middleware-B9LOv9Q4.js");
const f = new FederationImpl$1(options);
const trailingSlashInsensitiveValue = f.router.trailingSlashInsensitive;
f.router = this.router.clone();
f.router.trailingSlashInsensitive = trailingSlashInsensitiveValue;
f._initializeRouter();
f.actorCallbacks = this.actorCallbacks == null ? void 0 : { ...this.actorCallbacks };
f.nodeInfoDispatcher = this.nodeInfoDispatcher;
f.objectCallbacks = { ...this.objectCallbacks };
f.objectTypeIds = { ...this.objectTypeIds };
f.inboxPath = this.inboxPath;
f.inboxCallbacks = this.inboxCallbacks == null ? void 0 : { ...this.inboxCallbacks };
f.outboxCallbacks = this.outboxCallbacks == null ? void 0 : { ...this.outboxCallbacks };
f.followingCallbacks = this.followingCallbacks == null ? void 0 : { ...this.followingCallbacks };
f.followersCallbacks = this.followersCallbacks == null ? void 0 : { ...this.followersCallbacks };
f.likedCallbacks = this.likedCallbacks == null ? void 0 : { ...this.likedCallbacks };
f.featuredCallbacks = this.featuredCallbacks == null ? void 0 : { ...this.featuredCallbacks };
f.featuredTagsCallbacks = this.featuredTagsCallbacks == null ? void 0 : { ...this.featuredTagsCallbacks };
f.inboxListeners = this.inboxListeners?.clone();
f.inboxErrorHandler = this.inboxErrorHandler;
f.sharedInboxKeyDispatcher = this.sharedInboxKeyDispatcher;
return f;
}
_getTracer() {
return trace.getTracer(deno_default.name, deno_default.version);
}
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$1, identifier) => {
const actor = await this._getTracer().startActiveSpan("activitypub.dispatch_actor", {
kind: SpanKind.SERVER,
attributes: { "fedify.actor.identifier": identifier }
}, async (span) => {
try {
const actor$1 = await dispatcher(context$1, identifier);
span.setAttribute("activitypub.actor.id", (actor$1?.id ?? context$1.getActorUri(identifier)).href);
if (actor$1 == null) span.setStatus({ code: SpanStatusCode.ERROR });
else span.setAttribute("activitypub.actor.type", getTypeId(actor$1).href);
return actor$1;
} catch (error) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: String(error)
});
throw error;
} finally {
span.end();
}
});
if (actor == null) return null;
const logger$1 = getLogger([
"fedify",
"federation",
"actor"
]);
if (actor.id == null) logger$1.warn("Actor dispatcher returned an actor without an id property. Set the property with Context.getActorUri(identifier).");
else if (actor.id.href != context$1.getActorUri(identifier).href) logger$1.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$1.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$1.getFollowingUri(identifier).href) logger$1.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$1.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$1.getFollowersUri(identifier).href) logger$1.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$1.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$1.getOutboxUri(identifier).href) logger$1.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$1.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$1.getLikedUri(identifier).href) logger$1.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$1.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$1.getFeaturedUri(identifier).href) logger$1.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$1.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$1.getFeaturedTagsUri(identifier).href) logger$1.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$1.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$1.getInboxUri(identifier).href) logger$1.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$1.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$1.getInboxUri().href) logger$1.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$1.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$1.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$1) => {
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$1(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;
}
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;
}
setObjectDispatcher(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;
},
setLastCursor(cursor) {
callbacks.lastCursor = cursor;
return setters;
},
authorize(predicate) {
callbacks.authorizePredicate = predicate;
return setters;
}
};
return setters;
}
setFeaturedTagsDispatcher(path, dispatcher) {
if (this.router.has("featuredTags")) throw new RouterError("Featured tags collection dispatcher already set.");
const variables = this.router.add(path, "featuredTags");
if (variables.size !== 1 || !(variables.has("identifier") || variables.has("handle"))) throw new RouterError("Path for featured tags collection dispatcher must have one variable: {identifier}");
if (variables.has("handle")) getLogger([
"fedify",
"federation",
"collection"
]).warn("The {{handle}} variable in the featured tags collection dispatcher path is deprecated. Use {{identifier}} instead.");
const callbacks = { dispatcher };
this.featuredTagsCallbacks = 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;
}
setInboxListeners(inboxPath, sharedInboxPath) {
if (this.inboxListeners != null) throw new RouterError("Inbox listeners already set.");
if (this.router.has("inbox")) {
if (this.inboxPath !== inboxPath) throw new RouterError("Inbox listener path must match inbox dispatcher path.");
} else {
const variables = this.router.add(inboxPath, "inbox");
if (variables.size !== 1 || !(variables.has("identifier") || variables.has("handle"))) throw new RouterError("Path for inbox must have one variable: {identifier}");
this.inboxPath = inboxPath;
if (variables.has("handle")) getLogger([
"fedify",
"federation",
"inbox"
]).warn("The {{handle}} variable in the inbox path is deprecated. Use {{identifier}} instead.");
}
if (sharedInboxPath != null) {
const siVars = this.router.add(sharedInboxPath, "sharedInbox");
if (siVars.size !== 0) throw new RouterError("Path for shared inbox must have no variables.");
}
const listeners = this.inboxListeners = new InboxListenerSet();
const setters = {
on(type, listener) {
listeners.add(type, listener);
return setters;
},
onError: (handler) => {
this.inboxErrorHandler = handler;
return setters;
},
setSharedKeyDispatcher: (dispatcher) => {
this.sharedInboxKeyDispatcher = dispatcher;
return setters;
}
};
return setters;
}
};
/**
* Creates a new {@link FederationBuilder} instance.
* @returns A new {@link FederationBuilder} instance.
* @since 1.6.0
*/
function createFederationBuilder() {
return new FederationBuilderImpl();
}
//#endregion
//#region federation/collection.ts
/**
* Calculates the [partial follower collection digest][1].
*
* [1]: https://w3id.org/fep/8fcf#partial-follower-collection-digest
* @param uris The URIs to calculate the digest. Duplicate URIs are ignored.
* @returns The digest.
*/
async function digest(uris) {
const processed = /* @__PURE__ */ new Set();
const encoder = new TextEncoder();
const result = new Uint8Array(32);
for (const uri of uris) {
const u = uri instanceof URL ? uri.href : uri;
if (processed.has(u)) continue;
processed.add(u);
const encoded = encoder.encode(u);
const digest$1 = new Uint8Array(await crypto.subtle.digest("SHA-256", encoded));
for (let i = 0; i < 32; i++) result[i] ^= digest$1[i];
}
return result;
}
/**
* Builds [`Collection-Synchronization`][1] header content.
*
* [1]: https://w3id.org/fep/8fcf#the-collection-synchronization-http-header
*
* @param collectionId The sender's followers collection URI.
* @param actorIds The actor URIs to digest.
* @returns The header content.
*/
async function buildCollectionSynchronizationHeader(collectionId, actorIds) {
const [anyActorId] = actorIds;
const baseUrl = new URL(anyActorId);
const url = new URL(collectionId);
url.searchParams.set("base-url", `${baseUrl.origin}/`);
const hash = encodeHex(await digest(actorIds));
return `collectionId="${collectionId}", url="${url}", digest="${hash}"`;
}
//#endregion
//#region federation/keycache.ts
var KvKeyCache = class {
kv;
prefix;
options;
nullKeys;
constructor(kv, prefix, options = {}) {
this.kv = kv;
this.prefix = prefix;
this.nullKeys = /* @__PURE__ */ new Set();
this.options = options;
}
async get(keyId) {
if (this.nullKeys.has(keyId.href)) return null;
const serialized = await this.kv.get([...this.prefix, keyId.href]);
if (serialized == null) return void 0;
try {
return await CryptographicKey.fromJsonLd(serialized, this.options);
} catch {
try {
return await Multikey.fromJsonLd(serialized, this.options);
} catch {
await this.kv.delete([...this.prefix, keyId.href]);
return void 0;
}
}
}
async set(keyId, key) {
if (key == null) {
this.nullKeys.add(keyId.href);
await this.kv.delete([...this.prefix, keyId.href]);
return;
}
this.nullKeys.delete(keyId.href);
const serialized = await key.toJsonLd(this.options);
await this.kv.set([...this.prefix, keyId.href], serialized);
}
};
//#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$1 = getLogger([
"fedify",
"federation",
"actor"
]);
if (actorDispatcher == null) {
logger$1.debug("Actor dispatcher is not set.", { identifier });
return await onNotFound(request);
}
const actor = await actorDispatcher(context$1, identifier);
if (actor == null) {
logger$1.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$1 = getLogger([
"fedify",
"federation",
"inbox"
]);
if (actorDispatcher == null) {
logger$1.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$1.error("Actor {recipient} not found.", { recipient });
span.setStatus({
code: SpanStatusCode.ERROR,
message: `Actor ${recipient} not found.`
});
return await onNotFound(request);
}
}
if (request.bodyUsed) {
logger$1.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$1.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$1.error("Failed to parse JSON:\n{error}", {
recipient,
error
});
try {
await inboxErrorHandler?.(ctx, error);
} catch (error$1) {
logger$1.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$1.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$1.debug("Linked Data Signatures are verified.", {
recipient,
json
});
activity = await Activity.fromJsonLd(jsonWithoutSig, ctx);
} else {
logger$1.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$1.error("Failed to parse activity:\n{error}", {
recipient,
activity: json,
error
});
try {
await inboxErrorHandler?.(ctx, error);
} catch (error$1) {
logger$1.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$1.debug("Object Integrity Proofs are not verified.", {
recipient,
activity: json
});
else logger$1.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$1.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$1.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