@fedify/fedify
Version:
An ActivityPub server framework
561 lines (560 loc) • 22.7 kB
JavaScript
import { getLogger } from "@logtape/logtape";
import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
import { accepts } from "../deps/jsr.io/@std/http/1.0.13/negotiation.js";
import metadata from "../deno.js";
import { verifyRequest } from "../sig/http.js";
import { detachSignature, verifyJsonLd } from "../sig/ld.js";
import { doesActorOwnKey } from "../sig/owner.js";
import { verifyObject } from "../sig/proof.js";
import { getTypeId } from "../vocab/type.js";
import { Activity, Link, Object, OrderedCollection, OrderedCollectionPage, } from "../vocab/vocab.js";
import { routeActivity } from "./inbox.js";
import { KvKeyCache } from "./keycache.js";
export function acceptsJsonLd(request) {
const types = accepts(request);
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");
}
export async function handleActor(request, { identifier, context, actorDispatcher, authorizePredicate, onNotFound, onNotAcceptable, onUnauthorized, }) {
const logger = getLogger(["fedify", "federation", "actor"]);
if (actorDispatcher == null) {
logger.debug("Actor dispatcher is not set.", { identifier });
return await onNotFound(request);
}
const actor = await actorDispatcher(context, identifier);
if (actor == null) {
logger.debug("Actor {identifier} not found.", { identifier });
return await onNotFound(request);
}
if (!acceptsJsonLd(request))
return await onNotAcceptable(request);
if (authorizePredicate != null) {
let key = await context.getSignedKey();
key = key?.clone({}, {
// @ts-expect-error: $warning is not part of the type definition
$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.getSignedKeyOwner();
keyOwner = keyOwner?.clone({}, {
// @ts-expect-error: $warning is not part of the type definition
$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, identifier, key, keyOwner)) {
return await onUnauthorized(request);
}
}
const jsonLd = await actor.toJsonLd(context);
return new Response(JSON.stringify(jsonLd), {
headers: {
"Content-Type": "application/activity+json",
Vary: "Accept",
},
});
}
export async function handleObject(request, { values, context, objectDispatcher, authorizePredicate, onNotFound, onNotAcceptable, onUnauthorized, }) {
if (objectDispatcher == null)
return await onNotFound(request);
const object = await objectDispatcher(context, values);
if (object == null)
return await onNotFound(request);
if (!acceptsJsonLd(request))
return await onNotAcceptable(request);
if (authorizePredicate != null) {
let key = await context.getSignedKey();
key = key?.clone({}, {
// @ts-expect-error: $warning is not part of the type definition
$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.getSignedKeyOwner();
keyOwner = keyOwner?.clone({}, {
// @ts-expect-error: $warning is not part of the type definition
$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, values, key, keyOwner)) {
return await onUnauthorized(request);
}
}
const jsonLd = await object.toJsonLd(context);
return new Response(JSON.stringify(jsonLd), {
headers: {
"Content-Type": "application/activity+json",
Vary: "Accept",
},
});
}
export async function handleCollection(request, { name, identifier, uriGetter, filter, filterPredicate, context, collectionCallbacks, tracerProvider, onUnauthorized, onNotFound, onNotAcceptable, }) {
const spanName = name.trim().replace(/\s+/g, "_");
tracerProvider = tracerProvider ?? trace.getTracerProvider();
const tracer = tracerProvider.getTracer(metadata.name, metadata.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, identifier);
const totalItems = filter == null
? await collectionCallbacks.counter?.(context, identifier)
: undefined;
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, 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, identifier);
const first = new URL(context.url);
first.searchParams.set("cursor", firstCursor);
let last = null;
if (lastCursor != null) {
last = new URL(context.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, 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.url);
prev.searchParams.set("cursor", prevCursor);
}
let next = null;
if (nextCursor != null) {
next = new URL(context.url);
next.searchParams.set("cursor", nextCursor);
}
const partOf = new URL(context.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.getSignedKey();
key = key?.clone({}, {
// @ts-expect-error: $warning is not part of the type definition
$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.getSignedKeyOwner();
keyOwner = keyOwner?.clone({}, {
// @ts-expect-error: $warning is not part of the type definition
$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, identifier, key, keyOwner)) {
return await onUnauthorized(request);
}
}
const jsonLd = await collection.toJsonLd(context);
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 || 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;
}
export async function handleInbox(request, options) {
const tracerProvider = options.tracerProvider ?? trace.getTracerProvider();
const tracer = tracerProvider.getTracer(metadata.name, metadata.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 = getLogger(["fedify", "federation", "inbox"]);
if (actorDispatcher == null) {
logger.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.error("Actor {recipient} not found.", { recipient });
span.setStatus({
code: SpanStatusCode.ERROR,
message: `Actor ${recipient} not found.`,
});
return await onNotFound(request);
}
}
if (request.bodyUsed) {
logger.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.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.error("Failed to parse JSON:\n{error}", { recipient, error });
try {
await inboxErrorHandler?.(ctx, error);
}
catch (error) {
logger.error("An unexpected error occurred in inbox error handler:\n{error}", { error, 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);
const ldSigVerified = await verifyJsonLd(json, {
contextLoader: ctx.contextLoader,
documentLoader: ctx.documentLoader,
keyCache,
tracerProvider,
});
const jsonWithoutSig = detachSignature(json);
let activity = null;
if (ldSigVerified) {
logger.debug("Linked Data Signatures are verified.", { recipient, json });
activity = await Activity.fromJsonLd(jsonWithoutSig, ctx);
}
else {
logger.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.error("Failed to parse activity:\n{error}", {
recipient,
activity: json,
error,
});
try {
await inboxErrorHandler?.(ctx, error);
}
catch (error) {
logger.error("An unexpected error occurred in inbox error handler:\n{error}", { error, 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.debug("Object Integrity Proofs are not verified.", { recipient, activity: json });
}
else {
logger.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.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.debug("HTTP Signatures are verified.", { recipient });
}
httpSigKey = key;
}
activity = await Activity.fromJsonLd(jsonWithoutSig, ctx);
}
if (activity.id != null) {
span.setAttribute("activitypub.activity.id", activity.id.href);
}
span.setAttribute("activitypub.activity.type", getTypeId(activity).href);
const routeResult = await routeActivity({
context: ctx,
json,
activity,
recipient,
inboxListeners,
inboxContextFactory,
inboxErrorHandler,
kv,
kvPrefixes,
queue,
span,
tracerProvider,
});
if (httpSigKey != null && !await doesActorOwnKey(activity, httpSigKey, ctx)) {
logger.error("The signer ({keyId}) and the actor ({actorId}) do not match.", {
activity: json,
recipient,
keyId: httpSigKey.id?.href,
actorId: activity.actorId?.href,
});
span.setStatus({
code: SpanStatusCode.ERROR,
message: `The signer (${httpSigKey.id?.href}) and ` +
`the actor (${activity.actorId?.href}) do not match.`,
});
return new Response("The signer and the actor do not match.", {
status: 401,
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
if (routeResult === "alreadyProcessed") {
return new Response(`Activity <${activity.id}> has already been processed.`, {
status: 202,
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
else if (routeResult === "missingActor") {
return new Response("Missing actor.", {
status: 400,
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
else if (routeResult === "enqueued") {
return new Response("Activity is enqueued.", {
status: 202,
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
else if (routeResult === "unsupportedActivity") {
return new Response("", {
status: 202,
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
else if (routeResult === "error") {
return new Response("Internal server error.", {
status: 500,
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
else {
return new Response("", {
status: 202,
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
}
/**
* Responds with the given object in JSON-LD format.
*
* @param object The object to respond with.
* @param options Options.
* @since 0.3.0
*/
export async function respondWithObject(object, options) {
const jsonLd = await object.toJsonLd(options);
return new Response(JSON.stringify(jsonLd), {
headers: {
"Content-Type": "application/activity+json",
},
});
}
/**
* Responds with the given object in JSON-LD format if the request accepts
* JSON-LD.
*
* @param object The object to respond with.
* @param request The request to check for JSON-LD acceptability.
* @param options Options.
* @since 0.3.0
*/
export async function respondWithObjectIfAcceptable(object, request, options) {
if (!acceptsJsonLd(request))
return null;
const response = await respondWithObject(object, options);
response.headers.set("Vary", "Accept");
return response;
}