UNPKG

@fedify/fedify

Version:

An ActivityPub server framework

561 lines (560 loc) • 22.7 kB
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; }