diff --git a/federation/README.md b/federation/README.md index a3c7e30..bbcea52 100644 --- a/federation/README.md +++ b/federation/README.md @@ -52,6 +52,31 @@ const validNote = await validator.Note(validNoteObject); Your editor's IntelliSense should provide you with every method and property available, which all match the [**Lysand**](https://lysand.org) specification names. +#### Requester + +A `FederationRequester` class is provided to make requests to a remote server. It sets the correct headers and has multiple methods to make requesters easier. + +```typescript +import { FederationRequester } from "@lysand-org/federation/requester"; +import { SignatureConstructor } from "@lysand-org/federation/cryptography"; + +const requester = new FederationRequester( + new URL("https://example.com"), + new SignatureConstructor(privateKey, keyId), +); + +const { data, ok } = await requester.get("/users/1"); + +if (!ok) { + console.error(data); +} + +console.log(data); + +// Do a WebFinger request +const userProfileUri = await requester.webFinger("banana"); +``` + #### Validation Helper `RequestParserHandler` is a class to parse the body of a request and call the appropriate callback. It is a helper for the `EntityValidator` class. diff --git a/federation/cryptography/index.ts b/federation/cryptography/index.ts index 0c35c18..e7d9175 100644 --- a/federation/cryptography/index.ts +++ b/federation/cryptography/index.ts @@ -259,7 +259,7 @@ export class SignatureConstructor { async sign( method: HttpVerb, url: URL, - body: string, + body?: string, headers?: Headers, date?: Date, ): Promise<{ @@ -302,7 +302,7 @@ export class SignatureConstructor { return { request, signedString }; } - if (!(url && body && headers)) { + if (!(url && headers)) { throw new TypeError( "Missing or empty required parameters: url, body or headers", ); @@ -312,7 +312,7 @@ export class SignatureConstructor { const digest = await crypto.subtle.digest( "SHA-256", - new TextEncoder().encode(body), + new TextEncoder().encode(body ?? ""), ); const signedString = diff --git a/federation/jsr.jsonc b/federation/jsr.jsonc index a638048..4587782 100644 --- a/federation/jsr.jsonc +++ b/federation/jsr.jsonc @@ -4,6 +4,7 @@ "version": "0.0.0", "exports": { ".": "./index.ts", - "./types": "./schemas.ts" + "./types": "./schemas.ts", + "./requester": "./requester.ts" } } diff --git a/federation/package.json b/federation/package.json index 97074d3..f391064 100644 --- a/federation/package.json +++ b/federation/package.json @@ -42,6 +42,10 @@ "import": "./index.ts", "default": "./index.ts" }, + "./requester": { + "import": "./requester.ts", + "default": "./requester.ts" + }, "./types": { "import": "./schemas.ts", "default": "./schemas.ts" diff --git a/federation/requester.ts b/federation/requester.ts new file mode 100644 index 0000000..1aaeec7 --- /dev/null +++ b/federation/requester.ts @@ -0,0 +1,7 @@ +import { + FederationRequester, + type Output, + ResponseError, +} from "./requester/index"; + +export { type Output, ResponseError, FederationRequester }; diff --git a/federation/requester/constants.ts b/federation/requester/constants.ts new file mode 100644 index 0000000..f9bbf3a --- /dev/null +++ b/federation/requester/constants.ts @@ -0,0 +1,3 @@ +import pkg from "../package.json" with { type: "json" }; + +export const DEFAULT_UA = `LysandFederation/${pkg.version} (+${pkg.homepage})`; diff --git a/federation/requester/index.ts b/federation/requester/index.ts new file mode 100644 index 0000000..a0119a0 --- /dev/null +++ b/federation/requester/index.ts @@ -0,0 +1,202 @@ +import { fromZodError } from "zod-validation-error"; +import type { SignatureConstructor } from "../cryptography"; +import type { User } from "../schemas"; +import { WebFingerSchema } from "../schemas/webfinger"; +import { DEFAULT_UA } from "./constants"; + +type HttpVerb = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + +/** + * Output of a request. Contains the data and headers. + * @template ReturnType The type of the data returned by the request. + */ +export interface Output { + data: ReturnType; + ok: boolean; + raw: Response; +} + +/** + * Wrapper around Error, useful for detecting if an error + * is due to a failed request. + * + * Throws if the request returns invalid or unexpected data. + */ +export class ResponseError< + ReturnType = { + error?: string; + }, +> extends Error { + constructor( + public response: Output, + message: string, + ) { + super(message); + this.name = "ResponseError"; + } +} + +/** + * Class to handle requests to a remote server. + * @param serverUrl The URL of the server to send requests to. + * @param signatureConstructor The constructor to sign requests with. + * @param globalCatch A function to call when a request fails. + * @example + * const requester = new FederationRequester( + * new URL("https://example.com"), + * new SignatureConstructor(privateKey, keyId), + * ); + * + * const { data, ok } = await requester.get("/users/1"); + * + * if (!ok) { + * console.error(data); + * } + * + * console.log(data); + */ +export class FederationRequester { + constructor( + private serverUrl: URL, + private signatureConstructor: SignatureConstructor, + public globalCatch: (error: ResponseError) => void = () => { + // Do nothing by default + }, + ) {} + + get url(): URL { + return this.serverUrl; + } + + /** + * Get the user's profile link from their username. + * @param username The username to get the profile link for. + * @returns The user's profile link. + * @throws If the request fails or the response is invalid. + * @example + * const profileLink = await requester.webFinger("example"); + * + * console.log(profileLink); + * // => "https://example.com/users/1" + */ + public async webFinger(username: string): Promise { + const result = await this.get( + `/.well-known/webfinger?${new URLSearchParams({ + resource: `acct:${username}@${this.serverUrl.hostname}`, + })}`, + ); + + // Validate the response + const { error, success, data } = await WebFingerSchema.safeParseAsync( + result.data, + ); + + if (!success) { + throw fromZodError(error); + } + + // Get the first link with a rel of "self" + const selfLink = data.links?.find((link) => link.rel === "self"); + + if (!selfLink) { + throw new Error( + "No link with rel=self found in WebFinger response", + ); + } + + if (!selfLink.href) { + throw new Error( + "Link with rel=self has no href in WebFinger response", + ); + } + + // Return user's profile link + return selfLink.href; + } + + private async request( + request: Request, + ): Promise> { + const result = await fetch(request); + const isJson = result.headers.get("Content-Type")?.includes("json"); + + if (!result.ok) { + const error = isJson ? await result.json() : await result.text(); + throw new ResponseError( + { + data: error, + ok: false, + raw: result, + }, + `Request failed (${result.status}): ${ + error.error || error.message || result.statusText + }`, + ); + } + + return { + data: isJson ? await result.json() : (await result.text()) || null, + ok: true, + raw: result, + }; + } + + private async constructRequest( + path: string, + method: HttpVerb, + body?: object | FormData, + extra?: RequestInit, + ): Promise { + const headers = new Headers({ + "User-Agent": DEFAULT_UA, + }); + + if (body) { + headers.set("Content-Type", "application/json; charset=utf-8"); + } + + for (const [key, value] of Object.entries(extra?.headers || {})) { + headers.set(key, value); + } + + headers.set("Accept", "application/json"); + + const request = new Request(new URL(path, this.serverUrl).toString(), { + method, + headers, + body: body + ? body instanceof FormData + ? body + : JSON.stringify(body) + : undefined, + ...extra, + }); + + return (await this.signatureConstructor.sign(request)).request; + } + + public async get( + path: string, + extra?: RequestInit, + ): Promise> { + return this.request( + await this.constructRequest(path, "GET", undefined, extra), + ).catch((e) => { + this.globalCatch(e); + throw e; + }); + } + + public async post( + path: string, + body: object, + extra?: RequestInit, + ): Promise> { + return this.request( + await this.constructRequest(path, "POST", body, extra), + ).catch((e) => { + this.globalCatch(e); + throw e; + }); + } +} diff --git a/federation/schemas/webfinger.ts b/federation/schemas/webfinger.ts new file mode 100644 index 0000000..3110683 --- /dev/null +++ b/federation/schemas/webfinger.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; + +export const WebFingerSchema = z.object({ + subject: z.string().url(), + aliases: z.array(z.string().url()).optional(), + properties: z.record(z.string().url(), z.string().or(z.null())).optional(), + links: z + .array( + z.object({ + rel: z.string(), + type: z.string().optional(), + href: z.string().url().optional(), + titles: z.record(z.string(), z.string()).optional(), + properties: z + .record(z.string().url(), z.string().or(z.null())) + .optional(), + }), + ) + .optional(), +});