refactor(api): 🎨 Move User methods into their own class similar to Note

This commit is contained in:
Jesse Wierzbinski 2024-04-24 17:40:27 -10:00
parent abc8f1ae16
commit 9d70778abd
No known key found for this signature in database
81 changed files with 2055 additions and 1603 deletions

View file

@ -0,0 +1,262 @@
import type * as Lysand from "lysand-types";
import { fromZodError } from "zod-validation-error";
import { schemas } from "./schemas";
const types = [
"Note",
"User",
"Reaction",
"Poll",
"Vote",
"VoteResult",
"Report",
"ServerMetadata",
"Like",
"Dislike",
"Follow",
"FollowAccept",
"FollowReject",
"Announce",
"Undo",
];
/**
* Validates an incoming Lysand object using Zod, and returns the object if it is valid.
*/
export class EntityValidator {
constructor(private entity: Lysand.Entity) {}
/**
* Validates the entity.
*/
validate<ExpectedType>() {
// Check if type is valid
if (!this.entity.type) {
throw new Error("Entity type is required");
}
const schema = this.matchSchema(this.getType());
const output = schema.safeParse(this.entity);
if (!output.success) {
throw fromZodError(output.error);
}
return output.data as ExpectedType;
}
getType() {
// Check if type is valid, return TypeScript type
if (!this.entity.type) {
throw new Error("Entity type is required");
}
if (!types.includes(this.entity.type)) {
throw new Error(`Unknown entity type: ${this.entity.type}`);
}
return this.entity.type as (typeof types)[number];
}
matchSchema(type: string) {
switch (type) {
case "Note":
return schemas.Note;
case "User":
return schemas.User;
case "Reaction":
return schemas.Reaction;
case "Poll":
return schemas.Poll;
case "Vote":
return schemas.Vote;
case "VoteResult":
return schemas.VoteResult;
case "Report":
return schemas.Report;
case "ServerMetadata":
return schemas.ServerMetadata;
case "Like":
return schemas.Like;
case "Dislike":
return schemas.Dislike;
case "Follow":
return schemas.Follow;
case "FollowAccept":
return schemas.FollowAccept;
case "FollowReject":
return schemas.FollowReject;
case "Announce":
return schemas.Announce;
case "Undo":
return schemas.Undo;
default:
throw new Error(`Unknown entity type: ${type}`);
}
}
}
export class SignatureValidator {
constructor(
private public_key: CryptoKey,
private signature: string,
private date: string,
private method: string,
private url: URL,
private body: string,
) {}
static async fromStringKey(
public_key: string,
signature: string,
date: string,
method: string,
url: URL,
body: string,
) {
return new SignatureValidator(
await crypto.subtle.importKey(
"spki",
Buffer.from(public_key, "base64"),
"Ed25519",
false,
["verify"],
),
signature,
date,
method,
url,
body,
);
}
async validate() {
const signature = this.signature
.split("signature=")[1]
.replace(/"/g, "");
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(this.body),
);
const expectedSignedString =
`(request-target): ${this.method.toLowerCase()} ${
this.url.pathname
}\n` +
`host: ${this.url.host}\n` +
`date: ${this.date}\n` +
`digest: SHA-256=${Buffer.from(new Uint8Array(digest)).toString(
"base64",
)}\n`;
// Check if signed string is valid
const isValid = await crypto.subtle.verify(
"Ed25519",
this.public_key,
Buffer.from(signature, "base64"),
new TextEncoder().encode(expectedSignedString),
);
return isValid;
}
}
export class SignatureConstructor {
constructor(
private private_key: CryptoKey,
private url: URL,
private authorUri: URL,
) {}
static async fromStringKey(private_key: string, url: URL, authorUri: URL) {
return new SignatureConstructor(
await crypto.subtle.importKey(
"pkcs8",
Buffer.from(private_key, "base64"),
"Ed25519",
false,
["sign"],
),
url,
authorUri,
);
}
async sign(method: string, body: string) {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(body),
);
const date = new Date();
const signature = await crypto.subtle.sign(
"Ed25519",
this.private_key,
new TextEncoder().encode(
`(request-target): ${method.toLowerCase()} ${
this.url.pathname
}\n` +
`host: ${this.url.host}\n` +
`date: ${date.toISOString()}\n` +
`digest: SHA-256=${Buffer.from(
new Uint8Array(digest),
).toString("base64")}\n`,
),
);
const signatureBase64 = Buffer.from(new Uint8Array(signature)).toString(
"base64",
);
return {
date: date.toISOString(),
signature: `keyId="${this.authorUri.toString()}",algorithm="ed25519",headers="(request-target) host date digest",signature="${signatureBase64}"`,
};
}
}
/**
* Extends native fetch with object signing
* Make sure to format your JSON in Canonical JSON format!
* @param url URL to fetch
* @param options Standard Web Fetch API options
* @param privateKey Author private key in base64
* @param authorUri Author URI
* @param baseUrl Base URL of this server
* @returns Fetch response
*/
export const signedFetch = async (
url: string | URL,
options: RequestInit,
privateKey: string,
authorUri: string | URL,
baseUrl: string | URL,
) => {
const urlObj = new URL(url);
const authorUriObj = new URL(authorUri);
const signature = await SignatureConstructor.fromStringKey(
privateKey,
urlObj,
authorUriObj,
);
const { date, signature: signatureHeader } = await signature.sign(
options.method ?? "GET",
options.body?.toString() || "",
);
return fetch(url, {
...options,
headers: {
Date: date,
Origin: new URL(baseUrl).origin,
Signature: signatureHeader,
"Content-Type": "application/json; charset=utf-8",
Accept: "application/json",
...options.headers,
},
});
};

View file

@ -0,0 +1,6 @@
{
"name": "lysand-utils",
"version": "0.0.0",
"main": "index.ts",
"dependencies": { "zod": "^3.22.4", "zod-validation-error": "^3.1.0" }
}

View file

@ -0,0 +1,261 @@
import { z } from "zod";
const Entity = z.object({
id: z.string().uuid(),
created_at: z.string(),
uri: z.string().url(),
type: z.string(),
extensions: z.object({
"org.lysand:custom_emojis": z.object({
emojis: z.array(
z.object({
shortcode: z.string(),
url: z.string(),
}),
),
}),
}),
});
const ContentFormat = z.record(
z.string(),
z.object({
content: z.string(),
description: z.string().optional(),
size: z.number().optional(),
hash: z.record(z.string().optional()).optional(),
blurhash: z.string().optional(),
fps: z.number().optional(),
width: z.number().optional(),
height: z.number().optional(),
duration: z.number().optional(),
}),
);
const Visibility = z.enum(["public", "unlisted", "private", "direct"]);
const Publication = Entity.extend({
type: z.union([z.literal("Note"), z.literal("Patch")]),
author: z.string().url(),
content: ContentFormat.optional(),
attachments: z.array(ContentFormat).optional(),
replies_to: z.string().url().optional(),
quotes: z.string().url().optional(),
mentions: z.array(z.string().url()).optional(),
subject: z.string().optional(),
is_sensitive: z.boolean().optional(),
visibility: Visibility,
extensions: Entity.shape.extensions.extend({
"org.lysand:reactions": z
.object({
reactions: z.string(),
})
.optional(),
"org.lysand:polls": z
.object({
poll: z.object({
options: z.array(ContentFormat),
votes: z.array(z.number()),
multiple_choice: z.boolean().optional(),
expires_at: z.string(),
}),
})
.optional(),
}),
});
const Note = Publication.extend({
type: z.literal("Note"),
});
const Patch = Publication.extend({
type: z.literal("Patch"),
patched_id: z.string().uuid(),
patched_at: z.string(),
});
const ActorPublicKeyData = z.object({
public_key: z.string(),
actor: z.string().url(),
});
const VanityExtension = z.object({
avatar_overlay: ContentFormat.optional(),
avatar_mask: ContentFormat.optional(),
background: ContentFormat.optional(),
audio: ContentFormat.optional(),
pronouns: z.record(
z.string(),
z.array(
z.union([
z.object({
subject: z.string(),
object: z.string(),
dependent_possessive: z.string(),
independent_possessive: z.string(),
reflexive: z.string(),
}),
z.string(),
]),
),
),
birthday: z.string().optional(),
location: z.string().optional(),
activitypub: z.string().optional(),
});
const User = Entity.extend({
type: z.literal("User"),
display_name: z.string().optional(),
username: z.string(),
avatar: ContentFormat.optional(),
header: ContentFormat.optional(),
indexable: z.boolean(),
public_key: ActorPublicKeyData,
bio: ContentFormat.optional(),
fields: z
.array(
z.object({
name: ContentFormat,
value: ContentFormat,
}),
)
.optional(),
featured: z.string().url(),
followers: z.string().url(),
following: z.string().url(),
likes: z.string().url(),
dislikes: z.string().url(),
inbox: z.string().url(),
outbox: z.string().url(),
extensions: Entity.shape.extensions.extend({
"org.lysand:vanity": VanityExtension.optional(),
}),
});
const Action = Entity.extend({
type: z.union([
z.literal("Like"),
z.literal("Dislike"),
z.literal("Follow"),
z.literal("FollowAccept"),
z.literal("FollowReject"),
z.literal("Announce"),
z.literal("Undo"),
]),
author: z.string().url(),
});
const Like = Action.extend({
type: z.literal("Like"),
object: z.string().url(),
});
const Undo = Action.extend({
type: z.literal("Undo"),
object: z.string().url(),
});
const Dislike = Action.extend({
type: z.literal("Dislike"),
object: z.string().url(),
});
const Follow = Action.extend({
type: z.literal("Follow"),
followee: z.string().url(),
});
const FollowAccept = Action.extend({
type: z.literal("FollowAccept"),
follower: z.string().url(),
});
const FollowReject = Action.extend({
type: z.literal("FollowReject"),
follower: z.string().url(),
});
const Announce = Action.extend({
type: z.literal("Announce"),
object: z.string().url(),
});
const Extension = Entity.extend({
type: z.literal("Extension"),
extension_type: z.string(),
});
const Reaction = Extension.extend({
extension_type: z.literal("org.lysand:reactions/Reaction"),
object: z.string().url(),
content: z.string(),
});
const Poll = Extension.extend({
extension_type: z.literal("org.lysand:polls/Poll"),
options: z.array(ContentFormat),
votes: z.array(z.number()),
multiple_choice: z.boolean().optional(),
expires_at: z.string(),
});
const Vote = Extension.extend({
extension_type: z.literal("org.lysand:polls/Vote"),
poll: z.string().url(),
option: z.number(),
});
const VoteResult = Extension.extend({
extension_type: z.literal("org.lysand:polls/VoteResult"),
poll: z.string().url(),
votes: z.array(z.number()),
});
const Report = Extension.extend({
extension_type: z.literal("org.lysand:reports/Report"),
objects: z.array(z.string().url()),
reason: z.string(),
comment: z.string().optional(),
});
const ServerMetadata = Entity.extend({
type: z.literal("ServerMetadata"),
name: z.string(),
version: z.string(),
description: z.string().optional(),
website: z.string().optional(),
moderators: z.array(z.string()).optional(),
admins: z.array(z.string()).optional(),
logo: ContentFormat.optional(),
banner: ContentFormat.optional(),
supported_extensions: z.array(z.string()),
extensions: z.record(z.string(), z.any()).optional(),
});
export const schemas = {
Entity,
ContentFormat,
Visibility,
Publication,
Note,
Patch,
ActorPublicKeyData,
VanityExtension,
User,
Action,
Like,
Undo,
Dislike,
Follow,
FollowAccept,
FollowReject,
Announce,
Extension,
Reaction,
Poll,
Vote,
VoteResult,
Report,
ServerMetadata,
};