Loading examples/blog/federation/mod.ts +18 −1 Original line number Diff line number Diff line import { Federation } from "fedify/federation/middleware.ts"; import { fetchDocumentLoader, kvCache } from "fedify/runtime/docloader.ts"; import { isActor } from "fedify/vocab/actor.ts"; import { Accept, Activity, Create, CryptographicKey, Loading Loading @@ -100,7 +102,22 @@ federation.setOutboxDispatcher( federation.setInboxListeners("/users/{handle}/inbox") .on(Follow, async (ctx, follow) => { console.log({ follow }); const blog = await getBlog(); if (blog == null) return; const actorUri = ctx.getActorUri(blog.handle); if (follow.objectId?.href != actorUri.href) { return; } const recipient = await follow.getActor(ctx); if (!isActor(recipient)) return; await ctx.sendActivity( { keyId: new URL(`${actorUri}#main-key`), privateKey: blog.privateKey }, recipient, new Accept({ actor: actorUri, object: follow, }), ); }) .on(Undo, async (ctx, undo) => { console.log({ undo }); Loading examples/blog/models/blog.ts +3 −3 Original line number Diff line number Diff line Loading @@ -25,7 +25,7 @@ export async function setBlog(blog: BlogInput): Promise<void> { name: "RSASSA-PKCS1-v1_5", modulusLength: 4096, publicExponent: new Uint8Array([0x01, 0x00, 0x01]), hash: "SHA-512", hash: "SHA-256", }, true, ["sign", "verify"], Loading Loading @@ -55,14 +55,14 @@ export async function getBlog(): Promise<Blog | null> { privateKey: await crypto.subtle.importKey( "jwk", entry.value.privateKey, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-512" }, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, true, ["sign"], ), publicKey: await crypto.subtle.importKey( "jwk", entry.value.publicKey, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-512" }, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, true, ["verify"], ), Loading federation/context.ts +43 −0 Original line number Diff line number Diff line import { validateCryptoKey } from "../httpsig/key.ts"; import { DocumentLoader } from "../runtime/docloader.ts"; import { Actor } from "../vocab/actor.ts"; import { Activity } from "../vocab/mod.ts"; import { Router, RouterError } from "./router.ts"; import { extractInboxes, sendActivity } from "./send.ts"; /** * A context for a request. Loading @@ -6,6 +11,11 @@ import { Router, RouterError } from "./router.ts"; export class Context<TContextData> { #router: Router; /** * The document loader used for loading remote JSON-LD documents. */ readonly documentLoader: DocumentLoader; /** * The request object. */ Loading @@ -24,17 +34,20 @@ export class Context<TContextData> { /** * Create a new context. * @param router The router used for the request. * @param documentLoader: The document loader used for JSON-LD context retrieval. * @param request The request object. * @param data The user-defined data associated with the context. * @param treatHttps Whether to treat the request as HTTPS even if it's not. */ constructor( router: Router, documentLoader: DocumentLoader, request: Request, data: TContextData, treatHttps = false, ) { this.#router = router; this.documentLoader = documentLoader; this.request = request; this.data = data; this.url = new URL(request.url); Loading Loading @@ -79,4 +92,34 @@ export class Context<TContextData> { } return new URL(path, this.url); } /** * Sends an activity to recipients' inboxes. * @param sender The sender's handle or sender's key pair. * @param recipients The recipients of the activity. * @param activity The activity to send. * @param options Options for sending the activity. */ async sendActivity( sender: { keyId: URL; privateKey: CryptoKey }, recipients: Actor | Actor[], activity: Activity, { preferSharedInbox }: { preferSharedInbox?: boolean } = {}, ): Promise<void> { const { keyId, privateKey } = sender; validateCryptoKey(privateKey, "private"); const inboxes = extractInboxes({ recipients: Array.isArray(recipients) ? recipients : [recipients], preferSharedInbox, }); for (const inbox of inboxes) { const successful = await sendActivity({ keyId, privateKey, activity, inbox, documentLoader: this.documentLoader, }); } } } federation/middleware.ts +1 −0 Original line number Diff line number Diff line Loading @@ -182,6 +182,7 @@ export class Federation<TContextData> { } const context = new Context( this.#router, this.#documentLoader, request, contextData, this.#treatHttps, Loading federation/send.ts 0 → 100644 +103 −0 Original line number Diff line number Diff line import { sign } from "../httpsig/mod.ts"; import { DocumentLoader } from "../runtime/docloader.ts"; import { Actor } from "../vocab/actor.ts"; import { Activity } from "../vocab/mod.ts"; /** * Parameters for {@link extractInboxes}. */ export interface ExtractInboxesParameters { /** * Actors to extract the inboxes from. */ recipients: Actor[]; /** * Whether to prefer the shared inbox over the personal inbox. * Defaults to `false`. */ preferSharedInbox?: boolean; } /** * Extracts the inbox URLs from recipients. * @param parameters The parameters to extract the inboxes. * See also {@link ExtractInboxesParameters}. * @returns The inbox URLs. */ export function extractInboxes( { recipients, preferSharedInbox }: ExtractInboxesParameters, ): Set<URL> { const inboxes = new Set<URL>(); for (const recipient of recipients) { let inbox = preferSharedInbox ? recipient.endpoints?.sharedInbox ?? recipient.inboxId : recipient.inboxId; if (inbox != null) inboxes.add(inbox); } return inboxes; } /** * Parameters for {@link sendActivity}. */ export interface SendActivityParameters { /** * The activity to send. */ activity: Activity; /** * The actor's private key to sign the request. */ privateKey: CryptoKey; /** * The public key ID that corresponds to the private key. */ keyId: URL; /** * The inbox URL to send the activity to. */ inbox: URL; /** * The document loader to use for JSON-LD context retrieval. */ documentLoader?: DocumentLoader; } /** * Sends an {@link Activity} to an inbox. * * @param parameters The parameters for sending the activity. * See also {@link SendActivityParameters}. * @returns Whether the activity was successfully sent. */ export async function sendActivity( { activity, privateKey, keyId, inbox, documentLoader, }: SendActivityParameters, ): Promise<boolean> { if (activity.actorId == null) { throw new TypeError( "The activity to send must have at least one actor property.", ); } const jsonLd = await activity.toJsonLd({ documentLoader }); let request = new Request(inbox, { method: "POST", headers: { "Content-Type": "application/ld+json", }, body: JSON.stringify(jsonLd), }); request = await sign(request, privateKey, keyId); const response = await fetch(request); return response.ok; } Loading
examples/blog/federation/mod.ts +18 −1 Original line number Diff line number Diff line import { Federation } from "fedify/federation/middleware.ts"; import { fetchDocumentLoader, kvCache } from "fedify/runtime/docloader.ts"; import { isActor } from "fedify/vocab/actor.ts"; import { Accept, Activity, Create, CryptographicKey, Loading Loading @@ -100,7 +102,22 @@ federation.setOutboxDispatcher( federation.setInboxListeners("/users/{handle}/inbox") .on(Follow, async (ctx, follow) => { console.log({ follow }); const blog = await getBlog(); if (blog == null) return; const actorUri = ctx.getActorUri(blog.handle); if (follow.objectId?.href != actorUri.href) { return; } const recipient = await follow.getActor(ctx); if (!isActor(recipient)) return; await ctx.sendActivity( { keyId: new URL(`${actorUri}#main-key`), privateKey: blog.privateKey }, recipient, new Accept({ actor: actorUri, object: follow, }), ); }) .on(Undo, async (ctx, undo) => { console.log({ undo }); Loading
examples/blog/models/blog.ts +3 −3 Original line number Diff line number Diff line Loading @@ -25,7 +25,7 @@ export async function setBlog(blog: BlogInput): Promise<void> { name: "RSASSA-PKCS1-v1_5", modulusLength: 4096, publicExponent: new Uint8Array([0x01, 0x00, 0x01]), hash: "SHA-512", hash: "SHA-256", }, true, ["sign", "verify"], Loading Loading @@ -55,14 +55,14 @@ export async function getBlog(): Promise<Blog | null> { privateKey: await crypto.subtle.importKey( "jwk", entry.value.privateKey, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-512" }, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, true, ["sign"], ), publicKey: await crypto.subtle.importKey( "jwk", entry.value.publicKey, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-512" }, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, true, ["verify"], ), Loading
federation/context.ts +43 −0 Original line number Diff line number Diff line import { validateCryptoKey } from "../httpsig/key.ts"; import { DocumentLoader } from "../runtime/docloader.ts"; import { Actor } from "../vocab/actor.ts"; import { Activity } from "../vocab/mod.ts"; import { Router, RouterError } from "./router.ts"; import { extractInboxes, sendActivity } from "./send.ts"; /** * A context for a request. Loading @@ -6,6 +11,11 @@ import { Router, RouterError } from "./router.ts"; export class Context<TContextData> { #router: Router; /** * The document loader used for loading remote JSON-LD documents. */ readonly documentLoader: DocumentLoader; /** * The request object. */ Loading @@ -24,17 +34,20 @@ export class Context<TContextData> { /** * Create a new context. * @param router The router used for the request. * @param documentLoader: The document loader used for JSON-LD context retrieval. * @param request The request object. * @param data The user-defined data associated with the context. * @param treatHttps Whether to treat the request as HTTPS even if it's not. */ constructor( router: Router, documentLoader: DocumentLoader, request: Request, data: TContextData, treatHttps = false, ) { this.#router = router; this.documentLoader = documentLoader; this.request = request; this.data = data; this.url = new URL(request.url); Loading Loading @@ -79,4 +92,34 @@ export class Context<TContextData> { } return new URL(path, this.url); } /** * Sends an activity to recipients' inboxes. * @param sender The sender's handle or sender's key pair. * @param recipients The recipients of the activity. * @param activity The activity to send. * @param options Options for sending the activity. */ async sendActivity( sender: { keyId: URL; privateKey: CryptoKey }, recipients: Actor | Actor[], activity: Activity, { preferSharedInbox }: { preferSharedInbox?: boolean } = {}, ): Promise<void> { const { keyId, privateKey } = sender; validateCryptoKey(privateKey, "private"); const inboxes = extractInboxes({ recipients: Array.isArray(recipients) ? recipients : [recipients], preferSharedInbox, }); for (const inbox of inboxes) { const successful = await sendActivity({ keyId, privateKey, activity, inbox, documentLoader: this.documentLoader, }); } } }
federation/middleware.ts +1 −0 Original line number Diff line number Diff line Loading @@ -182,6 +182,7 @@ export class Federation<TContextData> { } const context = new Context( this.#router, this.#documentLoader, request, contextData, this.#treatHttps, Loading
federation/send.ts 0 → 100644 +103 −0 Original line number Diff line number Diff line import { sign } from "../httpsig/mod.ts"; import { DocumentLoader } from "../runtime/docloader.ts"; import { Actor } from "../vocab/actor.ts"; import { Activity } from "../vocab/mod.ts"; /** * Parameters for {@link extractInboxes}. */ export interface ExtractInboxesParameters { /** * Actors to extract the inboxes from. */ recipients: Actor[]; /** * Whether to prefer the shared inbox over the personal inbox. * Defaults to `false`. */ preferSharedInbox?: boolean; } /** * Extracts the inbox URLs from recipients. * @param parameters The parameters to extract the inboxes. * See also {@link ExtractInboxesParameters}. * @returns The inbox URLs. */ export function extractInboxes( { recipients, preferSharedInbox }: ExtractInboxesParameters, ): Set<URL> { const inboxes = new Set<URL>(); for (const recipient of recipients) { let inbox = preferSharedInbox ? recipient.endpoints?.sharedInbox ?? recipient.inboxId : recipient.inboxId; if (inbox != null) inboxes.add(inbox); } return inboxes; } /** * Parameters for {@link sendActivity}. */ export interface SendActivityParameters { /** * The activity to send. */ activity: Activity; /** * The actor's private key to sign the request. */ privateKey: CryptoKey; /** * The public key ID that corresponds to the private key. */ keyId: URL; /** * The inbox URL to send the activity to. */ inbox: URL; /** * The document loader to use for JSON-LD context retrieval. */ documentLoader?: DocumentLoader; } /** * Sends an {@link Activity} to an inbox. * * @param parameters The parameters for sending the activity. * See also {@link SendActivityParameters}. * @returns Whether the activity was successfully sent. */ export async function sendActivity( { activity, privateKey, keyId, inbox, documentLoader, }: SendActivityParameters, ): Promise<boolean> { if (activity.actorId == null) { throw new TypeError( "The activity to send must have at least one actor property.", ); } const jsonLd = await activity.toJsonLd({ documentLoader }); let request = new Request(inbox, { method: "POST", headers: { "Content-Type": "application/ld+json", }, body: JSON.stringify(jsonLd), }); request = await sign(request, privateKey, keyId); const response = await fetch(request); return response.ok; }