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

@ -5,6 +5,7 @@ import {
desc,
eq,
inArray,
isNotNull,
} from "drizzle-orm";
import { htmlToText } from "html-to-text";
import type * as Lysand from "lysand-types";
@ -30,17 +31,7 @@ import {
contentToHtml,
findFirstNote,
findManyNotes,
getStatusUri,
} from "~database/entities/Status";
import {
type User,
type UserWithRelations,
type UserWithRelationsAndRelationships,
findManyUsers,
getUserUri,
userToAPI,
userToMention,
} from "~database/entities/User";
import { db } from "~drizzle/db";
import {
Attachments,
@ -48,13 +39,12 @@ import {
NoteToMentions,
Notes,
Notifications,
UserToPinnedNotes,
Users,
UsersRelations,
} from "~drizzle/schema";
import { config } from "~packages/config-manager";
import type { Attachment as APIAttachment } from "~types/mastodon/attachment";
import type { Status as APIStatus } from "~types/mastodon/status";
import { User } from "./user";
/**
* Gives helpers to fetch notes from database in a nice format
@ -101,36 +91,44 @@ export class Note {
return found.map((s) => new Note(s));
}
get id() {
return this.status.id;
}
async getUsersToFederateTo() {
// Mentioned users
const mentionedUsers =
this.getStatus().mentions.length > 0
? await findManyUsers({
where: (user, { and, isNotNull, inArray }) =>
and(
isNotNull(user.instanceId),
inArray(
user.id,
this.getStatus().mentions.map(
(mention) => mention.id,
),
? await User.manyFromSql(
and(
isNotNull(Users.instanceId),
inArray(
Users.id,
this.getStatus().mentions.map(
(mention) => mention.id,
),
),
})
),
)
: [];
const usersThatCanSeePost = await findManyUsers({
where: (user, { isNotNull }) => isNotNull(user.instanceId),
with: {
relationships: {
where: (relationship, { eq, and }) =>
and(
eq(relationship.subjectId, Users.id),
eq(relationship.following, true),
),
const usersThatCanSeePost = await User.manyFromSql(
isNotNull(Users.instanceId),
undefined,
undefined,
undefined,
{
with: {
relationships: {
where: (relationship, { eq, and }) =>
and(
eq(relationship.subjectId, Users.id),
eq(relationship.following, true),
),
},
},
},
});
);
const fusedUsers = [...mentionedUsers, ...usersThatCanSeePost];
@ -159,39 +157,13 @@ export class Note {
}
getAuthor() {
return this.status.author;
return new User(this.status.author);
}
async getReplyChildren() {
return await Note.manyFromSql(eq(Notes.replyId, this.status.id));
}
async pin(pinner: User) {
return (
await db
.insert(UserToPinnedNotes)
.values({
noteId: this.status.id,
userId: pinner.id,
})
.returning()
)[0];
}
async unpin(unpinner: User) {
return (
await db
.delete(UserToPinnedNotes)
.where(
and(
eq(NoteToMentions.noteId, this.status.id),
eq(NoteToMentions.userId, unpinner.id),
),
)
.returning()
)[0];
}
static async insert(values: InferInsertModel<typeof Notes>) {
return (await db.insert(Notes).values(values).returning())[0];
}
@ -204,7 +176,7 @@ export class Note {
spoiler_text: string,
emojis: EmojiWithInstance[],
uri?: string,
mentions?: UserWithRelations[],
mentions?: User[],
/** List of IDs of database Attachment objects */
media_attachments?: string[],
replyId?: string,
@ -216,7 +188,7 @@ export class Note {
// Parse emojis and fuse with existing emojis
let foundEmojis = emojis;
if (author.instanceId === null) {
if (author.isLocal()) {
const parsedEmojis = await parseEmojis(htmlContent);
// Fuse and deduplicate
foundEmojis = [...emojis, ...parsedEmojis].filter(
@ -277,7 +249,7 @@ export class Note {
// Send notifications for mentioned local users
for (const mention of mentions ?? []) {
if (mention.instanceId === null) {
if (mention.isLocal()) {
await db.insert(Notifications).values({
accountId: author.id,
notifiedId: mention.id,
@ -296,7 +268,7 @@ export class Note {
is_sensitive?: boolean,
spoiler_text?: string,
emojis: EmojiWithInstance[] = [],
mentions: UserWithRelations[] = [],
mentions: User[] = [],
/** List of IDs of database Attachment objects */
media_attachments: string[] = [],
) {
@ -307,7 +279,7 @@ export class Note {
// Parse emojis and fuse with existing emojis
let foundEmojis = emojis;
if (this.getAuthor().instanceId === null && htmlContent) {
if (this.getAuthor().isLocal() && htmlContent) {
const parsedEmojis = await parseEmojis(htmlContent);
// Fuse and deduplicate
foundEmojis = [...emojis, ...parsedEmojis].filter(
@ -401,7 +373,7 @@ export class Note {
* @param user The user to check.
* @returns Whether this status is viewable by the user.
*/
async isViewableByUser(user: UserWithRelations | null) {
async isViewableByUser(user: User | null) {
if (this.getAuthor().id === user?.id) return true;
if (this.getStatus().visibility === "public") return true;
if (this.getStatus().visibility === "unlisted") return true;
@ -423,7 +395,7 @@ export class Note {
);
}
async toAPI(userFetching?: UserWithRelations | null): Promise<APIStatus> {
async toAPI(userFetching?: User | null): Promise<APIStatus> {
const data = this.getStatus();
const wasPinnedByUser = userFetching
? !!(await db.query.UserToPinnedNotes.findFirst({
@ -480,7 +452,7 @@ export class Note {
id: data.id,
in_reply_to_id: data.replyId || null,
in_reply_to_account_id: data.reply?.authorId || null,
account: userToAPI(data.author),
account: this.getAuthor().toAPI(userFetching?.id === data.authorId),
created_at: new Date(data.createdAt).toISOString(),
application: data.application
? applicationToAPI(data.application)
@ -495,7 +467,16 @@ export class Note {
media_attachments: (data.attachments ?? []).map(
(a) => attachmentToAPI(a) as APIAttachment,
),
mentions: data.mentions.map((mention) => userToMention(mention)),
mentions: data.mentions.map((mention) => ({
id: mention.id,
acct: User.getAcct(
mention.instanceId === null,
mention.username,
mention.instance?.baseUrl,
),
url: User.getUri(mention.id, mention.uri, config.http.base_url),
username: mention.username,
})),
language: null,
muted: wasMutedByUser,
pinned: wasPinnedByUser,
@ -531,8 +512,13 @@ export class Note {
return localObjectURI(this.getStatus().id);
}
static getURI(id?: string | null) {
if (!id) return null;
return localObjectURI(id);
}
getMastoURI() {
return `/@${this.getAuthor().username}/${this.getStatus().id}`;
return `/@${this.getAuthor().getUser().username}/${this.id}`;
}
toLysand(): Lysand.Note {
@ -541,7 +527,7 @@ export class Note {
type: "Note",
created_at: new Date(status.createdAt).toISOString(),
id: status.id,
author: getUserUri(status.author),
author: this.getAuthor().getUri(),
uri: this.getURI(),
content: {
"text/html": {
@ -556,8 +542,8 @@ export class Note {
),
is_sensitive: status.sensitive,
mentions: status.mentions.map((mention) => mention.uri || ""),
quotes: getStatusUri(status.quote) ?? undefined,
replies_to: getStatusUri(status.reply) ?? undefined,
quotes: Note.getURI(status.quotingId) ?? undefined,
replies_to: Note.getURI(status.replyId) ?? undefined,
subject: status.spoilerText,
visibility: status.visibility as Lysand.Visibility,
extensions: {
@ -572,7 +558,7 @@ export class Note {
/**
* Return all the ancestors of this post,
*/
async getAncestors(fetcher: UserWithRelationsAndRelationships | null) {
async getAncestors(fetcher: User | null) {
const ancestors: Note[] = [];
let currentStatus: Note = this;
@ -599,10 +585,7 @@ export class Note {
* Return all the descendants of this post (recursive)
* Temporary implementation, will be replaced with a recursive SQL query when I get to it
*/
async getDescendants(
fetcher: UserWithRelationsAndRelationships | null,
depth = 0,
) {
async getDescendants(fetcher: User | null, depth = 0) {
const descendants: Note[] = [];
for (const child of await this.getReplyChildren()) {
descendants.push(child);

View file

@ -1,10 +1,12 @@
import { type SQL, gt } from "drizzle-orm";
import { Notes } from "~drizzle/schema";
import { Notes, Users } from "~drizzle/schema";
import { config } from "~packages/config-manager";
import { Note } from "./note";
import { User } from "./user";
enum TimelineType {
NOTE = "Note",
USER = "User",
}
export class Timeline {
@ -15,7 +17,23 @@ export class Timeline {
limit: number,
url: string,
) {
return new Timeline(TimelineType.NOTE).fetchTimeline(sql, limit, url);
return new Timeline(TimelineType.NOTE).fetchTimeline<Note>(
sql,
limit,
url,
);
}
static async getUserTimeline(
sql: SQL<unknown> | undefined,
limit: number,
url: string,
) {
return new Timeline(TimelineType.USER).fetchTimeline<User>(
sql,
limit,
url,
);
}
private async fetchTimeline<T>(
@ -23,13 +41,15 @@ export class Timeline {
limit: number,
url: string,
) {
const objects: Note[] = [];
const notes: Note[] = [];
const users: User[] = [];
switch (this.type) {
case TimelineType.NOTE:
objects.push(
...(await Note.manyFromSql(sql, undefined, limit)),
);
notes.push(...(await Note.manyFromSql(sql, undefined, limit)));
break;
case TimelineType.USER:
users.push(...(await User.manyFromSql(sql, undefined, limit)));
break;
}
@ -39,26 +59,26 @@ export class Timeline {
config.http.base_url,
).toString();
if (objects.length > 0) {
if (notes.length > 0) {
switch (this.type) {
case TimelineType.NOTE: {
const objectBefore = await Note.fromSql(
gt(Notes.id, objects[0].getStatus().id),
gt(Notes.id, notes[0].getStatus().id),
);
if (objectBefore) {
linkHeader.push(
`<${urlWithoutQuery}?limit=${limit ?? 20}&min_id=${
objects[0].getStatus().id
notes[0].getStatus().id
}>; rel="prev"`,
);
}
if (objects.length >= (limit ?? 20)) {
if (notes.length >= (limit ?? 20)) {
const objectAfter = await Note.fromSql(
gt(
Notes.id,
objects[objects.length - 1].getStatus().id,
notes[notes.length - 1].getStatus().id,
),
);
@ -67,7 +87,37 @@ export class Timeline {
`<${urlWithoutQuery}?limit=${
limit ?? 20
}&max_id=${
objects[objects.length - 1].getStatus().id
notes[notes.length - 1].getStatus().id
}>; rel="next"`,
);
}
}
break;
}
case TimelineType.USER: {
const objectBefore = await User.fromSql(
gt(Users.id, users[0].id),
);
if (objectBefore) {
linkHeader.push(
`<${urlWithoutQuery}?limit=${limit ?? 20}&min_id=${
users[0].id
}>; rel="prev"`,
);
}
if (users.length >= (limit ?? 20)) {
const objectAfter = await User.fromSql(
gt(Users.id, users[users.length - 1].id),
);
if (objectAfter) {
linkHeader.push(
`<${urlWithoutQuery}?limit=${
limit ?? 20
}&max_id=${
users[users.length - 1].id
}>; rel="next"`,
);
}
@ -77,9 +127,17 @@ export class Timeline {
}
}
return {
link: linkHeader.join(", "),
objects,
};
switch (this.type) {
case TimelineType.NOTE:
return {
link: linkHeader.join(", "),
objects: notes as T[],
};
case TimelineType.USER:
return {
link: linkHeader.join(", "),
objects: users as T[],
};
}
}
}

View file

@ -0,0 +1,421 @@
import { idValidator } from "@api";
import { getBestContentType, urlToContentFormat } from "@content_types";
import { addUserToMeilisearch } from "@meilisearch";
import { type SQL, and, desc, eq, inArray } from "drizzle-orm";
import { htmlToText } from "html-to-text";
import type * as Lysand from "lysand-types";
import {
emojiToAPI,
emojiToLysand,
fetchEmoji,
} from "~database/entities/Emoji";
import { addInstanceIfNotExists } from "~database/entities/Instance";
import {
type UserWithRelations,
findFirstUser,
findManyUsers,
} from "~database/entities/User";
import { db } from "~drizzle/db";
import {
EmojiToUser,
NoteToMentions,
UserToPinnedNotes,
Users,
} from "~drizzle/schema";
import { type Config, config } from "~packages/config-manager";
import type { Account as APIAccount } from "~types/mastodon/account";
import type { Mention as APIMention } from "~types/mastodon/mention";
import type { Note } from "./note";
/**
* Gives helpers to fetch users from database in a nice format
*/
export class User {
constructor(private user: UserWithRelations) {}
static async fromId(id: string | null): Promise<User | null> {
if (!id) return null;
return await User.fromSql(eq(Users.id, id));
}
static async fromIds(ids: string[]): Promise<User[]> {
return await User.manyFromSql(inArray(Users.id, ids));
}
static async fromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Users.id),
) {
const found = await findFirstUser({
where: sql,
orderBy,
});
if (!found) return null;
return new User(found);
}
static async manyFromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Users.id),
limit?: number,
offset?: number,
extra?: Parameters<typeof db.query.Users.findMany>[0],
) {
const found = await findManyUsers({
where: sql,
orderBy,
limit,
offset,
with: extra?.with,
});
return found.map((s) => new User(s));
}
get id() {
return this.user.id;
}
getUser() {
return this.user;
}
isLocal() {
return this.user.instanceId === null;
}
isRemote() {
return !this.isLocal();
}
getUri() {
return (
this.user.uri ||
new URL(`/users/${this.user.id}`, config.http.base_url).toString()
);
}
static getUri(id: string, uri: string | null, baseUrl: string) {
return uri || new URL(`/users/${id}`, baseUrl).toString();
}
async pin(note: Note) {
return (
await db
.insert(UserToPinnedNotes)
.values({
noteId: note.id,
userId: this.id,
})
.returning()
)[0];
}
async unpin(note: Note) {
return (
await db
.delete(UserToPinnedNotes)
.where(
and(
eq(NoteToMentions.noteId, note.id),
eq(NoteToMentions.userId, this.id),
),
)
.returning()
)[0];
}
static async resolve(uri: string): Promise<User | null> {
// Check if user not already in database
const foundUser = await User.fromSql(eq(Users.uri, uri));
if (foundUser) return foundUser;
// Check if URI is of a local user
if (uri.startsWith(config.http.base_url)) {
const uuid = uri.match(idValidator);
if (!uuid || !uuid[0]) {
throw new Error(
`URI ${uri} is of a local user, but it could not be parsed`,
);
}
return await User.fromId(uuid[0]);
}
if (!URL.canParse(uri)) {
throw new Error(`Invalid URI to parse ${uri}`);
}
const response = await fetch(uri, {
method: "GET",
headers: {
Accept: "application/json",
},
});
const data = (await response.json()) as Partial<Lysand.User>;
if (
!(
data.id &&
data.username &&
data.uri &&
data.created_at &&
data.dislikes &&
data.featured &&
data.likes &&
data.followers &&
data.following &&
data.inbox &&
data.outbox &&
data.public_key
)
) {
throw new Error("Invalid user data");
}
// Parse emojis and add them to database
const userEmojis =
data.extensions?.["org.lysand:custom_emojis"]?.emojis ?? [];
const instance = await addInstanceIfNotExists(data.uri);
const emojis = [];
for (const emoji of userEmojis) {
emojis.push(await fetchEmoji(emoji));
}
const newUser = (
await db
.insert(Users)
.values({
username: data.username,
uri: data.uri,
createdAt: new Date(data.created_at).toISOString(),
endpoints: {
dislikes: data.dislikes,
featured: data.featured,
likes: data.likes,
followers: data.followers,
following: data.following,
inbox: data.inbox,
outbox: data.outbox,
},
updatedAt: new Date(data.created_at).toISOString(),
instanceId: instance.id,
avatar: data.avatar
? Object.entries(data.avatar)[0][1].content
: "",
header: data.header
? Object.entries(data.header)[0][1].content
: "",
displayName: data.display_name ?? "",
note: getBestContentType(data.bio).content,
publicKey: data.public_key.public_key,
source: {
language: null,
note: "",
privacy: "public",
sensitive: false,
fields: [],
},
})
.returning()
)[0];
// Add emojis to user
if (emojis.length > 0) {
await db.insert(EmojiToUser).values(
emojis.map((emoji) => ({
emojiId: emoji.id,
userId: newUser.id,
})),
);
}
const finalUser = await User.fromId(newUser.id);
if (!finalUser) return null;
// Add to Meilisearch
await addUserToMeilisearch(finalUser);
return finalUser;
}
/**
* Get the user's avatar in raw URL format
* @param config The config to use
* @returns The raw URL for the user's avatar
*/
getAvatarUrl(config: Config) {
if (!this.user.avatar)
return (
config.defaults.avatar ||
`https://api.dicebear.com/8.x/${config.defaults.placeholder_style}/svg?seed=${this.user.username}`
);
return this.user.avatar;
}
/**
* Get the user's header in raw URL format
* @param config The config to use
* @returns The raw URL for the user's header
*/
getHeaderUrl(config: Config) {
if (!this.user.header) return config.defaults.header;
return this.user.header;
}
getAcct() {
return this.isLocal()
? this.user.username
: `${this.user.username}@${this.user.instance?.baseUrl}`;
}
static getAcct(isLocal: boolean, username: string, baseUrl?: string) {
return isLocal ? username : `${username}@${baseUrl}`;
}
toAPI(isOwnAccount = false): APIAccount {
const user = this.getUser();
return {
id: user.id,
username: user.username,
display_name: user.displayName,
note: user.note,
url:
user.uri ||
new URL(`/@${user.username}`, config.http.base_url).toString(),
avatar: this.getAvatarUrl(config),
header: this.getHeaderUrl(config),
locked: user.isLocked,
created_at: new Date(user.createdAt).toISOString(),
followers_count: user.followerCount,
following_count: user.followingCount,
statuses_count: user.statusCount,
emojis: user.emojis.map((emoji) => emojiToAPI(emoji)),
// TODO: Add fields
fields: [],
bot: user.isBot,
source: isOwnAccount ? user.source : undefined,
// TODO: Add static avatar and header
avatar_static: this.getAvatarUrl(config),
header_static: this.getHeaderUrl(config),
acct: this.getAcct(),
// TODO: Add these fields
limited: false,
moved: null,
noindex: false,
suspended: false,
discoverable: undefined,
mute_expires_at: undefined,
group: false,
// @ts-expect-error Pleroma extension
pleroma: {
is_admin: user.isAdmin,
is_moderator: user.isAdmin,
},
};
}
toLysand(): Lysand.User {
if (this.isRemote()) {
throw new Error("Cannot convert remote user to Lysand format");
}
const user = this.getUser();
return {
id: user.id,
type: "User",
uri: this.getUri(),
bio: {
"text/html": {
content: user.note,
},
"text/plain": {
content: htmlToText(user.note),
},
},
created_at: new Date(user.createdAt).toISOString(),
dislikes: new URL(
`/users/${user.id}/dislikes`,
config.http.base_url,
).toString(),
featured: new URL(
`/users/${user.id}/featured`,
config.http.base_url,
).toString(),
likes: new URL(
`/users/${user.id}/likes`,
config.http.base_url,
).toString(),
followers: new URL(
`/users/${user.id}/followers`,
config.http.base_url,
).toString(),
following: new URL(
`/users/${user.id}/following`,
config.http.base_url,
).toString(),
inbox: new URL(
`/users/${user.id}/inbox`,
config.http.base_url,
).toString(),
outbox: new URL(
`/users/${user.id}/outbox`,
config.http.base_url,
).toString(),
indexable: false,
username: user.username,
avatar: urlToContentFormat(this.getAvatarUrl(config)) ?? undefined,
header: urlToContentFormat(this.getHeaderUrl(config)) ?? undefined,
display_name: user.displayName,
fields: user.source.fields.map((field) => ({
key: {
"text/html": {
content: field.name,
},
"text/plain": {
content: htmlToText(field.name),
},
},
value: {
"text/html": {
content: field.value,
},
"text/plain": {
content: htmlToText(field.value),
},
},
})),
public_key: {
actor: new URL(
`/users/${user.id}`,
config.http.base_url,
).toString(),
public_key: user.publicKey,
},
extensions: {
"org.lysand:custom_emojis": {
emojis: user.emojis.map((emoji) => emojiToLysand(emoji)),
},
},
};
}
toMention(): APIMention {
return {
url: this.getUri(),
username: this.getUser().username,
acct: this.getAcct(),
id: this.id,
};
}
}

View file

@ -2,11 +2,8 @@ import { join } from "node:path";
import { redirect } from "@response";
import type { BunFile } from "bun";
import { config } from "config-manager";
import {
type UserWithRelations,
retrieveUserFromToken,
userToAPI,
} from "~database/entities/User";
import { retrieveUserFromToken } from "~database/entities/User";
import type { User } from "~packages/database-interface/user";
import type { LogManager, MultiLogManager } from "~packages/log-manager";
import { languages } from "./glitch-languages";
@ -104,7 +101,7 @@ const handleSignInRequest = async (
req: Request,
path: string,
url: URL,
user: UserWithRelations | null,
user: User | null,
accessToken: string,
) => {
if (req.method === "POST") {
@ -181,7 +178,7 @@ const returnFile = async (file: BunFile, content?: string) => {
const handleDefaultRequest = async (
req: Request,
path: string,
user: UserWithRelations | null,
user: User | null,
accessToken: string,
) => {
const file = Bun.file(join(config.frontend.glitch.assets, path));
@ -204,7 +201,7 @@ const handleDefaultRequest = async (
const brandingTransforms = async (
fileContents: string,
accessToken: string,
user: UserWithRelations | null,
user: User | null,
) => {
let newFileContents = fileContents;
for (const server of config.frontend.glitch.server) {
@ -239,7 +236,7 @@ const brandingTransforms = async (
const htmlTransforms = async (
fileContents: string,
accessToken: string,
user: UserWithRelations | null,
user: User | null,
) => {
// Find script id="initial-state" and replace its contents with custom json
const rewriter = new HTMLRewriter()
@ -290,7 +287,7 @@ const htmlTransforms = async (
},
accounts: user
? {
[user.id]: userToAPI(user, true),
[user.id]: user.toAPI(true),
}
: {},
media_attachments: {

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,
};

View file

@ -1,51 +0,0 @@
import type { APActor, APNote } from "activitypub-types";
import { ActivityPubTranslator } from "./protocols/activitypub";
export enum SupportedProtocols {
ACTIVITYPUB = "activitypub",
}
/**
* ProtocolTranslator
* @summary Translates between federation protocols such as ActivityPub to Lysand and back
* @description This class is responsible for translating between federation protocols such as ActivityPub to Lysand and back.
* This class is not meant to be instantiated directly, but rather for its children to be used.
*/
export class ProtocolTranslator {
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
static auto(object: any) {
const protocol = ProtocolTranslator.recognizeProtocol(object);
switch (protocol) {
case SupportedProtocols.ACTIVITYPUB:
return new ActivityPubTranslator();
default:
throw new Error("Unknown protocol");
}
}
/**
* Translates an ActivityPub actor to a Lysand user
* @param data Raw JSON-LD data from an ActivityPub actor
*/
user(data: APActor) {
//
}
/**
* Translates an ActivityPub note to a Lysand status
* @param data Raw JSON-LD data from an ActivityPub note
*/
status(data: APNote) {
//
}
/**
* Automatically recognizes the protocol of a given object
*/
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
private static recognizeProtocol(object: any) {
// Temporary stub
return SupportedProtocols.ACTIVITYPUB;
}
}

View file

@ -1,9 +0,0 @@
{
"name": "protocol-translator",
"version": "0.0.0",
"main": "index.ts",
"dependencies": {},
"devDependencies": {
"activitypub-types": "^1.1.0"
}
}

View file

@ -1,5 +0,0 @@
import { ProtocolTranslator } from "..";
export class ActivityPubTranslator extends ProtocolTranslator {
user() {}
}

View file

@ -7,11 +7,8 @@ import { RequestParser } from "request-parser";
import type { ZodType, z } from "zod";
import { fromZodError } from "zod-validation-error";
import type { Application } from "~database/entities/Application";
import {
type AuthData,
type UserWithRelations,
getFromRequest,
} from "~database/entities/User";
import { type AuthData, getFromRequest } from "~database/entities/User";
import type { User } from "~packages/database-interface/user";
type MaybePromise<T> = T | Promise<T>;
type HttpVerb = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS";
@ -24,11 +21,11 @@ export type RouteHandler<
matchedRoute: MatchedRoute,
extraData: {
auth: {
// If the route doesn't require authentication, set the type to UserWithRelations | null
// Otherwise set to UserWithRelations
// If the route doesn't require authentication, set the type to User | null
// Otherwise set to User
user: RouteMeta["auth"]["required"] extends true
? UserWithRelations
: UserWithRelations | null;
? User
: User | null;
token: RouteMeta["auth"]["required"] extends true
? string
: string | null;