Loading examples/blog/.vscode/settings.json +1 −0 Original line number Diff line number Diff line Loading @@ -29,6 +29,7 @@ "cSpell.words": [ "codegen", "deno", "docloader", "fedify", "fediverse", "preact", Loading examples/blog/federation/mod.ts +21 −2 Original line number Diff line number Diff line import { Federation } from "fedify/federation/middleware.ts"; import { fetchDocumentLoader, kvCache } from "fedify/runtime/docloader.ts"; import { Activity, Create, CryptographicKey, Follow, Note, Person, Undo, } from "fedify/vocab/mod.ts"; import { getBlog } from "../models/blog.ts"; import { openKv } from "../models/kv.ts"; import { countPosts, getPosts } from "../models/post.ts"; // The `Federation<TContextData>` object is a registry that registers // federation-related callbacks: export const federation = new Federation<Deno.Kv>(); export const federation = new Federation<Deno.Kv>({ treatHttps: true, documentLoader: kvCache({ loader: fetchDocumentLoader, kv: await openKv(), }), }); // Registers the actor dispatcher, which is responsible for creating a // `Actor` object (`Person` in this case) for a given actor URI. Loading @@ -28,7 +38,7 @@ federation.setActorDispatcher("/users/{handle}", async (ctx, handle) => { preferredUsername: handle, url: new URL("/", ctx.request.url), outbox: ctx.getOutboxUri(handle), inbox: new URL(`${ctx.getActorUri(handle)}/inbox`), // FIXME inbox: ctx.getInboxUri(handle), publicKey: new CryptographicKey({ id: new URL(`${ctx.getActorUri(handle)}#main-key`), owner: ctx.getActorUri(handle), Loading Loading @@ -87,3 +97,12 @@ federation.setOutboxDispatcher( // Treat the empty string as the first cursor: return ""; }); federation.setInboxListeners("/users/{handle}/inbox") .on(Follow, async (ctx, follow) => { console.log({ follow }); }) .on(Undo, async (ctx, undo) => { console.log({ undo }); }) .onError((e) => console.error(e)); federation/callback.ts +8 −0 Original line number Diff line number Diff line Loading @@ -35,3 +35,11 @@ export type OutboxCursor<TContextData> = ( context: Context<TContextData>, handle: string, ) => string | null | Promise<string | null>; /** * A callback that listens for activities in an inbox. */ export type InboxListener<TContextData, TActivity extends Activity> = ( context: Context<TContextData>, activity: TActivity, ) => void | Promise<void>; federation/context.ts +29 −3 Original line number Diff line number Diff line Loading @@ -16,16 +16,29 @@ export class Context<TContextData> { */ readonly data: TContextData; /** * The URL of the request. */ readonly url: URL; /** * Create a new context. * @param router The router used for the request. * @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, request: Request, data: TContextData) { constructor( router: Router, request: Request, data: TContextData, treatHttps = false, ) { this.#router = router; this.request = request; this.data = data; this.url = new URL(request.url); if (treatHttps) this.url.protocol = "https:"; } /** Loading @@ -38,7 +51,7 @@ export class Context<TContextData> { if (path == null) { throw new RouterError("No actor dispatcher registered."); } return new URL(path, this.request.url); return new URL(path, this.url); } /** Loading @@ -51,6 +64,19 @@ export class Context<TContextData> { if (path == null) { throw new RouterError("No outbox dispatcher registered."); } return new URL(path, this.request.url); return new URL(path, this.url); } /** * Builds the URI of an actor's inbox with the given handle. * @param handle The actor's handle. * @returns The actor's inbox URI. */ getInboxUri(handle: string): URL { const path = this.#router.build("inbox", { handle }); if (path == null) { throw new RouterError("No inbox path registered."); } return new URL(path, this.url); } } federation/handler.ts +138 −17 Original line number Diff line number Diff line import { OrderedCollection, OrderedCollectionPage } from "../vocab/mod.ts"; import { accepts } from "https://deno.land/std@0.217.0/http/mod.ts"; import { ActorDispatcher, InboxListener, OutboxCounter, OutboxCursor, OutboxDispatcher, } from "./callback.ts"; import { Context } from "./context.ts"; import { Router } from "./router.ts"; import { accepts } from "https://deno.land/std@0.217.0/http/mod.ts"; import { verify } from "../httpsig/mod.ts"; import { DocumentLoader } from "../runtime/docloader.ts"; import { isActor } from "../vocab/actor.ts"; import { Activity, OrderedCollection, OrderedCollectionPage, } from "../vocab/mod.ts"; function acceptsJsonLd(request: Request): boolean { const types = accepts(request); Loading @@ -21,8 +28,8 @@ function acceptsJsonLd(request: Request): boolean { export interface ActorHandlerParameters<TContextData> { handle: string; router: Router; contextData: TContextData; context: Context<TContextData>; documentLoader: DocumentLoader; actorDispatcher?: ActorDispatcher<TContextData>; onNotFound(request: Request): Response | Promise<Response>; onNotAcceptable(request: Request): Response | Promise<Response>; Loading @@ -32,13 +39,13 @@ export async function handleActor<TContextData>( request: Request, { handle, router, contextData, context, documentLoader, actorDispatcher, onNotFound, onNotAcceptable, }: ActorHandlerParameters<TContextData>, ) { ): Promise<Response> { if (actorDispatcher == null) { const response = onNotFound(request); return response instanceof Promise ? await response : response; Loading @@ -47,13 +54,12 @@ export async function handleActor<TContextData>( const response = onNotAcceptable(request); return response instanceof Promise ? await response : response; } const context = new Context(router, request, contextData); const actor = await actorDispatcher(context, handle); if (actor == null) { const response = onNotFound(request); return response instanceof Promise ? await response : response; } const jsonLd = await actor.toJsonLd(); const jsonLd = await actor.toJsonLd({ documentLoader }); return new Response(JSON.stringify(jsonLd), { headers: { "Content-Type": Loading @@ -65,8 +71,8 @@ export async function handleActor<TContextData>( export interface OutboxHandlerParameters<TContextData> { handle: string; router: Router; contextData: TContextData; context: Context<TContextData>; documentLoader: DocumentLoader; outboxDispatcher?: OutboxDispatcher<TContextData>; outboxCounter?: OutboxCounter<TContextData>; outboxFirstCursor?: OutboxCursor<TContextData>; Loading @@ -79,8 +85,8 @@ export async function handleOutbox<TContextData>( request: Request, { handle, router, contextData, context, documentLoader, outboxCounter, outboxFirstCursor, outboxLastCursor, Loading @@ -88,7 +94,7 @@ export async function handleOutbox<TContextData>( onNotFound, onNotAcceptable, }: OutboxHandlerParameters<TContextData>, ) { ): Promise<Response> { if (outboxDispatcher == null) { const response = onNotFound(request); return response instanceof Promise ? await response : response; Loading @@ -99,7 +105,6 @@ export async function handleOutbox<TContextData>( } const url = new URL(request.url); const cursor = url.searchParams.get("cursor"); const context = new Context(router, request, contextData); let collection: OrderedCollection | OrderedCollectionPage; if (cursor == null) { const firstCursorPromise = outboxFirstCursor?.(context, handle); Loading Loading @@ -167,7 +172,7 @@ export async function handleOutbox<TContextData>( items, }); } const jsonLd = await collection.toJsonLd(); const jsonLd = await collection.toJsonLd({ documentLoader }); return new Response(JSON.stringify(jsonLd), { headers: { "Content-Type": Loading @@ -176,3 +181,119 @@ export async function handleOutbox<TContextData>( }, }); } export interface InboxHandlerParameters<TContextData> { handle: string; context: Context<TContextData>; actorDispatcher?: ActorDispatcher<TContextData>; inboxListeners: Map< new (...args: unknown[]) => Activity, InboxListener<TContextData, Activity> >; inboxErrorHandler?: (error: Error) => void | Promise<void>; documentLoader: DocumentLoader; onNotFound(request: Request): Response | Promise<Response>; } export async function handleInbox<TContextData>( request: Request, { handle, context, actorDispatcher, inboxListeners, inboxErrorHandler, documentLoader, onNotFound, }: InboxHandlerParameters<TContextData>, ): Promise<Response> { if (actorDispatcher == null) { const response = onNotFound(request); return response instanceof Promise ? await response : response; } else { const promise = actorDispatcher(context, handle); const actor = promise instanceof Promise ? await promise : promise; if (actor == null) { const response = onNotFound(request); return response instanceof Promise ? await response : response; } } const keyId = await verify(request, documentLoader); if (keyId == null) { const response = new Response("Failed to verify the reuqest signature.", { status: 401, headers: { "Content-Type": "text/plain; charset=utf-8" }, }); return response; } let json: unknown; try { json = await request.json(); } catch (e) { const promise = inboxErrorHandler?.(e); if (promise instanceof Promise) await promise; return new Response("Invalid JSON.", { status: 400, headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } let activity: Activity; try { activity = await Activity.fromJsonLd(json, { documentLoader }); } catch (e) { const promise = inboxErrorHandler?.(e); if (promise instanceof Promise) await promise; return new Response("Invalid activity.", { status: 400, headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } if (activity.actorId == null) { const response = new Response("Missing actor.", { status: 400, headers: { "Content-Type": "text/plain; charset=utf-8" }, }); return response; } if (!await doesActorOwnKey(activity, keyId)) { const response = new Response("The signer and the actor do not match.", { status: 401, headers: { "Content-Type": "text/plain; charset=utf-8" }, }); return response; } // deno-lint-ignore no-explicit-any let cls: new (...args: any[]) => Activity = activity // deno-lint-ignore no-explicit-any .constructor as unknown as new (...args: any[]) => Activity; while (true) { if (inboxListeners.has(cls)) break; if (cls === Activity) { return new Response("", { status: 202, headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } cls = Object.getPrototypeOf(cls); } const listener = inboxListeners.get(cls)!; const promise = listener(context, activity); if (promise instanceof Promise) await promise; return new Response("", { status: 202, headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } async function doesActorOwnKey( activity: Activity, keyId: URL, ): Promise<boolean> { if (activity.actorId?.href === keyId.href.replace(/#.*$/, "")) return true; const actor = await activity.getActor(); if (actor == null || !isActor(actor)) return false; for (const publicKeyId of actor.publicKeyIds) { if (publicKeyId.href === keyId.href) return true; } return false; } Loading
examples/blog/.vscode/settings.json +1 −0 Original line number Diff line number Diff line Loading @@ -29,6 +29,7 @@ "cSpell.words": [ "codegen", "deno", "docloader", "fedify", "fediverse", "preact", Loading
examples/blog/federation/mod.ts +21 −2 Original line number Diff line number Diff line import { Federation } from "fedify/federation/middleware.ts"; import { fetchDocumentLoader, kvCache } from "fedify/runtime/docloader.ts"; import { Activity, Create, CryptographicKey, Follow, Note, Person, Undo, } from "fedify/vocab/mod.ts"; import { getBlog } from "../models/blog.ts"; import { openKv } from "../models/kv.ts"; import { countPosts, getPosts } from "../models/post.ts"; // The `Federation<TContextData>` object is a registry that registers // federation-related callbacks: export const federation = new Federation<Deno.Kv>(); export const federation = new Federation<Deno.Kv>({ treatHttps: true, documentLoader: kvCache({ loader: fetchDocumentLoader, kv: await openKv(), }), }); // Registers the actor dispatcher, which is responsible for creating a // `Actor` object (`Person` in this case) for a given actor URI. Loading @@ -28,7 +38,7 @@ federation.setActorDispatcher("/users/{handle}", async (ctx, handle) => { preferredUsername: handle, url: new URL("/", ctx.request.url), outbox: ctx.getOutboxUri(handle), inbox: new URL(`${ctx.getActorUri(handle)}/inbox`), // FIXME inbox: ctx.getInboxUri(handle), publicKey: new CryptographicKey({ id: new URL(`${ctx.getActorUri(handle)}#main-key`), owner: ctx.getActorUri(handle), Loading Loading @@ -87,3 +97,12 @@ federation.setOutboxDispatcher( // Treat the empty string as the first cursor: return ""; }); federation.setInboxListeners("/users/{handle}/inbox") .on(Follow, async (ctx, follow) => { console.log({ follow }); }) .on(Undo, async (ctx, undo) => { console.log({ undo }); }) .onError((e) => console.error(e));
federation/callback.ts +8 −0 Original line number Diff line number Diff line Loading @@ -35,3 +35,11 @@ export type OutboxCursor<TContextData> = ( context: Context<TContextData>, handle: string, ) => string | null | Promise<string | null>; /** * A callback that listens for activities in an inbox. */ export type InboxListener<TContextData, TActivity extends Activity> = ( context: Context<TContextData>, activity: TActivity, ) => void | Promise<void>;
federation/context.ts +29 −3 Original line number Diff line number Diff line Loading @@ -16,16 +16,29 @@ export class Context<TContextData> { */ readonly data: TContextData; /** * The URL of the request. */ readonly url: URL; /** * Create a new context. * @param router The router used for the request. * @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, request: Request, data: TContextData) { constructor( router: Router, request: Request, data: TContextData, treatHttps = false, ) { this.#router = router; this.request = request; this.data = data; this.url = new URL(request.url); if (treatHttps) this.url.protocol = "https:"; } /** Loading @@ -38,7 +51,7 @@ export class Context<TContextData> { if (path == null) { throw new RouterError("No actor dispatcher registered."); } return new URL(path, this.request.url); return new URL(path, this.url); } /** Loading @@ -51,6 +64,19 @@ export class Context<TContextData> { if (path == null) { throw new RouterError("No outbox dispatcher registered."); } return new URL(path, this.request.url); return new URL(path, this.url); } /** * Builds the URI of an actor's inbox with the given handle. * @param handle The actor's handle. * @returns The actor's inbox URI. */ getInboxUri(handle: string): URL { const path = this.#router.build("inbox", { handle }); if (path == null) { throw new RouterError("No inbox path registered."); } return new URL(path, this.url); } }
federation/handler.ts +138 −17 Original line number Diff line number Diff line import { OrderedCollection, OrderedCollectionPage } from "../vocab/mod.ts"; import { accepts } from "https://deno.land/std@0.217.0/http/mod.ts"; import { ActorDispatcher, InboxListener, OutboxCounter, OutboxCursor, OutboxDispatcher, } from "./callback.ts"; import { Context } from "./context.ts"; import { Router } from "./router.ts"; import { accepts } from "https://deno.land/std@0.217.0/http/mod.ts"; import { verify } from "../httpsig/mod.ts"; import { DocumentLoader } from "../runtime/docloader.ts"; import { isActor } from "../vocab/actor.ts"; import { Activity, OrderedCollection, OrderedCollectionPage, } from "../vocab/mod.ts"; function acceptsJsonLd(request: Request): boolean { const types = accepts(request); Loading @@ -21,8 +28,8 @@ function acceptsJsonLd(request: Request): boolean { export interface ActorHandlerParameters<TContextData> { handle: string; router: Router; contextData: TContextData; context: Context<TContextData>; documentLoader: DocumentLoader; actorDispatcher?: ActorDispatcher<TContextData>; onNotFound(request: Request): Response | Promise<Response>; onNotAcceptable(request: Request): Response | Promise<Response>; Loading @@ -32,13 +39,13 @@ export async function handleActor<TContextData>( request: Request, { handle, router, contextData, context, documentLoader, actorDispatcher, onNotFound, onNotAcceptable, }: ActorHandlerParameters<TContextData>, ) { ): Promise<Response> { if (actorDispatcher == null) { const response = onNotFound(request); return response instanceof Promise ? await response : response; Loading @@ -47,13 +54,12 @@ export async function handleActor<TContextData>( const response = onNotAcceptable(request); return response instanceof Promise ? await response : response; } const context = new Context(router, request, contextData); const actor = await actorDispatcher(context, handle); if (actor == null) { const response = onNotFound(request); return response instanceof Promise ? await response : response; } const jsonLd = await actor.toJsonLd(); const jsonLd = await actor.toJsonLd({ documentLoader }); return new Response(JSON.stringify(jsonLd), { headers: { "Content-Type": Loading @@ -65,8 +71,8 @@ export async function handleActor<TContextData>( export interface OutboxHandlerParameters<TContextData> { handle: string; router: Router; contextData: TContextData; context: Context<TContextData>; documentLoader: DocumentLoader; outboxDispatcher?: OutboxDispatcher<TContextData>; outboxCounter?: OutboxCounter<TContextData>; outboxFirstCursor?: OutboxCursor<TContextData>; Loading @@ -79,8 +85,8 @@ export async function handleOutbox<TContextData>( request: Request, { handle, router, contextData, context, documentLoader, outboxCounter, outboxFirstCursor, outboxLastCursor, Loading @@ -88,7 +94,7 @@ export async function handleOutbox<TContextData>( onNotFound, onNotAcceptable, }: OutboxHandlerParameters<TContextData>, ) { ): Promise<Response> { if (outboxDispatcher == null) { const response = onNotFound(request); return response instanceof Promise ? await response : response; Loading @@ -99,7 +105,6 @@ export async function handleOutbox<TContextData>( } const url = new URL(request.url); const cursor = url.searchParams.get("cursor"); const context = new Context(router, request, contextData); let collection: OrderedCollection | OrderedCollectionPage; if (cursor == null) { const firstCursorPromise = outboxFirstCursor?.(context, handle); Loading Loading @@ -167,7 +172,7 @@ export async function handleOutbox<TContextData>( items, }); } const jsonLd = await collection.toJsonLd(); const jsonLd = await collection.toJsonLd({ documentLoader }); return new Response(JSON.stringify(jsonLd), { headers: { "Content-Type": Loading @@ -176,3 +181,119 @@ export async function handleOutbox<TContextData>( }, }); } export interface InboxHandlerParameters<TContextData> { handle: string; context: Context<TContextData>; actorDispatcher?: ActorDispatcher<TContextData>; inboxListeners: Map< new (...args: unknown[]) => Activity, InboxListener<TContextData, Activity> >; inboxErrorHandler?: (error: Error) => void | Promise<void>; documentLoader: DocumentLoader; onNotFound(request: Request): Response | Promise<Response>; } export async function handleInbox<TContextData>( request: Request, { handle, context, actorDispatcher, inboxListeners, inboxErrorHandler, documentLoader, onNotFound, }: InboxHandlerParameters<TContextData>, ): Promise<Response> { if (actorDispatcher == null) { const response = onNotFound(request); return response instanceof Promise ? await response : response; } else { const promise = actorDispatcher(context, handle); const actor = promise instanceof Promise ? await promise : promise; if (actor == null) { const response = onNotFound(request); return response instanceof Promise ? await response : response; } } const keyId = await verify(request, documentLoader); if (keyId == null) { const response = new Response("Failed to verify the reuqest signature.", { status: 401, headers: { "Content-Type": "text/plain; charset=utf-8" }, }); return response; } let json: unknown; try { json = await request.json(); } catch (e) { const promise = inboxErrorHandler?.(e); if (promise instanceof Promise) await promise; return new Response("Invalid JSON.", { status: 400, headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } let activity: Activity; try { activity = await Activity.fromJsonLd(json, { documentLoader }); } catch (e) { const promise = inboxErrorHandler?.(e); if (promise instanceof Promise) await promise; return new Response("Invalid activity.", { status: 400, headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } if (activity.actorId == null) { const response = new Response("Missing actor.", { status: 400, headers: { "Content-Type": "text/plain; charset=utf-8" }, }); return response; } if (!await doesActorOwnKey(activity, keyId)) { const response = new Response("The signer and the actor do not match.", { status: 401, headers: { "Content-Type": "text/plain; charset=utf-8" }, }); return response; } // deno-lint-ignore no-explicit-any let cls: new (...args: any[]) => Activity = activity // deno-lint-ignore no-explicit-any .constructor as unknown as new (...args: any[]) => Activity; while (true) { if (inboxListeners.has(cls)) break; if (cls === Activity) { return new Response("", { status: 202, headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } cls = Object.getPrototypeOf(cls); } const listener = inboxListeners.get(cls)!; const promise = listener(context, activity); if (promise instanceof Promise) await promise; return new Response("", { status: 202, headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } async function doesActorOwnKey( activity: Activity, keyId: URL, ): Promise<boolean> { if (activity.actorId?.href === keyId.href.replace(/#.*$/, "")) return true; const actor = await activity.getActor(); if (actor == null || !isActor(actor)) return false; for (const publicKeyId of actor.publicKeyIds) { if (publicKeyId.href === keyId.href) return true; } return false; }