diff --git a/bun.lockb b/bun.lockb index 061bd1cd..da89323d 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/cli.ts b/cli.ts index 00dc3aaf..d6c425bc 100644 --- a/cli.ts +++ b/cli.ts @@ -13,15 +13,11 @@ import extract from "extract-zip"; import { MediaBackend } from "media-manager"; import { lookup } from "mime-types"; import { getUrl } from "~database/entities/Attachment"; -import { - type User, - createNewLocalUser, - findFirstUser, - findManyUsers, -} from "~database/entities/User"; +import { type UserType, createNewLocalUser } from "~database/entities/User"; import { client, db } from "~drizzle/db"; import { Emojis, Notes, OpenIdAccounts, Users } from "~drizzle/schema"; import { Note } from "~packages/database-interface/note"; +import { User } from "~packages/database-interface/user"; await client.connect(); const args = process.argv; @@ -111,13 +107,12 @@ const cliBuilder = new CliBuilder([ } // Check if user already exists - const user = await findFirstUser({ - where: (user, { or, eq }) => - or(eq(user.username, username), eq(user.email, email)), - }); + const user = await User.fromSql( + or(eq(Users.username, username), eq(Users.email, email)), + ); if (user) { - if (user.username === username) { + if (user.getUser().username === username) { console.log( `${chalk.red("✗")} User with username ${chalk.blue( username, @@ -143,7 +138,7 @@ const cliBuilder = new CliBuilder([ console.log( `${chalk.green("✓")} Created user ${chalk.blue( - newUser?.username, + newUser?.getUser().username, )}${admin ? chalk.green(" (admin)") : ""}`, ); @@ -196,9 +191,7 @@ const cliBuilder = new CliBuilder([ return 1; } - const foundUser = await findFirstUser({ - where: (user, { eq }) => eq(user.username, username), - }); + const foundUser = await User.fromSql(eq(Users.username, username)); if (!foundUser) { console.log(`${chalk.red("✗")} User not found`); @@ -208,7 +201,7 @@ const cliBuilder = new CliBuilder([ if (!args.noconfirm) { process.stdout.write( `Are you sure you want to delete user ${chalk.blue( - foundUser.username, + foundUser.getUser().username, )}?\n${chalk.red( chalk.bold( "This is a destructive action and cannot be undone!", @@ -229,7 +222,7 @@ const cliBuilder = new CliBuilder([ console.log( `${chalk.green("✓")} Deleted user ${chalk.blue( - foundUser.username, + foundUser.getUser().username, )}`, ); @@ -312,22 +305,19 @@ const cliBuilder = new CliBuilder([ return 1; } - // @ts-ignore - let users: (User & { - instance?: { - baseUrl: string; - }; - })[] = await findManyUsers({ - where: (user, { eq }) => - admins ? eq(user.isAdmin, true) : undefined, - limit: args.limit ?? 200, - }); + let users = ( + await User.manyFromSql( + admins ? eq(Users.isAdmin, true) : undefined, + undefined, + args.limit ?? 200, + ) + ).map((u) => u.getUser()); // If instance is not in fields, remove them if (fields.length > 0 && !fields.includes("instance")) { users = users.map((user) => ({ ...user, - instance: undefined, + instance: null, })); } @@ -505,14 +495,14 @@ const cliBuilder = new CliBuilder([ return 1; } - const users = await findManyUsers({ - where: (user, { or, eq }) => - or( - // @ts-expect-error - ...fields.map((field) => eq(user[field], query)), - ), - limit: Number(limit), - }); + const users: User["user"][] = ( + await User.manyFromSql( + // @ts-ignore + or(...fields.map((field) => eq(users[field], query))), + undefined, + Number(limit), + ) + ).map((u) => u.getUser()); if (redact) { for (const user of users) { @@ -631,9 +621,7 @@ const cliBuilder = new CliBuilder([ return 1; } - const user = await findFirstUser({ - where: (user, { eq }) => eq(user.username, username), - }); + const user = await User.fromSql(eq(Users.username, username)); if (!user) { console.log(`${chalk.red("✗")} User not found`); @@ -653,7 +641,7 @@ const cliBuilder = new CliBuilder([ if (linkedOpenIdAccounts.find((a) => a.issuerId === issuerId)) { console.log( `${chalk.red("✗")} User ${chalk.blue( - user.username, + user.getUser().username, )} is already connected to this OpenID Connect issuer with another account`, ); return 1; @@ -670,7 +658,7 @@ const cliBuilder = new CliBuilder([ `${chalk.green( "✓", )} Connected OpenID Connect account to user ${chalk.blue( - user.username, + user.getUser().username, )}`, ); @@ -732,9 +720,7 @@ const cliBuilder = new CliBuilder([ return 1; } - const user = await findFirstUser({ - where: (user, { eq }) => eq(user.id, account.userId ?? ""), - }); + const user = await User.fromId(account.userId); await db .delete(OpenIdAccounts) @@ -744,7 +730,7 @@ const cliBuilder = new CliBuilder([ `${chalk.green( "✓", )} Disconnected OpenID account from user ${chalk.blue( - user?.username, + user?.getUser().username, )}`, ); diff --git a/database/entities/Federation.ts b/database/entities/Federation.ts index 5898bfdf..bb127d3f 100644 --- a/database/entities/Federation.ts +++ b/database/entities/Federation.ts @@ -1,6 +1,6 @@ import { config } from "config-manager"; import type * as Lysand from "lysand-types"; -import { type User, getUserUri } from "./User"; +import type { User } from "~packages/database-interface/user"; export const localObjectURI = (id: string) => `/objects/${id}`; @@ -9,17 +9,17 @@ export const objectToInboxRequest = async ( author: User, userToSendTo: User, ): Promise => { - if (!userToSendTo.instanceId || !userToSendTo.endpoints?.inbox) { + if (userToSendTo.isLocal() || !userToSendTo.getUser().endpoints?.inbox) { throw new Error("UserToSendTo has no inbox or is a local user"); } - if (author.instanceId) { + if (author.isRemote()) { throw new Error("Author is a remote user"); } const privateKey = await crypto.subtle.importKey( "pkcs8", - Buffer.from(author.privateKey ?? "", "base64"), + Buffer.from(author.getUser().privateKey ?? "", "base64"), "Ed25519", false, ["sign"], @@ -30,7 +30,7 @@ export const objectToInboxRequest = async ( new TextEncoder().encode(JSON.stringify(object)), ); - const userInbox = new URL(userToSendTo.endpoints.inbox); + const userInbox = new URL(userToSendTo.getUser().endpoints?.inbox ?? ""); const date = new Date(); @@ -41,14 +41,14 @@ export const objectToInboxRequest = async ( `(request-target): post ${userInbox.pathname}\n` + `host: ${userInbox.host}\n` + `date: ${date.toISOString()}\n` + - `digest: SHA-256=${btoa( - String.fromCharCode(...new Uint8Array(digest)), + `digest: SHA-256=${Buffer.from(new Uint8Array(digest)).toString( + "base64", )}\n`, ), ); - const signatureBase64 = btoa( - String.fromCharCode(...new Uint8Array(signature)), + const signatureBase64 = Buffer.from(new Uint8Array(signature)).toString( + "base64", ); return new Request(userInbox, { @@ -57,9 +57,7 @@ export const objectToInboxRequest = async ( "Content-Type": "application/json", Date: date.toISOString(), Origin: new URL(config.http.base_url).host, - Signature: `keyId="${getUserUri( - author, - )}",algorithm="ed25519",headers="(request-target) host date digest",signature="${signatureBase64}"`, + Signature: `keyId="${author.getUri()}",algorithm="ed25519",headers="(request-target) host date digest",signature="${signatureBase64}"`, }, body: JSON.stringify(object), }); diff --git a/database/entities/Instance.ts b/database/entities/Instance.ts index 6f217898..0d1c0902 100644 --- a/database/entities/Instance.ts +++ b/database/entities/Instance.ts @@ -15,7 +15,7 @@ export const addInstanceIfNotExists = async (url: string) => { const origin = new URL(url).origin; const host = new URL(url).host; - const found = await db.query.instance.findFirst({ + const found = await db.query.Instances.findFirst({ where: (instance, { eq }) => eq(instance.baseUrl, host), }); diff --git a/database/entities/Like.ts b/database/entities/Like.ts index 584b7e34..31a300dd 100644 --- a/database/entities/Like.ts +++ b/database/entities/Like.ts @@ -3,8 +3,8 @@ import { type InferSelectModel, and, eq } from "drizzle-orm"; import type * as Lysand from "lysand-types"; import { db } from "~drizzle/db"; import { Likes, Notifications } from "~drizzle/schema"; -import type { StatusWithRelations } from "./Status"; -import type { UserWithRelations } from "./User"; +import type { Note } from "~packages/database-interface/note"; +import type { User } from "~packages/database-interface/user"; export type Like = InferSelectModel; @@ -27,24 +27,21 @@ export const likeToLysand = (like: Like): Lysand.Like => { /** * Create a like * @param user User liking the status - * @param status Status being liked + * @param note Status being liked */ -export const createLike = async ( - user: UserWithRelations, - status: StatusWithRelations, -) => { +export const createLike = async (user: User, note: Note) => { await db.insert(Likes).values({ - likedId: status.id, + likedId: note.id, likerId: user.id, }); - if (status.author.instanceId === user.instanceId) { + if (note.getAuthor().getUser().instanceId === user.getUser().instanceId) { // Notify the user that their post has been favourited await db.insert(Notifications).values({ accountId: user.id, type: "favourite", - notifiedId: status.authorId, - noteId: status.id, + notifiedId: note.getAuthor().id, + noteId: note.id, }); } else { // TODO: Add database jobs for federating this @@ -54,15 +51,12 @@ export const createLike = async ( /** * Delete a like * @param user User deleting their like - * @param status Status being unliked + * @param note Status being unliked */ -export const deleteLike = async ( - user: UserWithRelations, - status: StatusWithRelations, -) => { +export const deleteLike = async (user: User, note: Note) => { await db .delete(Likes) - .where(and(eq(Likes.likedId, status.id), eq(Likes.likerId, user.id))); + .where(and(eq(Likes.likedId, note.id), eq(Likes.likerId, user.id))); // Notify the user that their post has been favourited await db @@ -71,12 +65,12 @@ export const deleteLike = async ( and( eq(Notifications.accountId, user.id), eq(Notifications.type, "favourite"), - eq(Notifications.notifiedId, status.authorId), - eq(Notifications.noteId, status.id), + eq(Notifications.notifiedId, note.getAuthor().id), + eq(Notifications.noteId, note.id), ), ); - if (user.instanceId === null && status.author.instanceId !== null) { + if (user.isLocal() && note.getAuthor().isRemote()) { // User is local, federate the delete // TODO: Federate this } diff --git a/database/entities/Notification.ts b/database/entities/Notification.ts index 1cdcedf3..9548302c 100644 --- a/database/entities/Notification.ts +++ b/database/entities/Notification.ts @@ -2,6 +2,7 @@ import type { InferSelectModel } from "drizzle-orm"; import { db } from "~drizzle/db"; import type { Notifications } from "~drizzle/schema"; import { Note } from "~packages/database-interface/note"; +import { User } from "~packages/database-interface/user"; import type { Notification as APINotification } from "~types/mastodon/notification"; import type { StatusWithRelations } from "./Status"; import { @@ -9,7 +10,6 @@ import { transformOutputToUserWithRelations, userExtrasTemplate, userRelations, - userToAPI, } from "./User"; export type Notification = InferSelectModel; @@ -50,15 +50,14 @@ export const findManyNotifications = async ( export const notificationToAPI = async ( notification: NotificationWithRelations, ): Promise => { + const account = new User(notification.account); return { - account: userToAPI(notification.account), + account: account.toAPI(), created_at: new Date(notification.createdAt).toISOString(), id: notification.id, type: notification.type, status: notification.status - ? await Note.fromStatus(notification.status).toAPI( - notification.account, - ) + ? await Note.fromStatus(notification.status).toAPI(account) : undefined, }; }; diff --git a/database/entities/Object.ts b/database/entities/Object.ts index f4c0dd4e..7fb5128c 100644 --- a/database/entities/Object.ts +++ b/database/entities/Object.ts @@ -14,7 +14,7 @@ export const createFromObject = async ( object: Lysand.Entity, authorUri: string, ) => { - const foundObject = await db.query.lysandObject.findFirst({ + const foundObject = await db.query.LysandObjects.findFirst({ where: (o, { eq }) => eq(o.remoteId, object.id), with: { author: true, diff --git a/database/entities/Relationship.ts b/database/entities/Relationship.ts index b3266f4d..bbd038d5 100644 --- a/database/entities/Relationship.ts +++ b/database/entities/Relationship.ts @@ -1,8 +1,9 @@ import type { InferSelectModel } from "drizzle-orm"; import { db } from "~drizzle/db"; import { Relationships } from "~drizzle/schema"; +import type { User } from "~packages/database-interface/user"; import type { Relationship as APIRelationship } from "~types/mastodon/relationship"; -import type { User } from "./User"; +import type { UserType } from "./User"; export type Relationship = InferSelectModel; diff --git a/database/entities/Status.ts b/database/entities/Status.ts index 178c85f1..4e313c25 100644 --- a/database/entities/Status.ts +++ b/database/entities/Status.ts @@ -1,3 +1,4 @@ +import markdownItTaskLists from "@hackmd/markdown-it-task-lists"; import { dualLogger } from "@loggers"; import { sanitizeHtml } from "@sanitization"; import { config } from "config-manager"; @@ -10,7 +11,6 @@ import { or, sql, } from "drizzle-orm"; -import { htmlToText } from "html-to-text"; import linkifyHtml from "linkify-html"; import type * as Lysand from "lysand-types"; import { @@ -24,46 +24,30 @@ import { maybe, oneOrMore, } from "magic-regexp"; +import MarkdownIt from "markdown-it"; +import markdownItAnchor from "markdown-it-anchor"; +import markdownItContainer from "markdown-it-container"; +import markdownItTocDoneRight from "markdown-it-toc-done-right"; import { db } from "~drizzle/db"; -import { - Attachments, - EmojiToNote, - Instances, - NoteToMentions, - Notes, - Notifications, - Users, -} from "~drizzle/schema"; +import { type Attachments, Instances, Notes, Users } from "~drizzle/schema"; import { Note } from "~packages/database-interface/note"; +import { User } from "~packages/database-interface/user"; import { LogLevel } from "~packages/log-manager"; import type { Status as APIStatus } from "~types/mastodon/status"; import type { Application } from "./Application"; -import { attachmentFromLysand, attachmentToLysand } from "./Attachment"; -import { - type EmojiWithInstance, - emojiToLysand, - fetchEmoji, - parseEmojis, -} from "./Emoji"; +import { attachmentFromLysand } from "./Attachment"; +import { type EmojiWithInstance, fetchEmoji } from "./Emoji"; import { objectToInboxRequest } from "./Federation"; import type { Like } from "./Like"; import { - type User, + type UserType, type UserWithInstance, type UserWithRelations, - findManyUsers, - getUserUri, - resolveUser, resolveWebFinger, transformOutputToUserWithRelations, userExtrasTemplate, userRelations, } from "./User"; -import MarkdownIt from "markdown-it"; -import markdownItTocDoneRight from "markdown-it-toc-done-right"; -import markdownItContainer from "markdown-it-container"; -import markdownItAnchor from "markdown-it-anchor"; -import markdownItTaskLists from "@hackmd/markdown-it-task-lists"; export type Status = InferSelectModel; @@ -362,7 +346,7 @@ export const resolveNote = async ( throw new Error("Invalid object author"); } - const author = await resolveUser(note.author); + const author = await User.resolve(note.author); if (!author) { throw new Error("Invalid object author"); @@ -415,10 +399,8 @@ export const resolveNote = async ( note.uri, await Promise.all( (note.mentions ?? []) - .map((mention) => resolveUser(mention)) - .filter( - (mention) => mention !== null, - ) as Promise[], + .map((mention) => User.resolve(mention)) + .filter((mention) => mention !== null) as Promise[], ), attachments.map((a) => a.id), note.replies_to @@ -454,9 +436,7 @@ export const createMentionRegExp = () => * @param text The text to parse mentions from. * @returns An array of users mentioned in the text. */ -export const parseTextMentions = async ( - text: string, -): Promise => { +export const parseTextMentions = async (text: string): Promise => { const mentionedPeople = [...text.matchAll(createMentionRegExp())] ?? []; if (mentionedPeople.length === 0) return []; @@ -497,13 +477,12 @@ export const parseTextMentions = async ( const finalList = foundUsers.length > 0 - ? await findManyUsers({ - where: (user, { inArray }) => - inArray( - user.id, - foundUsers.map((u) => u.id), - ), - }) + ? await User.manyFromSql( + inArray( + Users.id, + foundUsers.map((u) => u.id), + ), + ) : []; // Attempt to resolve mentions that were not found @@ -521,49 +500,47 @@ export const parseTextMentions = async ( return finalList; }; -export const replaceTextMentions = async ( - text: string, - mentions: UserWithRelations[], -) => { +export const replaceTextMentions = async (text: string, mentions: User[]) => { let finalText = text; for (const mention of mentions) { + const user = mention.getUser(); // Replace @username and @username@domain - if (mention.instance) { + if (user.instance) { finalText = finalText.replace( createRegExp( - exactly(`@${mention.username}@${mention.instance.baseUrl}`), + exactly(`@${user.username}@${user.instance.baseUrl}`), [global], ), - `@${mention.username}@${mention.instance.baseUrl}`, + `@${ + user.username + }@${user.instance.baseUrl}`, ); } else { finalText = finalText.replace( // Only replace @username if it doesn't have another @ right after createRegExp( - exactly(`@${mention.username}`) + exactly(`@${user.username}`) .notBefore(anyOf(letter, digit, charIn("@"))) .notAfter(anyOf(letter, digit, charIn("@"))), [global], ), - `@${mention.username}`, + `@${ + user.username + }`, ); finalText = finalText.replace( createRegExp( exactly( - `@${mention.username}@${ + `@${user.username}@${ new URL(config.http.base_url).host }`, ), [global], ), - `@${mention.username}`, + `@${ + user.username + }`, ); } } @@ -573,7 +550,7 @@ export const replaceTextMentions = async ( export const contentToHtml = async ( content: Lysand.ContentFormat, - mentions: UserWithRelations[] = [], + mentions: User[] = [], ): Promise => { let htmlContent: string; @@ -663,152 +640,17 @@ export const federateNote = async (note: Note) => { dualLogger.log( LogLevel.ERROR, "Federation.Status", - `Failed to federate status ${note.getStatus().id} to ${ - user.uri - }`, + `Failed to federate status ${ + note.getStatus().id + } to ${user.getUri()}`, ); } } }; -export const editStatus = async ( - statusToEdit: StatusWithRelations, - data: { - content: string; - visibility?: APIStatus["visibility"]; - sensitive: boolean; - spoiler_text: string; - emojis?: EmojiWithInstance[]; - content_type?: string; - uri?: string; - mentions?: User[]; - media_attachments?: string[]; - }, -): Promise => { - const mentions = await parseTextMentions(data.content); - - // Parse emojis - const emojis = await parseEmojis(data.content); - - // Fuse and deduplicate emojis - data.emojis = data.emojis - ? [...data.emojis, ...emojis].filter( - (emoji, index, self) => - index === self.findIndex((t) => t.id === emoji.id), - ) - : emojis; - - const htmlContent = await contentToHtml({ - [data.content_type ?? "text/plain"]: { - content: data.content, - }, - }); - - const note = await Note.fromId(statusToEdit.id); - - if (!note) { - return null; - } - - const updated = await note.update({ - content: htmlContent, - contentSource: data.content, - contentType: data.content_type, - visibility: data.visibility, - sensitive: data.sensitive, - spoilerText: data.spoiler_text, - }); - - // Connect emojis - for (const emoji of data.emojis) { - await db - .insert(EmojiToNote) - .values({ - emojiId: emoji.id, - noteId: updated.id, - }) - .execute(); - } - - // Connect mentions - for (const mention of mentions) { - await db - .insert(NoteToMentions) - .values({ - noteId: updated.id, - userId: mention.id, - }) - .execute(); - } - - // Send notifications for mentioned local users - for (const mention of mentions ?? []) { - if (mention.instanceId === null) { - await db.insert(Notifications).values({ - accountId: statusToEdit.authorId, - notifiedId: mention.id, - type: "mention", - noteId: updated.id, - }); - } - } - - // Set attachment parents - await db - .update(Attachments) - .set({ - noteId: updated.id, - }) - .where(inArray(Attachments.id, data.media_attachments ?? [])); - - return await Note.fromId(updated.id); -}; - -export const isFavouritedBy = async (status: Status, user: User) => { +export const isFavouritedBy = async (status: Status, user: UserType) => { return !!(await db.query.Likes.findFirst({ where: (like, { and, eq }) => and(eq(like.likerId, user.id), eq(like.likedId, status.id)), })); }; - -export const getStatusUri = (status?: Status | null) => { - if (!status) return undefined; - - return ( - status.uri || - new URL(`/objects/${status.id}`, config.http.base_url).toString() - ); -}; - -export const statusToLysand = (status: StatusWithRelations): Lysand.Note => { - return { - type: "Note", - created_at: new Date(status.createdAt).toISOString(), - id: status.id, - author: getUserUri(status.author), - uri: getStatusUri(status) ?? "", - content: { - "text/html": { - content: status.content, - }, - "text/plain": { - content: htmlToText(status.content), - }, - }, - attachments: (status.attachments ?? []).map((attachment) => - attachmentToLysand(attachment), - ), - is_sensitive: status.sensitive, - mentions: status.mentions.map((mention) => mention.uri || ""), - quotes: getStatusUri(status.quote) ?? undefined, - replies_to: getStatusUri(status.reply) ?? undefined, - subject: status.spoilerText, - visibility: status.visibility as Lysand.Visibility, - extensions: { - "org.lysand:custom_emojis": { - emojis: status.emojis.map((emoji) => emojiToLysand(emoji)), - }, - // TODO: Add polls and reactions - }, - }; -}; diff --git a/database/entities/User.ts b/database/entities/User.ts index ba21007b..e9dd677d 100644 --- a/database/entities/User.ts +++ b/database/entities/User.ts @@ -1,43 +1,32 @@ -import { getBestContentType, urlToContentFormat } from "@content_types"; import { dualLogger } from "@loggers"; import { addUserToMeilisearch } from "@meilisearch"; -import { type Config, config } from "config-manager"; -import { type InferSelectModel, and, eq, sql } from "drizzle-orm"; -import { htmlToText } from "html-to-text"; +import { config } from "config-manager"; +import { type InferSelectModel, and, eq, inArray, sql } from "drizzle-orm"; import type * as Lysand from "lysand-types"; import { db } from "~drizzle/db"; import { Applications, - EmojiToUser, Instances, Notifications, Relationships, Tokens, Users, } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; import { LogLevel } from "~packages/log-manager"; -import type { Account as APIAccount } from "~types/mastodon/account"; -import type { Mention as APIMention } from "~types/mastodon/mention"; -import type { Source as APISource } from "~types/mastodon/source"; import type { Application } from "./Application"; -import { - type EmojiWithInstance, - emojiToAPI, - emojiToLysand, - fetchEmoji, -} from "./Emoji"; +import type { EmojiWithInstance } from "./Emoji"; import { objectToInboxRequest } from "./Federation"; -import { addInstanceIfNotExists } from "./Instance"; import { createNewRelationship } from "./Relationship"; import type { Token } from "./Token"; -export type User = InferSelectModel; +export type UserType = InferSelectModel; -export type UserWithInstance = User & { +export type UserWithInstance = UserType & { instance: InferSelectModel | null; }; -export type UserWithRelations = User & { +export type UserWithRelations = UserType & { instance: InferSelectModel | null; emojis: EmojiWithInstance[]; followerCount: number; @@ -105,35 +94,11 @@ export const userExtrasTemplate = (name: string) => ({ }); export interface AuthData { - user: UserWithRelations | null; + user: User | null; token: string; application: Application | null; } -/** - * Get the user's avatar in raw URL format - * @param config The config to use - * @returns The raw URL for the user's avatar - */ -export const getAvatarUrl = (user: User, config: Config) => { - if (!user.avatar) - return ( - config.defaults.avatar || - `https://api.dicebear.com/8.x/${config.defaults.placeholder_style}/svg?seed=${user.username}` - ); - return 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 - */ -export const getHeaderUrl = (user: User, config: Config) => { - if (!user.header) return config.defaults.header; - return user.header; -}; - export const getFromRequest = async (req: Request): Promise => { // Check auth token const token = req.headers.get("Authorization")?.split(" ")[1] || ""; @@ -152,14 +117,14 @@ export const followRequestUser = async ( notify = false, languages: string[] = [], ): Promise> => { - const isRemote = followee.instanceId !== null; + const isRemote = followee.isRemote(); const updatedRelationship = ( await db .update(Relationships) .set({ - following: isRemote ? false : !followee.isLocked, - requested: isRemote ? true : followee.isLocked, + following: isRemote ? false : !followee.getUser().isLocked, + requested: isRemote ? true : followee.getUser().isLocked, showingReblogs: reblogs, notifying: notify, languages: languages, @@ -190,7 +155,9 @@ export const followRequestUser = async ( dualLogger.log( LogLevel.ERROR, "Federation.FollowRequest", - `Failed to federate follow request from ${follower.id} to ${followee.uri}`, + `Failed to federate follow request from ${ + follower.id + } to ${followee.getUri()}`, ); return ( @@ -207,7 +174,7 @@ export const followRequestUser = async ( } else { await db.insert(Notifications).values({ accountId: follower.id, - type: followee.isLocked ? "follow_request" : "follow", + type: followee.getUser().isLocked ? "follow_request" : "follow", notifiedId: followee.id, }); } @@ -236,7 +203,9 @@ export const sendFollowAccept = async (follower: User, followee: User) => { dualLogger.log( LogLevel.ERROR, "Federation.FollowAccept", - `Failed to federate follow accept from ${followee.id} to ${follower.uri}`, + `Failed to federate follow accept from ${ + followee.id + } to ${follower.getUri()}`, ); } }; @@ -262,13 +231,15 @@ export const sendFollowReject = async (follower: User, followee: User) => { dualLogger.log( LogLevel.ERROR, "Federation.FollowReject", - `Failed to federate follow reject from ${followee.id} to ${follower.uri}`, + `Failed to federate follow reject from ${ + followee.id + } to ${follower.getUri()}`, ); } }; export const transformOutputToUserWithRelations = ( - user: Omit & { + user: Omit & { followerCount: unknown; followingCount: unknown; statusCount: unknown; @@ -343,146 +314,6 @@ export const findFirstUser = async ( return transformOutputToUserWithRelations(output); }; -export const resolveUser = async ( - uri: string, -): Promise => { - // Check if user not already in database - const foundUser = await findFirstUser({ - where: (user, { eq }) => eq(user.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( - /[0-9A-F]{8}-[0-9A-F]{4}-[7][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i, - ); - - if (!uuid) { - throw new Error( - `URI ${uri} is of a local user, but it could not be parsed`, - ); - } - - const foundLocalUser = await findFirstUser({ - where: (user, { eq }) => eq(user.id, uuid[0]), - }); - - return foundLocalUser || null; - } - - 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; - - 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 findFirstUser({ - where: (user, { eq }) => eq(user.id, newUser.id), - }); - - if (!finalUser) return null; - - // Add to Meilisearch - await addUserToMeilisearch(finalUser); - - return finalUser; -}; - -export const getUserUri = (user: User) => { - return ( - user.uri || - new URL(`/users/${user.id}`, config.http.base_url).toString() - ); -}; - /** * Resolves a WebFinger identifier to a user. * @param identifier Either a UUID or a username @@ -490,7 +321,7 @@ export const getUserUri = (user: User) => { export const resolveWebFinger = async ( identifier: string, host: string, -): Promise => { +): Promise => { // Check if user not already in database const foundUser = await db .select() @@ -499,12 +330,7 @@ export const resolveWebFinger = async ( .where(and(eq(Users.username, identifier), eq(Instances.baseUrl, host))) .limit(1); - if (foundUser[0]) - return ( - (await findFirstUser({ - where: (user, { eq }) => eq(user.id, foundUser[0].Users.id), - })) || null - ); + if (foundUser[0]) return await User.fromId(foundUser[0].Users.id); const hostWithProtocol = host.startsWith("http") ? host : `https://${host}`; @@ -550,7 +376,7 @@ export const resolveWebFinger = async ( ); } - return resolveUser(relevantLink.href); + return User.resolve(relevantLink.href); }; /** @@ -575,7 +401,7 @@ export const createNewLocalUser = async (data: { header?: string; admin?: boolean; skipPasswordHash?: boolean; -}): Promise => { +}): Promise => { const keys = await generateUserKeys(); const newUser = ( @@ -606,9 +432,7 @@ export const createNewLocalUser = async (data: { .returning() )[0]; - const finalUser = await findFirstUser({ - where: (user, { eq }) => eq(user.id, newUser.id), - }); + const finalUser = await User.fromId(newUser.id); if (!finalUser) return null; @@ -623,11 +447,8 @@ export const createNewLocalUser = async (data: { */ export const parseMentionsUris = async ( mentions: string[], -): Promise => { - return await findManyUsers({ - where: (user, { inArray }) => inArray(user.uri, mentions), - with: userRelations, - }); +): Promise => { + return await User.manyFromSql(inArray(Users.uri, mentions)); }; /** @@ -637,16 +458,14 @@ export const parseMentionsUris = async ( */ export const retrieveUserFromToken = async ( access_token: string, -): Promise => { +): Promise => { if (!access_token) return null; const token = await retrieveToken(access_token); if (!token || !token.userId) return null; - const user = await findFirstUser({ - where: (user, { eq }) => eq(user.id, token.userId ?? ""), - }); + const user = await User.fromId(token.userId); return user; }; @@ -654,7 +473,7 @@ export const retrieveUserFromToken = async ( export const retrieveUserAndApplicationFromToken = async ( access_token: string, ): Promise<{ - user: UserWithRelations | null; + user: User | null; application: Application | null; }> => { if (!access_token) return { user: null, application: null }; @@ -673,9 +492,7 @@ export const retrieveUserAndApplicationFromToken = async ( if (!output?.token.userId) return { user: null, application: null }; - const user = await findFirstUser({ - where: (user, { eq }) => eq(user.id, output.token.userId ?? ""), - }); + const user = await User.fromId(output.token.userId); return { user, application: output.application ?? null }; }; @@ -698,7 +515,7 @@ export const retrieveToken = async ( * @returns The relationship to the other user. */ export const getRelationshipToOtherUser = async ( - user: UserWithRelations, + user: User, other: User, ): Promise> => { const foundRelationship = await db.query.Relationships.findFirst({ @@ -745,169 +562,19 @@ export const generateUserKeys = async () => { }; }; -export const userToMention = (user: UserWithInstance): APIMention => ({ - url: getUserUri(user), - username: user.username, - acct: - user.instance === null - ? user.username - : `${user.username}@${user.instance.baseUrl}`, - id: user.id, -}); - -export const userToAPI = ( - userToConvert: UserWithRelations, - isOwnAccount = false, -): APIAccount => { - return { - id: userToConvert.id, - username: userToConvert.username, - display_name: userToConvert.displayName, - note: userToConvert.note, - url: - userToConvert.uri || - new URL( - `/@${userToConvert.username}`, - config.http.base_url, - ).toString(), - avatar: getAvatarUrl(userToConvert, config), - header: getHeaderUrl(userToConvert, config), - locked: userToConvert.isLocked, - created_at: new Date(userToConvert.createdAt).toISOString(), - followers_count: userToConvert.followerCount, - following_count: userToConvert.followingCount, - statuses_count: userToConvert.statusCount, - emojis: userToConvert.emojis.map((emoji) => emojiToAPI(emoji)), - // TODO: Add fields - fields: [], - bot: userToConvert.isBot, - source: - isOwnAccount && userToConvert.source - ? (userToConvert.source as APISource) - : undefined, - // TODO: Add static avatar and header - avatar_static: getAvatarUrl(userToConvert, config), - header_static: getHeaderUrl(userToConvert, config), - acct: - userToConvert.instance === null - ? userToConvert.username - : `${userToConvert.username}@${userToConvert.instance.baseUrl}`, - // 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: userToConvert.isAdmin, - is_moderator: userToConvert.isAdmin, - }, - }; -}; - -/** - * Should only return local users - */ -export const userToLysand = (user: UserWithRelations): Lysand.User => { - if (user.instanceId !== null) { - throw new Error("Cannot convert remote user to Lysand format"); - } - - return { - id: user.id, - type: "User", - uri: getUserUri(user), - 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(getAvatarUrl(user, config)) ?? undefined, - header: urlToContentFormat(getHeaderUrl(user, config)) ?? undefined, - display_name: user.displayName, - fields: (user.source as APISource).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)), - }, - }, - }; -}; - export const followRequestToLysand = ( follower: User, followee: User, ): Lysand.Follow => { - if (follower.instanceId) { + if (follower.isRemote()) { throw new Error("Follower must be a local user"); } - if (!followee.instanceId) { + if (!followee.isRemote()) { throw new Error("Followee must be a remote user"); } - if (!followee.uri) { + if (!followee.getUser().uri) { throw new Error("Followee must have a URI in database"); } @@ -916,8 +583,8 @@ export const followRequestToLysand = ( return { type: "Follow", id: id, - author: getUserUri(follower), - followee: followee.uri, + author: follower.getUri(), + followee: followee.getUri(), created_at: new Date().toISOString(), uri: new URL(`/follows/${id}`, config.http.base_url).toString(), }; @@ -927,15 +594,15 @@ export const followAcceptToLysand = ( follower: User, followee: User, ): Lysand.FollowAccept => { - if (!follower.instanceId) { + if (!follower.isRemote()) { throw new Error("Follower must be a remote user"); } - if (followee.instanceId) { + if (followee.isRemote()) { throw new Error("Followee must be a local user"); } - if (!follower.uri) { + if (!follower.getUser().uri) { throw new Error("Follower must have a URI in database"); } @@ -944,9 +611,9 @@ export const followAcceptToLysand = ( return { type: "FollowAccept", id: id, - author: getUserUri(followee), + author: followee.getUri(), created_at: new Date().toISOString(), - follower: follower.uri, + follower: follower.getUri(), uri: new URL(`/follows/${id}`, config.http.base_url).toString(), }; }; diff --git a/package.json b/package.json index 90d673aa..5ee01d5d 100644 --- a/package.json +++ b/package.json @@ -1,106 +1,108 @@ { - "name": "lysand", - "module": "index.ts", - "type": "module", - "version": "0.4.0", - "description": "A project to build a federated social network", - "author": { - "email": "contact@cpluspatch.com", - "name": "CPlusPatch", - "url": "https://cpluspatch.com" - }, - "bugs": { - "url": "https://github.com/lysand-org/lysand/issues" - }, - "icon": "https://github.com/lysand-org/lysand", - "license": "AGPL-3.0", - "keywords": ["federated", "activitypub", "bun"], - "workspaces": ["packages/*"], - "maintainers": [ - { - "email": "contact@cpluspatch.com", - "name": "CPlusPatch", - "url": "https://cpluspatch.com" + "name": "lysand", + "module": "index.ts", + "type": "module", + "version": "0.4.0", + "description": "A project to build a federated social network", + "author": { + "email": "contact@cpluspatch.com", + "name": "CPlusPatch", + "url": "https://cpluspatch.com" + }, + "bugs": { + "url": "https://github.com/lysand-org/lysand/issues" + }, + "icon": "https://github.com/lysand-org/lysand", + "license": "AGPL-3.0", + "keywords": ["federated", "activitypub", "bun"], + "workspaces": ["packages/*"], + "maintainers": [ + { + "email": "contact@cpluspatch.com", + "name": "CPlusPatch", + "url": "https://cpluspatch.com" + } + ], + "repository": { + "type": "git", + "url": "git+https://github.com/lysand-org/lysand.git" + }, + "private": true, + "scripts": { + "dev": "bun run --watch index.ts", + "start": "NODE_ENV=production bun run dist/index.js --prod", + "lint": "bunx @biomejs/biome check .", + "prod-build": "bun run build.ts", + "benchmark:timeline": "bun run benchmarks/timelines.ts", + "cloc": "cloc . --exclude-dir node_modules,dist,.output,.nuxt,meta,logs,glitch,glitch-dev --exclude-ext sql,log,pem", + "cli": "bun run cli.ts" + }, + "trustedDependencies": [ + "@biomejs/biome", + "@fortawesome/fontawesome-common-types", + "@fortawesome/free-regular-svg-icons", + "@fortawesome/free-solid-svg-icons", + "es5-ext", + "esbuild", + "json-editor-vue", + "msgpackr-extract", + "nuxt-app", + "sharp", + "vue-demi" + ], + "devDependencies": { + "@biomejs/biome": "^1.7.0", + "@types/cli-table": "^0.3.4", + "@types/html-to-text": "^9.0.4", + "@types/ioredis": "^5.0.0", + "@types/jsonld": "^1.5.13", + "@types/markdown-it-container": "^2.0.10", + "@types/mime-types": "^2.1.4", + "@types/pg": "^8.11.5", + "bun-types": "latest", + "drizzle-kit": "^0.20.14", + "typescript": "latest" + }, + "peerDependencies": { + "typescript": "^5.3.2" + }, + "dependencies": { + "@hackmd/markdown-it-task-lists": "^2.1.4", + "@json2csv/plainjs": "^7.0.6", + "@shikijs/markdown-it": "^1.3.0", + "@tufjs/canonical-json": "^2.0.0", + "blurhash": "^2.0.5", + "bullmq": "^5.7.1", + "chalk": "^5.3.0", + "cli-parser": "workspace:*", + "cli-table": "^0.3.11", + "config-manager": "workspace:*", + "drizzle-orm": "^0.30.7", + "extract-zip": "^2.0.1", + "html-to-text": "^9.0.5", + "ioredis": "^5.3.2", + "ip-matching": "^2.1.2", + "iso-639-1": "^3.1.0", + "isomorphic-dompurify": "latest", + "jose": "^5.2.4", + "linkify-html": "^4.1.3", + "linkify-string": "^4.1.3", + "linkifyjs": "^4.1.3", + "log-manager": "workspace:*", + "magic-regexp": "^0.8.0", + "markdown-it": "^14.1.0", + "markdown-it-anchor": "^8.6.7", + "markdown-it-container": "^4.0.0", + "markdown-it-toc-done-right": "^4.2.0", + "media-manager": "workspace:*", + "megalodon": "^10.0.0", + "meilisearch": "^0.38.0", + "mime-types": "^2.1.35", + "oauth4webapi": "^2.4.0", + "pg": "^8.11.5", + "request-parser": "workspace:*", + "sharp": "^0.33.3", + "zod": "^3.22.4", + "zod-validation-error": "^3.2.0" } - ], - "repository": { - "type": "git", - "url": "git+https://github.com/lysand-org/lysand.git" - }, - "private": true, - "scripts": { - "dev": "bun run --watch index.ts", - "start": "NODE_ENV=production bun run dist/index.js --prod", - "lint": "bunx @biomejs/biome check .", - "prod-build": "bun run build.ts", - "benchmark:timeline": "bun run benchmarks/timelines.ts", - "cloc": "cloc . --exclude-dir node_modules,dist,.output,.nuxt,meta,logs,glitch,glitch-dev --exclude-ext sql,log,pem", - "cli": "bun run cli.ts" - }, - "trustedDependencies": [ - "@biomejs/biome", - "@fortawesome/fontawesome-common-types", - "@fortawesome/free-regular-svg-icons", - "@fortawesome/free-solid-svg-icons", - "es5-ext", - "esbuild", - "json-editor-vue", - "msgpackr-extract", - "nuxt-app", - "sharp", - "vue-demi" - ], - "devDependencies": { - "@biomejs/biome": "^1.7.0", - "@types/cli-table": "^0.3.4", - "@types/html-to-text": "^9.0.4", - "@types/ioredis": "^5.0.0", - "@types/jsonld": "^1.5.13", - "@types/markdown-it-container": "^2.0.10", - "@types/mime-types": "^2.1.4", - "@types/pg": "^8.11.5", - "bun-types": "latest", - "drizzle-kit": "^0.20.14", - "typescript": "latest" - }, - "peerDependencies": { - "typescript": "^5.3.2" - }, - "dependencies": { - "@hackmd/markdown-it-task-lists": "^2.1.4", - "@json2csv/plainjs": "^7.0.6", - "@shikijs/markdown-it": "^1.3.0", - "blurhash": "^2.0.5", - "bullmq": "^5.7.1", - "chalk": "^5.3.0", - "cli-parser": "workspace:*", - "cli-table": "^0.3.11", - "config-manager": "workspace:*", - "drizzle-orm": "^0.30.7", - "extract-zip": "^2.0.1", - "html-to-text": "^9.0.5", - "ioredis": "^5.3.2", - "ip-matching": "^2.1.2", - "iso-639-1": "^3.1.0", - "isomorphic-dompurify": "latest", - "jose": "^5.2.4", - "linkify-html": "^4.1.3", - "linkify-string": "^4.1.3", - "linkifyjs": "^4.1.3", - "log-manager": "workspace:*", - "magic-regexp": "^0.8.0", - "markdown-it": "^14.1.0", - "markdown-it-anchor": "^8.6.7", - "markdown-it-container": "^4.0.0", - "markdown-it-toc-done-right": "^4.2.0", - "media-manager": "workspace:*", - "megalodon": "^10.0.0", - "meilisearch": "^0.38.0", - "mime-types": "^2.1.35", - "oauth4webapi": "^2.4.0", - "pg": "^8.11.5", - "request-parser": "workspace:*", - "sharp": "^0.33.3", - "zod": "^3.22.4" - } } diff --git a/packages/database-interface/note.ts b/packages/database-interface/note.ts index 9b5be2f7..ce28233d 100644 --- a/packages/database-interface/note.ts +++ b/packages/database-interface/note.ts @@ -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) { 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 { + async toAPI(userFetching?: User | null): Promise { 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); diff --git a/packages/database-interface/timeline.ts b/packages/database-interface/timeline.ts index f18b37e1..76c27e66 100644 --- a/packages/database-interface/timeline.ts +++ b/packages/database-interface/timeline.ts @@ -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( + sql, + limit, + url, + ); + } + + static async getUserTimeline( + sql: SQL | undefined, + limit: number, + url: string, + ) { + return new Timeline(TimelineType.USER).fetchTimeline( + sql, + limit, + url, + ); } private async fetchTimeline( @@ -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[], + }; + } } } diff --git a/packages/database-interface/user.ts b/packages/database-interface/user.ts new file mode 100644 index 00000000..d1bb74a9 --- /dev/null +++ b/packages/database-interface/user.ts @@ -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 { + if (!id) return null; + + return await User.fromSql(eq(Users.id, id)); + } + + static async fromIds(ids: string[]): Promise { + return await User.manyFromSql(inArray(Users.id, ids)); + } + + static async fromSql( + sql: SQL | undefined, + orderBy: SQL | undefined = desc(Users.id), + ) { + const found = await findFirstUser({ + where: sql, + orderBy, + }); + + if (!found) return null; + return new User(found); + } + + static async manyFromSql( + sql: SQL | undefined, + orderBy: SQL | undefined = desc(Users.id), + limit?: number, + offset?: number, + extra?: Parameters[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 { + // 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; + + 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, + }; + } +} diff --git a/packages/glitch-server/main.ts b/packages/glitch-server/main.ts index fa59350c..2fe64cb3 100644 --- a/packages/glitch-server/main.ts +++ b/packages/glitch-server/main.ts @@ -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: { diff --git a/packages/lysand-utils/index.ts b/packages/lysand-utils/index.ts new file mode 100644 index 00000000..ceb0720f --- /dev/null +++ b/packages/lysand-utils/index.ts @@ -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() { + // 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, + }, + }); +}; diff --git a/packages/lysand-utils/package.json b/packages/lysand-utils/package.json new file mode 100644 index 00000000..b44ec99b --- /dev/null +++ b/packages/lysand-utils/package.json @@ -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" } +} diff --git a/packages/lysand-utils/schemas.ts b/packages/lysand-utils/schemas.ts new file mode 100644 index 00000000..f8d189f6 --- /dev/null +++ b/packages/lysand-utils/schemas.ts @@ -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, +}; diff --git a/packages/protocol-translator/index.ts b/packages/protocol-translator/index.ts deleted file mode 100644 index ca5cda81..00000000 --- a/packages/protocol-translator/index.ts +++ /dev/null @@ -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: - 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: - private static recognizeProtocol(object: any) { - // Temporary stub - return SupportedProtocols.ACTIVITYPUB; - } -} diff --git a/packages/protocol-translator/package.json b/packages/protocol-translator/package.json deleted file mode 100644 index 22108e4c..00000000 --- a/packages/protocol-translator/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "protocol-translator", - "version": "0.0.0", - "main": "index.ts", - "dependencies": {}, - "devDependencies": { - "activitypub-types": "^1.1.0" - } -} diff --git a/packages/protocol-translator/protocols/activitypub.ts b/packages/protocol-translator/protocols/activitypub.ts deleted file mode 100644 index 6e3c7697..00000000 --- a/packages/protocol-translator/protocols/activitypub.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { ProtocolTranslator } from ".."; - -export class ActivityPubTranslator extends ProtocolTranslator { - user() {} -} diff --git a/packages/server-handler/index.ts b/packages/server-handler/index.ts index fab64556..d68fd99b 100644 --- a/packages/server-handler/index.ts +++ b/packages/server-handler/index.ts @@ -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 | Promise; 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; diff --git a/server/api/api/auth/login/index.ts b/server/api/api/auth/login/index.ts index b05c015d..fc2cd952 100644 --- a/server/api/api/auth/login/index.ts +++ b/server/api/api/auth/login/index.ts @@ -1,12 +1,14 @@ import { apiRoute, applyConfig } from "@api"; import { errorResponse, response } from "@response"; +import { eq } from "drizzle-orm"; import { SignJWT } from "jose"; import { stringify } from "qs"; import { z } from "zod"; import { fromZodError } from "zod-validation-error"; -import { findFirstUser } from "~database/entities/User"; import { db } from "~drizzle/db"; +import { Users } from "~drizzle/schema"; import { config } from "~packages/config-manager"; +import { User } from "~packages/database-interface/user"; import { RequestParser } from "~packages/request-parser"; export const meta = applyConfig({ @@ -77,11 +79,12 @@ export default apiRoute(async (req, matchedRoute, extraData) => { ); // Find user - const user = await findFirstUser({ - where: (user, { eq }) => eq(user.email, email), - }); + const user = await User.fromSql(eq(Users.email, email)); - if (!user || !(await Bun.password.verify(password, user.password || ""))) + if ( + !user || + !(await Bun.password.verify(password, user.getUser().password || "")) + ) return returnError( extraData.parsedRequest, "invalid_request", diff --git a/server/api/api/auth/mastodon-login/index.ts b/server/api/api/auth/mastodon-login/index.ts index 0d19261b..cb6b3724 100644 --- a/server/api/api/auth/mastodon-login/index.ts +++ b/server/api/api/auth/mastodon-login/index.ts @@ -2,10 +2,11 @@ import { randomBytes } from "node:crypto"; import { apiRoute, applyConfig } from "@api"; import { z } from "zod"; import { TokenType } from "~database/entities/Token"; -import { findFirstUser } from "~database/entities/User"; import { db } from "~drizzle/db"; -import { Tokens } from "~drizzle/schema"; +import { Tokens, Users } from "~drizzle/schema"; import { config } from "~packages/config-manager"; +import { User } from "~packages/database-interface/user"; +import { eq } from "drizzle-orm"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -44,13 +45,14 @@ export default apiRoute( 302, ); - const user = await findFirstUser({ - where: (user, { eq }) => eq(user.email, email), - }); + const user = await User.fromSql(eq(Users.email, email)); if ( !user || - !(await Bun.password.verify(password, user.password || "")) + !(await Bun.password.verify( + password, + user.getUser().password || "", + )) ) return redirectToLogin("Invalid email or password"); diff --git a/server/api/api/v1/accounts/[id]/block.ts b/server/api/api/v1/accounts/[id]/block.ts index 3fcd4d9d..f5bd2fdb 100644 --- a/server/api/api/v1/accounts/[id]/block.ts +++ b/server/api/api/v1/accounts/[id]/block.ts @@ -2,12 +2,10 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { eq } from "drizzle-orm"; import { relationshipToAPI } from "~database/entities/Relationship"; -import { - findFirstUser, - getRelationshipToOtherUser, -} from "~database/entities/User"; +import { getRelationshipToOtherUser } from "~database/entities/User"; import { db } from "~drizzle/db"; import { Relationships } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -35,9 +33,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => { if (!self) return errorResponse("Unauthorized", 401); - const otherUser = await findFirstUser({ - where: (user, { eq }) => eq(user.id, id), - }); + const otherUser = await User.fromId(id); if (!otherUser) return errorResponse("User not found", 404); diff --git a/server/api/api/v1/accounts/[id]/follow.ts b/server/api/api/v1/accounts/[id]/follow.ts index 0cd114ab..e9c44ac0 100644 --- a/server/api/api/v1/accounts/[id]/follow.ts +++ b/server/api/api/v1/accounts/[id]/follow.ts @@ -4,10 +4,10 @@ import ISO6391 from "iso-639-1"; import { z } from "zod"; import { relationshipToAPI } from "~database/entities/Relationship"; import { - findFirstUser, followRequestUser, getRelationshipToOtherUser, } from "~database/entities/User"; +import { User } from "~packages/database-interface/user"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -46,9 +46,7 @@ export default apiRoute( const { languages, notify, reblogs } = extraData.parsedRequest; - const otherUser = await findFirstUser({ - where: (user, { eq }) => eq(user.id, id), - }); + const otherUser = await User.fromId(id); if (!otherUser) return errorResponse("User not found", 404); diff --git a/server/api/api/v1/accounts/[id]/followers.test.ts b/server/api/api/v1/accounts/[id]/followers.test.ts new file mode 100644 index 00000000..a3338d21 --- /dev/null +++ b/server/api/api/v1/accounts/[id]/followers.test.ts @@ -0,0 +1,105 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { config } from "config-manager"; +import { + deleteOldTestUsers, + getTestUsers, + sendTestRequest, +} from "~tests/utils"; +import type { Account as APIAccount } from "~types/mastodon/account"; +import { meta } from "./followers"; + +await deleteOldTestUsers(); + +const { users, tokens, deleteUsers } = await getTestUsers(5); + +afterAll(async () => { + await deleteUsers(); +}); + +beforeAll(async () => { + // Follow user + const response = await sendTestRequest( + new Request( + new URL( + `/api/v1/accounts/${users[1].id}/follow`, + config.http.base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + }, + ), + ); + + expect(response.status).toBe(200); +}); + +// /api/v1/accounts/:id/followers +describe(meta.route, () => { + test("should return 200 with followers", async () => { + const response = await sendTestRequest( + new Request( + new URL( + meta.route.replace(":id", users[1].id), + config.http.base_url, + ), + { + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + }, + ), + ); + + expect(response.status).toBe(200); + + const data = (await response.json()) as APIAccount[]; + + expect(data).toBeInstanceOf(Array); + expect(data.length).toBe(1); + expect(data[0].id).toBe(users[0].id); + }); + + test("should return no followers after unfollowing", async () => { + // Unfollow user + const response = await sendTestRequest( + new Request( + new URL( + `/api/v1/accounts/${users[1].id}/unfollow`, + config.http.base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + }, + ), + ); + + expect(response.status).toBe(200); + + const response2 = await sendTestRequest( + new Request( + new URL( + meta.route.replace(":id", users[1].id), + config.http.base_url, + ), + { + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + }, + ), + ); + + expect(response2.status).toBe(200); + + const data = (await response2.json()) as APIAccount[]; + + expect(data).toBeInstanceOf(Array); + expect(data.length).toBe(0); + }); +}); diff --git a/server/api/api/v1/accounts/[id]/followers.ts b/server/api/api/v1/accounts/[id]/followers.ts index 0b4edbaf..03207a71 100644 --- a/server/api/api/v1/accounts/[id]/followers.ts +++ b/server/api/api/v1/accounts/[id]/followers.ts @@ -1,13 +1,10 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import { fetchTimeline } from "@timelines"; +import { and, gt, gte, lt, sql } from "drizzle-orm"; import { z } from "zod"; -import { - type UserWithRelations, - findFirstUser, - findManyUsers, - userToAPI, -} from "~database/entities/User"; +import { Users } from "~drizzle/schema"; +import { Timeline } from "~packages/database-interface/timeline"; +import { User } from "~packages/database-interface/user"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -42,32 +39,23 @@ export default apiRoute( // TODO: Add pinned const { max_id, min_id, since_id, limit } = extraData.parsedRequest; - const otherUser = await findFirstUser({ - where: (user, { eq }) => eq(user.id, id), - }); + const otherUser = await User.fromId(id); if (!otherUser) return errorResponse("User not found", 404); - const { objects, link } = await fetchTimeline( - findManyUsers, - { - // @ts-ignore - where: (follower, { and, lt, gt, gte, eq, sql }) => - and( - max_id ? lt(follower.id, max_id) : undefined, - since_id ? gte(follower.id, since_id) : undefined, - min_id ? gt(follower.id, min_id) : undefined, - sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${otherUser.id} AND "Relationships"."ownerId" = ${follower.id} AND "Relationships"."following" = true)`, - ), - // @ts-expect-error Yes I KNOW the types are wrong - orderBy: (liker, { desc }) => desc(liker.id), - limit, - }, - req, + const { objects, link } = await Timeline.getUserTimeline( + and( + max_id ? lt(Users.id, max_id) : undefined, + since_id ? gte(Users.id, since_id) : undefined, + min_id ? gt(Users.id, min_id) : undefined, + sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${otherUser.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."following" = true)`, + ), + limit, + req.url, ); return jsonResponse( - await Promise.all(objects.map((object) => userToAPI(object))), + await Promise.all(objects.map((object) => object.toAPI())), 200, { Link: link, diff --git a/server/api/api/v1/accounts/[id]/following.test.ts b/server/api/api/v1/accounts/[id]/following.test.ts new file mode 100644 index 00000000..5441b949 --- /dev/null +++ b/server/api/api/v1/accounts/[id]/following.test.ts @@ -0,0 +1,105 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { config } from "config-manager"; +import { + deleteOldTestUsers, + getTestUsers, + sendTestRequest, +} from "~tests/utils"; +import type { Account as APIAccount } from "~types/mastodon/account"; +import { meta } from "./following"; + +await deleteOldTestUsers(); + +const { users, tokens, deleteUsers } = await getTestUsers(5); + +afterAll(async () => { + await deleteUsers(); +}); + +beforeAll(async () => { + // Follow user + const response = await sendTestRequest( + new Request( + new URL( + `/api/v1/accounts/${users[1].id}/follow`, + config.http.base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + }, + ), + ); + + expect(response.status).toBe(200); +}); + +// /api/v1/accounts/:id/following +describe(meta.route, () => { + test("should return 200 with following", async () => { + const response = await sendTestRequest( + new Request( + new URL( + meta.route.replace(":id", users[0].id), + config.http.base_url, + ), + { + headers: { + Authorization: `Bearer ${tokens[1].accessToken}`, + }, + }, + ), + ); + + expect(response.status).toBe(200); + + const data = (await response.json()) as APIAccount[]; + + expect(data).toBeInstanceOf(Array); + expect(data.length).toBe(1); + expect(data[0].id).toBe(users[1].id); + }); + + test("should return no following after unfollowing", async () => { + // Unfollow user + const response = await sendTestRequest( + new Request( + new URL( + `/api/v1/accounts/${users[1].id}/unfollow`, + config.http.base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + }, + ), + ); + + expect(response.status).toBe(200); + + const response2 = await sendTestRequest( + new Request( + new URL( + meta.route.replace(":id", users[0].id), + config.http.base_url, + ), + { + headers: { + Authorization: `Bearer ${tokens[1].accessToken}`, + }, + }, + ), + ); + + expect(response2.status).toBe(200); + + const data = (await response2.json()) as APIAccount[]; + + expect(data).toBeInstanceOf(Array); + expect(data.length).toBe(0); + }); +}); diff --git a/server/api/api/v1/accounts/[id]/following.ts b/server/api/api/v1/accounts/[id]/following.ts index 530a2656..5baf5f47 100644 --- a/server/api/api/v1/accounts/[id]/following.ts +++ b/server/api/api/v1/accounts/[id]/following.ts @@ -1,13 +1,10 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import { fetchTimeline } from "@timelines"; +import { and, gt, gte, lt, sql } from "drizzle-orm"; import { z } from "zod"; -import { - type UserWithRelations, - findFirstUser, - findManyUsers, - userToAPI, -} from "~database/entities/User"; +import { Users } from "~drizzle/schema"; +import { Timeline } from "~packages/database-interface/timeline"; +import { User } from "~packages/database-interface/user"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -42,32 +39,23 @@ export default apiRoute( // TODO: Add pinned const { max_id, min_id, since_id, limit } = extraData.parsedRequest; - const otherUser = await findFirstUser({ - where: (user, { eq }) => eq(user.id, id), - }); + const otherUser = await User.fromId(id); if (!otherUser) return errorResponse("User not found", 404); - const { objects, link } = await fetchTimeline( - findManyUsers, - { - // @ts-ignore - where: (following, { and, lt, gt, gte, eq, sql }) => - and( - max_id ? lt(following.id, max_id) : undefined, - since_id ? gte(following.id, since_id) : undefined, - min_id ? gt(following.id, min_id) : undefined, - sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${following.id} AND "Relationships"."ownerId" = ${otherUser.id} AND "Relationships"."following" = true)`, - ), - // @ts-expect-error Yes I KNOW the types are wrong - orderBy: (liker, { desc }) => desc(liker.id), - limit, - }, - req, + const { objects, link } = await Timeline.getUserTimeline( + and( + max_id ? lt(Users.id, max_id) : undefined, + since_id ? gte(Users.id, since_id) : undefined, + min_id ? gt(Users.id, min_id) : undefined, + sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${otherUser.id} AND "Relationships"."following" = true)`, + ), + limit, + req.url, ); return jsonResponse( - await Promise.all(objects.map((object) => userToAPI(object))), + await Promise.all(objects.map((object) => object.toAPI())), 200, { Link: link, diff --git a/server/api/api/v1/accounts/[id]/index.test.ts b/server/api/api/v1/accounts/[id]/index.test.ts index 00073c85..ce50130e 100644 --- a/server/api/api/v1/accounts/[id]/index.test.ts +++ b/server/api/api/v1/accounts/[id]/index.test.ts @@ -64,17 +64,17 @@ describe(meta.route, () => { const data = (await response.json()) as APIAccount; expect(data).toMatchObject({ id: users[0].id, - username: users[0].username, - display_name: users[0].displayName, + username: users[0].getUser().username, + display_name: users[0].getUser().displayName, avatar: expect.any(String), header: expect.any(String), - locked: users[0].isLocked, - created_at: new Date(users[0].createdAt).toISOString(), + locked: users[0].getUser().isLocked, + created_at: new Date(users[0].getUser().createdAt).toISOString(), followers_count: 0, following_count: 0, statuses_count: 40, - note: users[0].note, - acct: users[0].username, + note: users[0].getUser().note, + acct: users[0].getUser().username, url: expect.any(String), avatar_static: expect.any(String), header_static: expect.any(String), diff --git a/server/api/api/v1/accounts/[id]/index.ts b/server/api/api/v1/accounts/[id]/index.ts index a6bcb988..a8fbab97 100644 --- a/server/api/api/v1/accounts/[id]/index.ts +++ b/server/api/api/v1/accounts/[id]/index.ts @@ -1,6 +1,6 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import { findFirstUser, userToAPI } from "~database/entities/User"; +import { User } from "~packages/database-interface/user"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -26,11 +26,9 @@ export default apiRoute(async (req, matchedRoute, extraData) => { const { user } = extraData.auth; - const foundUser = await findFirstUser({ - where: (user, { eq }) => eq(user.id, id), - }).catch(() => null); + const foundUser = await User.fromId(id); if (!foundUser) return errorResponse("User not found", 404); - return jsonResponse(userToAPI(foundUser, user?.id === foundUser.id)); + return jsonResponse(foundUser.toAPI(user?.id === foundUser.id)); }); diff --git a/server/api/api/v1/accounts/[id]/mute.ts b/server/api/api/v1/accounts/[id]/mute.ts index 93173ffd..75b2f115 100644 --- a/server/api/api/v1/accounts/[id]/mute.ts +++ b/server/api/api/v1/accounts/[id]/mute.ts @@ -3,12 +3,10 @@ import { errorResponse, jsonResponse } from "@response"; import { eq } from "drizzle-orm"; import { z } from "zod"; import { relationshipToAPI } from "~database/entities/Relationship"; -import { - findFirstUser, - getRelationshipToOtherUser, -} from "~database/entities/User"; +import { getRelationshipToOtherUser } from "~database/entities/User"; import { db } from "~drizzle/db"; import { Relationships } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -49,9 +47,7 @@ export default apiRoute( const { notifications, duration } = extraData.parsedRequest; - const user = await findFirstUser({ - where: (user, { eq }) => eq(user.id, id), - }); + const user = await User.fromId(id); if (!user) return errorResponse("User not found", 404); diff --git a/server/api/api/v1/accounts/[id]/note.ts b/server/api/api/v1/accounts/[id]/note.ts index 83bfae02..2683eb5a 100644 --- a/server/api/api/v1/accounts/[id]/note.ts +++ b/server/api/api/v1/accounts/[id]/note.ts @@ -3,12 +3,10 @@ import { errorResponse, jsonResponse } from "@response"; import { eq } from "drizzle-orm"; import { z } from "zod"; import { relationshipToAPI } from "~database/entities/Relationship"; -import { - findFirstUser, - getRelationshipToOtherUser, -} from "~database/entities/User"; +import { getRelationshipToOtherUser } from "~database/entities/User"; import { db } from "~drizzle/db"; import { Relationships } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -43,9 +41,7 @@ export default apiRoute( const { comment } = extraData.parsedRequest; - const otherUser = await findFirstUser({ - where: (user, { eq }) => eq(user.id, id), - }); + const otherUser = await User.fromId(id); if (!otherUser) return errorResponse("User not found", 404); diff --git a/server/api/api/v1/accounts/[id]/pin.ts b/server/api/api/v1/accounts/[id]/pin.ts index 50e1cd70..3c15d21f 100644 --- a/server/api/api/v1/accounts/[id]/pin.ts +++ b/server/api/api/v1/accounts/[id]/pin.ts @@ -2,12 +2,10 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { eq } from "drizzle-orm"; import { relationshipToAPI } from "~database/entities/Relationship"; -import { - findFirstUser, - getRelationshipToOtherUser, -} from "~database/entities/User"; +import { getRelationshipToOtherUser } from "~database/entities/User"; import { db } from "~drizzle/db"; import { Relationships } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -35,9 +33,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => { if (!self) return errorResponse("Unauthorized", 401); - const otherUser = await findFirstUser({ - where: (user, { eq }) => eq(user.id, id), - }); + const otherUser = await User.fromId(id); if (!otherUser) return errorResponse("User not found", 404); diff --git a/server/api/api/v1/accounts/[id]/remove_from_followers.ts b/server/api/api/v1/accounts/[id]/remove_from_followers.ts index 8ce9c3a6..884d4dca 100644 --- a/server/api/api/v1/accounts/[id]/remove_from_followers.ts +++ b/server/api/api/v1/accounts/[id]/remove_from_followers.ts @@ -2,12 +2,10 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { and, eq } from "drizzle-orm"; import { relationshipToAPI } from "~database/entities/Relationship"; -import { - findFirstUser, - getRelationshipToOtherUser, -} from "~database/entities/User"; +import { getRelationshipToOtherUser } from "~database/entities/User"; import { db } from "~drizzle/db"; import { Relationships } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -35,9 +33,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => { if (!self) return errorResponse("Unauthorized", 401); - const otherUser = await findFirstUser({ - where: (user, { eq }) => eq(user.id, id), - }); + const otherUser = await User.fromId(id); if (!otherUser) return errorResponse("User not found", 404); @@ -54,7 +50,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => { }) .where(eq(Relationships.id, foundRelationship.id)); - if (otherUser.instanceId === null) { + if (otherUser.isLocal()) { // Also remove from followers list await db .update(Relationships) diff --git a/server/api/api/v1/accounts/[id]/statuses.ts b/server/api/api/v1/accounts/[id]/statuses.ts index a4d348ce..52c6fed6 100644 --- a/server/api/api/v1/accounts/[id]/statuses.ts +++ b/server/api/api/v1/accounts/[id]/statuses.ts @@ -2,9 +2,9 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { and, eq, gt, gte, isNull, lt, sql } from "drizzle-orm"; import { z } from "zod"; -import { findFirstUser } from "~database/entities/User"; import { Notes } from "~drizzle/schema"; import { Timeline } from "~packages/database-interface/timeline"; +import { User } from "~packages/database-interface/user"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -52,9 +52,7 @@ export default apiRoute( pinned, } = extraData.parsedRequest; - const user = await findFirstUser({ - where: (user, { eq }) => eq(user.id, id), - }); + const user = await User.fromId(id); if (!user) return errorResponse("User not found", 404); diff --git a/server/api/api/v1/accounts/[id]/unblock.ts b/server/api/api/v1/accounts/[id]/unblock.ts index 535127de..9a21ae3a 100644 --- a/server/api/api/v1/accounts/[id]/unblock.ts +++ b/server/api/api/v1/accounts/[id]/unblock.ts @@ -2,12 +2,10 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { eq } from "drizzle-orm"; import { relationshipToAPI } from "~database/entities/Relationship"; -import { - findFirstUser, - getRelationshipToOtherUser, -} from "~database/entities/User"; +import { getRelationshipToOtherUser } from "~database/entities/User"; import { db } from "~drizzle/db"; import { Relationships } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -32,9 +30,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => { if (!self) return errorResponse("Unauthorized", 401); - const otherUser = await findFirstUser({ - where: (user, { eq }) => eq(user.id, id), - }); + const otherUser = await User.fromId(id); if (!otherUser) return errorResponse("User not found", 404); diff --git a/server/api/api/v1/accounts/[id]/unfollow.ts b/server/api/api/v1/accounts/[id]/unfollow.ts index dccaba3a..83c9999c 100644 --- a/server/api/api/v1/accounts/[id]/unfollow.ts +++ b/server/api/api/v1/accounts/[id]/unfollow.ts @@ -2,12 +2,10 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { eq } from "drizzle-orm"; import { relationshipToAPI } from "~database/entities/Relationship"; -import { - findFirstUser, - getRelationshipToOtherUser, -} from "~database/entities/User"; +import { getRelationshipToOtherUser } from "~database/entities/User"; import { db } from "~drizzle/db"; import { Relationships } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -35,9 +33,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => { if (!self) return errorResponse("Unauthorized", 401); - const otherUser = await findFirstUser({ - where: (user, { eq }) => eq(user.id, id), - }); + const otherUser = await User.fromId(id); if (!otherUser) return errorResponse("User not found", 404); diff --git a/server/api/api/v1/accounts/[id]/unmute.ts b/server/api/api/v1/accounts/[id]/unmute.ts index 22116528..47b625d0 100644 --- a/server/api/api/v1/accounts/[id]/unmute.ts +++ b/server/api/api/v1/accounts/[id]/unmute.ts @@ -2,12 +2,10 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { eq } from "drizzle-orm"; import { relationshipToAPI } from "~database/entities/Relationship"; -import { - findFirstUser, - getRelationshipToOtherUser, -} from "~database/entities/User"; +import { getRelationshipToOtherUser } from "~database/entities/User"; import { db } from "~drizzle/db"; import { Relationships } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -35,9 +33,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => { if (!self) return errorResponse("Unauthorized", 401); - const user = await findFirstUser({ - where: (user, { eq }) => eq(user.id, id), - }); + const user = await User.fromId(id); if (!user) return errorResponse("User not found", 404); diff --git a/server/api/api/v1/accounts/[id]/unpin.ts b/server/api/api/v1/accounts/[id]/unpin.ts index 291e6b13..b0794f92 100644 --- a/server/api/api/v1/accounts/[id]/unpin.ts +++ b/server/api/api/v1/accounts/[id]/unpin.ts @@ -2,12 +2,10 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { eq } from "drizzle-orm"; import { relationshipToAPI } from "~database/entities/Relationship"; -import { - findFirstUser, - getRelationshipToOtherUser, -} from "~database/entities/User"; +import { getRelationshipToOtherUser } from "~database/entities/User"; import { db } from "~drizzle/db"; import { Relationships } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -35,9 +33,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => { if (!self) return errorResponse("Unauthorized", 401); - const otherUser = await findFirstUser({ - where: (user, { eq }) => eq(user.id, id), - }); + const otherUser = await User.fromId(id); if (!otherUser) return errorResponse("User not found", 404); diff --git a/server/api/api/v1/accounts/familiar_followers/index.ts b/server/api/api/v1/accounts/familiar_followers/index.ts index c24b4a8d..6bc65dcd 100644 --- a/server/api/api/v1/accounts/familiar_followers/index.ts +++ b/server/api/api/v1/accounts/familiar_followers/index.ts @@ -1,8 +1,10 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; +import { inArray } from "drizzle-orm"; import { z } from "zod"; -import { findManyUsers, userToAPI } from "~database/entities/User"; import { db } from "~drizzle/db"; +import { Users } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -67,18 +69,13 @@ export default apiRoute( return jsonResponse([]); } - const finalUsers = await findManyUsers({ - where: (user, { inArray }) => - inArray( - user.id, - relevantRelationships.map((r) => r.subjectId), - ), - }); + const finalUsers = await User.manyFromSql( + inArray( + Users.id, + relevantRelationships.map((r) => r.subjectId), + ), + ); - if (finalUsers.length === 0) { - return jsonResponse([]); - } - - return jsonResponse(finalUsers.map((o) => userToAPI(o))); + return jsonResponse(finalUsers.map((o) => o.toAPI())); }, ); diff --git a/server/api/api/v1/accounts/index.ts b/server/api/api/v1/accounts/index.ts index 6614c9b9..b3872747 100644 --- a/server/api/api/v1/accounts/index.ts +++ b/server/api/api/v1/accounts/index.ts @@ -1,9 +1,12 @@ import { apiRoute, applyConfig } from "@api"; import { jsonResponse, response } from "@response"; import { tempmailDomains } from "@tempmail"; +import { eq } from "drizzle-orm"; import ISO6391 from "iso-639-1"; import { z } from "zod"; -import { createNewLocalUser, findFirstUser } from "~database/entities/User"; +import { createNewLocalUser } from "~database/entities/User"; +import { Users } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -133,11 +136,7 @@ export default apiRoute( }); // Check if username is taken - if ( - await findFirstUser({ - where: (user, { eq }) => eq(user.username, body.username ?? ""), - }) - ) { + if (await User.fromSql(eq(Users.username, body.username))) { errors.details.username.push({ error: "ERR_TAKEN", description: "is already taken", diff --git a/server/api/api/v1/accounts/lookup/index.test.ts b/server/api/api/v1/accounts/lookup/index.test.ts index f06f4b1d..a5a9e653 100644 --- a/server/api/api/v1/accounts/lookup/index.test.ts +++ b/server/api/api/v1/accounts/lookup/index.test.ts @@ -22,7 +22,7 @@ describe(meta.route, () => { const response = await sendTestRequest( new Request( new URL( - `${meta.route}?acct=${users[0].username}`, + `${meta.route}?acct=${users[0].getUser().username}`, config.http.base_url, ), { @@ -39,8 +39,8 @@ describe(meta.route, () => { expect(data).toEqual( expect.objectContaining({ id: users[0].id, - username: users[0].username, - display_name: users[0].displayName, + username: users[0].getUser().username, + display_name: users[0].getUser().displayName, avatar: expect.any(String), header: expect.any(String), }), diff --git a/server/api/api/v1/accounts/lookup/index.ts b/server/api/api/v1/accounts/lookup/index.ts index 53850b8a..58660562 100644 --- a/server/api/api/v1/accounts/lookup/index.ts +++ b/server/api/api/v1/accounts/lookup/index.ts @@ -1,6 +1,7 @@ import { apiRoute, applyConfig } from "@api"; import { dualLogger } from "@loggers"; import { errorResponse, jsonResponse } from "@response"; +import { eq } from "drizzle-orm"; import { anyOf, charIn, @@ -13,11 +14,9 @@ import { oneOrMore, } from "magic-regexp"; import { z } from "zod"; -import { - findFirstUser, - resolveWebFinger, - userToAPI, -} from "~database/entities/User"; +import { resolveWebFinger } from "~database/entities/User"; +import { Users } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; import { LogLevel } from "~packages/log-manager"; export const meta = applyConfig({ @@ -80,7 +79,7 @@ export default apiRoute( ); if (foundAccount) { - return jsonResponse(userToAPI(foundAccount)); + return jsonResponse(foundAccount.toAPI()); } return errorResponse("Account not found", 404); @@ -91,12 +90,10 @@ export default apiRoute( username = username.slice(1); } - const account = await findFirstUser({ - where: (user, { eq }) => eq(user.username, username), - }); + const account = await User.fromSql(eq(Users.username, username)); if (account) { - return jsonResponse(userToAPI(account)); + return jsonResponse(account.toAPI()); } return errorResponse( diff --git a/server/api/api/v1/accounts/relationships/index.ts b/server/api/api/v1/accounts/relationships/index.ts index 9d57afa9..760910a4 100644 --- a/server/api/api/v1/accounts/relationships/index.ts +++ b/server/api/api/v1/accounts/relationships/index.ts @@ -5,7 +5,7 @@ import { createNewRelationship, relationshipToAPI, } from "~database/entities/Relationship"; -import type { User } from "~database/entities/User"; +import type { UserType } from "~database/entities/User"; import { db } from "~drizzle/db"; export const meta = applyConfig({ @@ -53,7 +53,7 @@ export default apiRoute( for (const id of missingIds) { const relationship = await createNewRelationship(self, { id, - } as User); + } as UserType); relationships.push(relationship); } diff --git a/server/api/api/v1/accounts/search/index.test.ts b/server/api/api/v1/accounts/search/index.test.ts index 15d16ff2..02351763 100644 --- a/server/api/api/v1/accounts/search/index.test.ts +++ b/server/api/api/v1/accounts/search/index.test.ts @@ -22,7 +22,7 @@ describe(meta.route, () => { const response = await sendTestRequest( new Request( new URL( - `${meta.route}?q=${users[0].username}`, + `${meta.route}?q=${users[0].getUser().username}`, config.http.base_url, ), { @@ -40,8 +40,8 @@ describe(meta.route, () => { expect.arrayContaining([ expect.objectContaining({ id: users[0].id, - username: users[0].username, - display_name: users[0].displayName, + username: users[0].getUser().username, + display_name: users[0].getUser().displayName, avatar: expect.any(String), header: expect.any(String), }), diff --git a/server/api/api/v1/accounts/search/index.ts b/server/api/api/v1/accounts/search/index.ts index 4c88529a..f9cf2ee9 100644 --- a/server/api/api/v1/accounts/search/index.ts +++ b/server/api/api/v1/accounts/search/index.ts @@ -1,6 +1,6 @@ import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import { sql } from "drizzle-orm"; +import { eq, like, not, or, sql } from "drizzle-orm"; import { anyOf, charIn, @@ -13,13 +13,9 @@ import { oneOrMore, } from "magic-regexp"; import { z } from "zod"; -import { - type UserWithRelations, - findManyUsers, - resolveWebFinger, - userToAPI, -} from "~database/entities/User"; +import { resolveWebFinger } from "~database/entities/User"; import { Users } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -78,7 +74,7 @@ export default apiRoute( // Remove any leading @ const [username, host] = q.replace(/^@/, "").split("@"); - const accounts: UserWithRelations[] = []; + const accounts: User[] = []; if (resolve && username && host) { const resolvedUser = await resolveWebFinger(username, host); @@ -88,21 +84,22 @@ export default apiRoute( } } else { accounts.push( - ...(await findManyUsers({ - where: (account, { or, like }) => - or( - like(account.displayName, `%${q}%`), - like(account.username, `%${q}%`), - following - ? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${account.id} AND "Relationships"."following" = true)` - : undefined, - ), - offset, + ...(await User.manyFromSql( + or( + like(Users.displayName, `%${q}%`), + like(Users.username, `%${q}%`), + following && self + ? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${self.id} AND "Relationships"."following" = true)` + : undefined, + self ? not(eq(Users.id, self.id)) : undefined, + ), + undefined, limit, - })), + offset, + )), ); } - return jsonResponse(accounts.map((acct) => userToAPI(acct))); + return jsonResponse(accounts.map((acct) => acct.toAPI())); }, ); diff --git a/server/api/api/v1/accounts/update_credentials/index.ts b/server/api/api/v1/accounts/update_credentials/index.ts index e184f890..29126b64 100644 --- a/server/api/api/v1/accounts/update_credentials/index.ts +++ b/server/api/api/v1/accounts/update_credentials/index.ts @@ -11,9 +11,9 @@ import { z } from "zod"; import { getUrl } from "~database/entities/Attachment"; import { parseEmojis } from "~database/entities/Emoji"; import { contentToHtml } from "~database/entities/Status"; -import { findFirstUser, userToAPI } from "~database/entities/User"; import { db } from "~drizzle/db"; import { EmojiToUser, Users } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; export const meta = applyConfig({ allowedMethods: ["PATCH"], @@ -51,11 +51,12 @@ export const schema = z.object({ export default apiRoute( async (req, matchedRoute, extraData) => { - const { user: self } = extraData.auth; + const { user } = extraData.auth; - if (!self) return errorResponse("Unauthorized", 401); + if (!user) return errorResponse("Unauthorized", 401); const config = await extraData.configManager.getConfig(); + const self = user.getUser(); const { display_name, @@ -231,12 +232,9 @@ export default apiRoute( .execute(); } - const output = await findFirstUser({ - where: (user, { eq }) => eq(user.id, self.id), - }); - + const output = await User.fromId(self.id); if (!output) return errorResponse("Couldn't edit user", 500); - return jsonResponse(userToAPI(output)); + return jsonResponse(output.toAPI()); }, ); diff --git a/server/api/api/v1/accounts/verify_credentials/index.ts b/server/api/api/v1/accounts/verify_credentials/index.ts index 3d07f9ea..a533742a 100644 --- a/server/api/api/v1/accounts/verify_credentials/index.ts +++ b/server/api/api/v1/accounts/verify_credentials/index.ts @@ -1,6 +1,5 @@ import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import { userToAPI } from "~database/entities/User"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -22,7 +21,5 @@ export default apiRoute((req, matchedRoute, extraData) => { if (!user) return errorResponse("Unauthorized", 401); - return jsonResponse({ - ...userToAPI(user, true), - }); + return jsonResponse(user.toAPI(true)); }); diff --git a/server/api/api/v1/blocks/index.ts b/server/api/api/v1/blocks/index.ts index b8553bd4..3d2f3887 100644 --- a/server/api/api/v1/blocks/index.ts +++ b/server/api/api/v1/blocks/index.ts @@ -1,12 +1,9 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import { fetchTimeline } from "@timelines"; +import { and, gt, gte, lt, sql } from "drizzle-orm"; import { z } from "zod"; -import { - type UserWithRelations, - findManyUsers, - userToAPI, -} from "~database/entities/User"; +import { Users } from "~drizzle/schema"; +import { Timeline } from "~packages/database-interface/timeline"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -36,27 +33,19 @@ export default apiRoute( const { max_id, since_id, min_id, limit } = extraData.parsedRequest; - const { objects: blocks, link } = - await fetchTimeline( - findManyUsers, - { - // @ts-expect-error Yes I KNOW the types are wrong - where: (subject, { lt, gte, gt, and, sql }) => - and( - max_id ? lt(subject.id, max_id) : undefined, - since_id ? gte(subject.id, since_id) : undefined, - min_id ? gt(subject.id, min_id) : undefined, - sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${subject.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."blocking" = true)`, - ), - limit, - // @ts-expect-error Yes I KNOW the types are wrong - orderBy: (subject, { desc }) => desc(subject.id), - }, - req, - ); + const { objects: blocks, link } = await Timeline.getUserTimeline( + and( + max_id ? lt(Users.id, max_id) : undefined, + since_id ? gte(Users.id, since_id) : undefined, + min_id ? gt(Users.id, min_id) : undefined, + sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."blocking" = true)`, + ), + limit, + req.url, + ); return jsonResponse( - blocks.map((u) => userToAPI(u)), + blocks.map((u) => u.toAPI()), 200, { Link: link, diff --git a/server/api/api/v1/follow_requests/[account_id]/authorize.ts b/server/api/api/v1/follow_requests/[account_id]/authorize.ts index 48db4323..ba427116 100644 --- a/server/api/api/v1/follow_requests/[account_id]/authorize.ts +++ b/server/api/api/v1/follow_requests/[account_id]/authorize.ts @@ -6,12 +6,12 @@ import { relationshipToAPI, } from "~database/entities/Relationship"; import { - findFirstUser, getRelationshipToOtherUser, sendFollowAccept, } from "~database/entities/User"; import { db } from "~drizzle/db"; import { Relationships } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -32,9 +32,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => { const { account_id } = matchedRoute.params; - const account = await findFirstUser({ - where: (user, { eq }) => eq(user.id, account_id), - }); + const account = await User.fromId(account_id); if (!account) return errorResponse("Account not found", 404); @@ -73,7 +71,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => { if (!foundRelationship) return errorResponse("Relationship not found", 404); // Check if accepting remote follow - if (account.instanceId) { + if (account.isRemote()) { // Federate follow accept await sendFollowAccept(account, user); } diff --git a/server/api/api/v1/follow_requests/[account_id]/reject.ts b/server/api/api/v1/follow_requests/[account_id]/reject.ts index 356be9ae..d21ce985 100644 --- a/server/api/api/v1/follow_requests/[account_id]/reject.ts +++ b/server/api/api/v1/follow_requests/[account_id]/reject.ts @@ -6,12 +6,12 @@ import { relationshipToAPI, } from "~database/entities/Relationship"; import { - findFirstUser, getRelationshipToOtherUser, sendFollowReject, } from "~database/entities/User"; import { db } from "~drizzle/db"; import { Relationships } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -32,9 +32,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => { const { account_id } = matchedRoute.params; - const account = await findFirstUser({ - where: (user, { eq }) => eq(user.id, account_id), - }); + const account = await User.fromId(account_id); if (!account) return errorResponse("Account not found", 404); @@ -73,7 +71,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => { if (!foundRelationship) return errorResponse("Relationship not found", 404); // Check if rejecting remote follow - if (account.instanceId) { + if (account.isRemote()) { // Federate follow reject await sendFollowReject(account, user); } diff --git a/server/api/api/v1/follow_requests/index.ts b/server/api/api/v1/follow_requests/index.ts index ef0c94e9..220afde3 100644 --- a/server/api/api/v1/follow_requests/index.ts +++ b/server/api/api/v1/follow_requests/index.ts @@ -1,12 +1,9 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import { fetchTimeline } from "@timelines"; +import { and, gt, gte, lt, sql } from "drizzle-orm"; import { z } from "zod"; -import { - type UserWithRelations, - findManyUsers, - userToAPI, -} from "~database/entities/User"; +import { Users } from "~drizzle/schema"; +import { Timeline } from "~packages/database-interface/timeline"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -35,26 +32,19 @@ export default apiRoute( if (!user) return errorResponse("Unauthorized", 401); - const { objects, link } = await fetchTimeline( - findManyUsers, - { - // @ts-expect-error Yes I KNOW the types are wrong - where: (subject, { lt, gte, gt, and, sql }) => - and( - max_id ? lt(subject.id, max_id) : undefined, - since_id ? gte(subject.id, since_id) : undefined, - min_id ? gt(subject.id, min_id) : undefined, - sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${user.id} AND "Relationships"."ownerId" = ${subject.id} AND "Relationships"."requested" = true)`, - ), - limit, - // @ts-expect-error Yes I KNOW the types are wrong - orderBy: (subject, { desc }) => desc(subject.id), - }, - req, + const { objects, link } = await Timeline.getUserTimeline( + and( + max_id ? lt(Users.id, max_id) : undefined, + since_id ? gte(Users.id, since_id) : undefined, + min_id ? gt(Users.id, min_id) : undefined, + sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${user.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."requested" = true)`, + ), + limit, + req.url, ); return jsonResponse( - objects.map((user) => userToAPI(user)), + objects.map((user) => user.toAPI()), 200, { Link: link, diff --git a/server/api/api/v1/instance/index.ts b/server/api/api/v1/instance/index.ts index 060ae2d9..1a038550 100644 --- a/server/api/api/v1/instance/index.ts +++ b/server/api/api/v1/instance/index.ts @@ -1,10 +1,10 @@ import { apiRoute, applyConfig } from "@api"; import { jsonResponse } from "@response"; import { and, count, countDistinct, eq, gte, isNull, sql } from "drizzle-orm"; -import { findFirstUser, userToAPI } from "~database/entities/User"; import { db } from "~drizzle/db"; import { Instances, Notes, Users } from "~drizzle/schema"; import manifest from "~package.json"; +import { User } from "~packages/database-interface/user"; import type { Instance as APIInstance } from "~types/mastodon/instance"; export const meta = applyConfig({ @@ -45,11 +45,9 @@ export default apiRoute(async (req, matchedRoute, extraData) => { .where(isNull(Users.instanceId)) )[0].count; - const contactAccount = await findFirstUser({ - where: (user, { isNull, eq, and }) => - and(isNull(user.instanceId), eq(user.isAdmin, true)), - orderBy: (user, { asc }) => asc(user.id), - }); + const contactAccount = await User.fromSql( + and(isNull(Users.instanceId), eq(Users.isAdmin, true)), + ); const monthlyActiveUsers = ( await db @@ -186,7 +184,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => { }, vapid_public_key: "", }, - contact_account: contactAccount ? userToAPI(contactAccount) : undefined, + contact_account: contactAccount?.toAPI() || undefined, } satisfies APIInstance & { pleroma: object; }); diff --git a/server/api/api/v1/markers/index.ts b/server/api/api/v1/markers/index.ts index 2e1b25d9..75c87799 100644 --- a/server/api/api/v1/markers/index.ts +++ b/server/api/api/v1/markers/index.ts @@ -1,6 +1,5 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import { fetchTimeline } from "@timelines"; import { and, count, eq } from "drizzle-orm"; import { z } from "zod"; import { db } from "~drizzle/db"; diff --git a/server/api/api/v1/mutes/index.ts b/server/api/api/v1/mutes/index.ts index 68800c79..ac937b1f 100644 --- a/server/api/api/v1/mutes/index.ts +++ b/server/api/api/v1/mutes/index.ts @@ -1,12 +1,9 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import { fetchTimeline } from "@timelines"; +import { and, gt, gte, lt, sql } from "drizzle-orm"; import { z } from "zod"; -import { - type UserWithRelations, - findManyUsers, - userToAPI, -} from "~database/entities/User"; +import { Users } from "~drizzle/schema"; +import { Timeline } from "~packages/database-interface/timeline"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -35,25 +32,17 @@ export default apiRoute( if (!user) return errorResponse("Unauthorized", 401); - const { objects: blocks, link } = - await fetchTimeline( - findManyUsers, - { - // @ts-expect-error Yes I KNOW the types are wrong - where: (subject, { lt, gte, gt, and, sql }) => - and( - max_id ? lt(subject.id, max_id) : undefined, - since_id ? gte(subject.id, since_id) : undefined, - min_id ? gt(subject.id, min_id) : undefined, - sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${subject.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."muting" = true)`, - ), - limit, - // @ts-expect-error Yes I KNOW the types are wrong - orderBy: (subject, { desc }) => desc(subject.id), - }, - req, - ); + const { objects: mutes, link } = await Timeline.getUserTimeline( + and( + max_id ? lt(Users.id, max_id) : undefined, + since_id ? gte(Users.id, since_id) : undefined, + min_id ? gt(Users.id, min_id) : undefined, + sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."muting" = true)`, + ), + limit, + req.url, + ); - return jsonResponse(blocks.map((u) => userToAPI(u))); + return jsonResponse(mutes.map((u) => u.toAPI())); }, ); diff --git a/server/api/api/v1/notifications/index.test.ts b/server/api/api/v1/notifications/index.test.ts index ea0350df..40794b10 100644 --- a/server/api/api/v1/notifications/index.test.ts +++ b/server/api/api/v1/notifications/index.test.ts @@ -58,7 +58,7 @@ beforeAll(async () => { "Content-Type": "application/json", }, body: JSON.stringify({ - status: `@${users[0].username} test mention`, + status: `@${users[0].getUser().username} test mention`, visibility: "direct", federate: false, }), diff --git a/server/api/api/v1/profile/avatar.ts b/server/api/api/v1/profile/avatar.ts index 5353b441..f96c0a81 100644 --- a/server/api/api/v1/profile/avatar.ts +++ b/server/api/api/v1/profile/avatar.ts @@ -1,9 +1,9 @@ import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { eq } from "drizzle-orm"; -import { userToAPI } from "~database/entities/User"; import { db } from "~drizzle/db"; import { Users } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; export const meta = applyConfig({ allowedMethods: ["DELETE"], @@ -27,10 +27,8 @@ export default apiRoute(async (req, matchedRoute, extraData) => { await db.update(Users).set({ avatar: "" }).where(eq(Users.id, self.id)); - return jsonResponse( - userToAPI({ - ...self, - avatar: "", - }), - ); + return jsonResponse({ + ...(await User.fromId(self.id))?.toAPI(), + avatar: "", + }); }); diff --git a/server/api/api/v1/profile/header.ts b/server/api/api/v1/profile/header.ts index 8038a75e..bd2676b3 100644 --- a/server/api/api/v1/profile/header.ts +++ b/server/api/api/v1/profile/header.ts @@ -1,9 +1,9 @@ import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { eq } from "drizzle-orm"; -import { userToAPI } from "~database/entities/User"; import { db } from "~drizzle/db"; import { Users } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; export const meta = applyConfig({ allowedMethods: ["DELETE"], @@ -28,10 +28,8 @@ export default apiRoute(async (req, matchedRoute, extraData) => { // Delete user header await db.update(Users).set({ header: "" }).where(eq(Users.id, self.id)); - return jsonResponse( - userToAPI({ - ...self, - header: "", - }), - ); + return jsonResponse({ + ...(await User.fromId(self.id))?.toAPI(), + header: "", + }); }); diff --git a/server/api/api/v1/statuses/[id]/favourite.ts b/server/api/api/v1/statuses/[id]/favourite.ts index c6c1575b..84f914ab 100644 --- a/server/api/api/v1/statuses/[id]/favourite.ts +++ b/server/api/api/v1/statuses/[id]/favourite.ts @@ -30,27 +30,27 @@ export default apiRoute(async (req, matchedRoute, extraData) => { if (!user) return errorResponse("Unauthorized", 401); - const status = await Note.fromId(id); + const note = await Note.fromId(id); // Check if user is authorized to view this status (if it's private) - if (!status?.isViewableByUser(user)) + if (!note?.isViewableByUser(user)) return errorResponse("Record not found", 404); const existingLike = await db.query.Likes.findFirst({ where: (like, { and, eq }) => and( - eq(like.likedId, status.getStatus().id), + eq(like.likedId, note.getStatus().id), eq(like.likerId, user.id), ), }); if (!existingLike) { - await createLike(user, status.getStatus()); + await createLike(user, note); } return jsonResponse({ - ...(await status.toAPI(user)), + ...(await note.toAPI(user)), favourited: true, - favourites_count: status.getStatus().likeCount + 1, + favourites_count: note.getStatus().likeCount + 1, } as APIStatus); }); diff --git a/server/api/api/v1/statuses/[id]/favourited_by.test.ts b/server/api/api/v1/statuses/[id]/favourited_by.test.ts index 00affb12..9da0af16 100644 --- a/server/api/api/v1/statuses/[id]/favourited_by.test.ts +++ b/server/api/api/v1/statuses/[id]/favourited_by.test.ts @@ -73,7 +73,7 @@ describe(meta.route, () => { expect(objects.length).toBe(1); for (const [index, status] of objects.entries()) { expect(status.id).toBe(users[1].id); - expect(status.username).toBe(users[1].username); + expect(status.username).toBe(users[1].getUser().username); } }); }); diff --git a/server/api/api/v1/statuses/[id]/favourited_by.ts b/server/api/api/v1/statuses/[id]/favourited_by.ts index 0016abb9..f445b624 100644 --- a/server/api/api/v1/statuses/[id]/favourited_by.ts +++ b/server/api/api/v1/statuses/[id]/favourited_by.ts @@ -1,13 +1,10 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import { fetchTimeline } from "@timelines"; +import { and, gt, gte, lt, sql } from "drizzle-orm"; import { z } from "zod"; -import { - type UserWithRelations, - findManyUsers, - userToAPI, -} from "~database/entities/User"; +import { Users } from "~drizzle/schema"; import { Note } from "~packages/database-interface/note"; +import { Timeline } from "~packages/database-interface/timeline"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -48,28 +45,19 @@ export default apiRoute( const { max_id, min_id, since_id, limit } = extraData.parsedRequest; - const { objects, link } = await fetchTimeline( - findManyUsers, - { - // @ts-ignore - where: (liker, { and, lt, gt, gte, eq, sql }) => - and( - max_id ? lt(liker.id, max_id) : undefined, - since_id ? gte(liker.id, since_id) : undefined, - min_id ? gt(liker.id, min_id) : undefined, - sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = ${ - status.getStatus().id - } AND "Likes"."likerId" = ${liker.id})`, - ), - // @ts-expect-error Yes I KNOW the types are wrong - orderBy: (liker, { desc }) => desc(liker.id), - limit, - }, - req, + const { objects, link } = await Timeline.getUserTimeline( + and( + max_id ? lt(Users.id, max_id) : undefined, + since_id ? gte(Users.id, since_id) : undefined, + min_id ? gt(Users.id, min_id) : undefined, + sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = ${status.id} AND "Likes"."likerId" = ${Users.id})`, + ), + limit, + req.url, ); return jsonResponse( - objects.map((user) => userToAPI(user)), + objects.map((user) => user.toAPI()), 200, { Link: link, diff --git a/server/api/api/v1/statuses/[id]/pin.ts b/server/api/api/v1/statuses/[id]/pin.ts index c7035353..9dfe7571 100644 --- a/server/api/api/v1/statuses/[id]/pin.ts +++ b/server/api/api/v1/statuses/[id]/pin.ts @@ -51,7 +51,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => { return errorResponse("Already pinned", 422); } - await foundStatus.pin(user); + await user.pin(foundStatus); return jsonResponse(await foundStatus.toAPI(user)); }); diff --git a/server/api/api/v1/statuses/[id]/reblogged_by.test.ts b/server/api/api/v1/statuses/[id]/reblogged_by.test.ts index e2b8a783..02e131fd 100644 --- a/server/api/api/v1/statuses/[id]/reblogged_by.test.ts +++ b/server/api/api/v1/statuses/[id]/reblogged_by.test.ts @@ -73,7 +73,7 @@ describe(meta.route, () => { expect(objects.length).toBe(1); for (const [index, status] of objects.entries()) { expect(status.id).toBe(users[1].id); - expect(status.username).toBe(users[1].username); + expect(status.username).toBe(users[1].getUser().username); } }); }); diff --git a/server/api/api/v1/statuses/[id]/reblogged_by.ts b/server/api/api/v1/statuses/[id]/reblogged_by.ts index 703b0c64..64ba2ba8 100644 --- a/server/api/api/v1/statuses/[id]/reblogged_by.ts +++ b/server/api/api/v1/statuses/[id]/reblogged_by.ts @@ -1,13 +1,10 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import { fetchTimeline } from "@timelines"; +import { and, gt, gte, lt, sql } from "drizzle-orm"; import { z } from "zod"; -import { - type UserWithRelations, - findManyUsers, - userToAPI, -} from "~database/entities/User"; +import { Users } from "~drizzle/schema"; import { Note } from "~packages/database-interface/note"; +import { Timeline } from "~packages/database-interface/timeline"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -48,28 +45,19 @@ export default apiRoute( const { max_id, min_id, since_id, limit } = extraData.parsedRequest; - const { objects, link } = await fetchTimeline( - findManyUsers, - { - // @ts-ignore - where: (reblogger, { and, lt, gt, gte, eq, sql }) => - and( - max_id ? lt(reblogger.id, max_id) : undefined, - since_id ? gte(reblogger.id, since_id) : undefined, - min_id ? gt(reblogger.id, min_id) : undefined, - sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."reblogId" = ${ - status.getStatus().id - } AND "Notes"."authorId" = ${reblogger.id})`, - ), - // @ts-expect-error Yes I KNOW the types are wrong - orderBy: (liker, { desc }) => desc(liker.id), - limit, - }, - req, + const { objects, link } = await Timeline.getUserTimeline( + and( + max_id ? lt(Users.id, max_id) : undefined, + since_id ? gte(Users.id, since_id) : undefined, + min_id ? gt(Users.id, min_id) : undefined, + sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."reblogId" = ${status.id} AND "Notes"."authorId" = ${Users.id})`, + ), + limit, + req.url, ); return jsonResponse( - objects.map((user) => userToAPI(user)), + objects.map((user) => user.toAPI()), 200, { Link: link, diff --git a/server/api/api/v1/statuses/[id]/unfavourite.ts b/server/api/api/v1/statuses/[id]/unfavourite.ts index 73aedfba..e5908cda 100644 --- a/server/api/api/v1/statuses/[id]/unfavourite.ts +++ b/server/api/api/v1/statuses/[id]/unfavourite.ts @@ -29,17 +29,17 @@ export default apiRoute(async (req, matchedRoute, extraData) => { if (!user) return errorResponse("Unauthorized", 401); - const foundStatus = await Note.fromId(id); + const note = await Note.fromId(id); // Check if user is authorized to view this status (if it's private) - if (!foundStatus?.isViewableByUser(user)) + if (!note?.isViewableByUser(user)) return errorResponse("Record not found", 404); - await deleteLike(user, foundStatus.getStatus()); + await deleteLike(user, note); return jsonResponse({ - ...(await foundStatus.toAPI(user)), + ...(await note.toAPI(user)), favourited: false, - favourites_count: foundStatus.getStatus().likeCount - 1, + favourites_count: note.getStatus().likeCount - 1, } as APIStatus); }); diff --git a/server/api/api/v1/statuses/[id]/unpin.ts b/server/api/api/v1/statuses/[id]/unpin.ts index 8ae38837..7d50e86e 100644 --- a/server/api/api/v1/statuses/[id]/unpin.ts +++ b/server/api/api/v1/statuses/[id]/unpin.ts @@ -36,7 +36,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => { if (status.getAuthor().id !== user.id) return errorResponse("Unauthorized", 401); - await status.unpin(user); + await user.unpin(status); if (!status) return errorResponse("Record not found", 404); diff --git a/server/api/api/v1/statuses/index.test.ts b/server/api/api/v1/statuses/index.test.ts index 1dd97eb0..c3f85bb7 100644 --- a/server/api/api/v1/statuses/index.test.ts +++ b/server/api/api/v1/statuses/index.test.ts @@ -306,7 +306,7 @@ describe(meta.route, () => { Authorization: `Bearer ${tokens[0].accessToken}`, }, body: JSON.stringify({ - status: `Hello, @${users[1].username}!`, + status: `Hello, @${users[1].getUser().username}!`, federate: false, }), }), @@ -322,8 +322,8 @@ describe(meta.route, () => { expect(object.mentions).toBeArrayOfSize(1); expect(object.mentions[0]).toMatchObject({ id: users[1].id, - username: users[1].username, - acct: users[1].username, + username: users[1].getUser().username, + acct: users[1].getUser().username, }); }); @@ -336,7 +336,7 @@ describe(meta.route, () => { Authorization: `Bearer ${tokens[0].accessToken}`, }, body: JSON.stringify({ - status: `Hello, @${users[1].username}@${ + status: `Hello, @${users[1].getUser().username}@${ new URL(config.http.base_url).host }!`, federate: false, @@ -354,8 +354,8 @@ describe(meta.route, () => { expect(object.mentions).toBeArrayOfSize(1); expect(object.mentions[0]).toMatchObject({ id: users[1].id, - username: users[1].username, - acct: users[1].username, + username: users[1].getUser().username, + acct: users[1].getUser().username, }); }); }); diff --git a/server/api/api/v2/instance/index.ts b/server/api/api/v2/instance/index.ts index ce140d2a..cc88e46c 100644 --- a/server/api/api/v2/instance/index.ts +++ b/server/api/api/v2/instance/index.ts @@ -1,10 +1,10 @@ import { apiRoute, applyConfig } from "@api"; import { jsonResponse } from "@response"; import { and, countDistinct, eq, gte, isNull } from "drizzle-orm"; -import { findFirstUser, userToAPI } from "~database/entities/User"; import { db } from "~drizzle/db"; import { Notes, Users } from "~drizzle/schema"; import manifest from "~package.json"; +import { User } from "~packages/database-interface/user"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -23,11 +23,10 @@ export default apiRoute(async (req, matchedRoute, extraData) => { // Get software version from package.json const version = manifest.version; - const contactAccount = await findFirstUser({ - where: (user, { isNull, eq, and }) => - and(isNull(user.instanceId), eq(user.isAdmin, true)), - orderBy: (user, { asc }) => asc(user.id), - }); + + const contactAccount = await User.fromSql( + and(isNull(Users.instanceId), eq(Users.isAdmin, true)), + ); const monthlyActiveUsers = ( await db @@ -104,8 +103,8 @@ export default apiRoute(async (req, matchedRoute, extraData) => { url: null, }, contact: { - email: contactAccount?.email || null, - account: contactAccount ? userToAPI(contactAccount) : null, + email: contactAccount?.getUser().email || null, + account: contactAccount?.toAPI() || null, }, rules: config.signups.rules.map((rule, index) => ({ id: String(index), diff --git a/server/api/api/v2/search/index.ts b/server/api/api/v2/search/index.ts index c08e1c4d..19f3b6fe 100644 --- a/server/api/api/v2/search/index.ts +++ b/server/api/api/v2/search/index.ts @@ -4,15 +4,11 @@ import { MeiliIndexType, meilisearch } from "@meilisearch"; import { errorResponse, jsonResponse } from "@response"; import { and, eq, inArray, sql } from "drizzle-orm"; import { z } from "zod"; -import { - findFirstUser, - findManyUsers, - resolveWebFinger, - userToAPI, -} from "~database/entities/User"; +import { resolveWebFinger } from "~database/entities/User"; import { db } from "~drizzle/db"; import { Instances, Notes, Users } from "~drizzle/schema"; import { Note } from "~packages/database-interface/note"; +import { User } from "~packages/database-interface/user"; import { LogLevel } from "~packages/log-manager"; export const meta = applyConfig({ @@ -100,15 +96,11 @@ export default apiRoute( ) )[0]?.id; - const account = accountId - ? await findFirstUser({ - where: (user, { eq }) => eq(user.id, accountId), - }) - : null; + const account = accountId ? await User.fromId(accountId) : null; if (account) { return jsonResponse({ - accounts: [userToAPI(account)], + accounts: [account.toAPI()], statuses: [], hashtags: [], }); @@ -129,7 +121,7 @@ export default apiRoute( if (newUser) { return jsonResponse({ - accounts: [userToAPI(newUser)], + accounts: [newUser.toAPI()], statuses: [], hashtags: [], }); @@ -160,23 +152,21 @@ export default apiRoute( ).hits; } - const accounts = await findManyUsers({ - where: (user, { and, eq, inArray }) => - and( - inArray( - user.id, - accountResults.map((hit) => hit.id), - ), - self - ? sql`EXISTS (SELECT 1 FROM Relationships WHERE Relationships.subjectId = ${ - self?.id - } AND Relationships.following = ${!!following} AND Relationships.ownerId = ${ - user.id - })` - : undefined, + const accounts = await User.manyFromSql( + and( + inArray( + Users.id, + accountResults.map((hit) => hit.id), ), - orderBy: (user, { desc }) => desc(user.createdAt), - }); + self + ? sql`EXISTS (SELECT 1 FROM Relationships WHERE Relationships.subjectId = ${ + self?.id + } AND Relationships.following = ${!!following} AND Relationships.ownerId = ${ + Users.id + })` + : undefined, + ), + ); const statuses = await Note.manyFromSql( and( @@ -196,7 +186,7 @@ export default apiRoute( ); return jsonResponse({ - accounts: accounts.map((account) => userToAPI(account)), + accounts: accounts.map((account) => account.toAPI()), statuses: await Promise.all( statuses.map((status) => status.toAPI(self)), ), diff --git a/server/api/oauth/authorize/index.ts b/server/api/oauth/authorize/index.ts index d67b1813..c3a17142 100644 --- a/server/api/oauth/authorize/index.ts +++ b/server/api/oauth/authorize/index.ts @@ -4,10 +4,10 @@ import { response } from "@response"; import { SignJWT, jwtVerify } from "jose"; import { z } from "zod"; import { TokenType } from "~database/entities/Token"; -import { findFirstUser } from "~database/entities/User"; import { db } from "~drizzle/db"; import { Tokens } from "~drizzle/schema"; import { config } from "~packages/config-manager"; +import { User } from "~packages/database-interface/user"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -136,9 +136,7 @@ export default apiRoute( if (!payload.exp) return returnError("invalid_request", "Invalid exp"); // Check if the user is authenticated - const user = await findFirstUser({ - where: (user, { eq }) => eq(user.id, payload.sub ?? ""), - }); + const user = await User.fromId(payload.sub); if (!user) return returnError("invalid_request", "Invalid sub"); @@ -226,17 +224,20 @@ export default apiRoute( // Include the user's profile information idTokenPayload = { ...idTokenPayload, - name: user.displayName, - preferred_username: user.username, - picture: user.avatar, - updated_at: new Date(user.updatedAt).toISOString(), + name: user.getUser().displayName, + preferred_username: user.getUser().username, + picture: user.getAvatarUrl(config), + updated_at: new Date( + user.getUser().updatedAt, + ).toISOString(), }; } if (scopeIncludesEmail) { // Include the user's email address idTokenPayload = { ...idTokenPayload, - email: user.email, + email: user.getUser().email, + // TODO: Add verification system email_verified: true, }; } diff --git a/server/api/oauth/callback/[issuer]/index.ts b/server/api/oauth/callback/[issuer]/index.ts index 76e28fdb..fde3ebe5 100644 --- a/server/api/oauth/callback/[issuer]/index.ts +++ b/server/api/oauth/callback/[issuer]/index.ts @@ -14,9 +14,9 @@ import { validateAuthResponse, } from "oauth4webapi"; import { TokenType } from "~database/entities/Token"; -import { findFirstUser } from "~database/entities/User"; import { db } from "~drizzle/db"; import { Tokens } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -154,9 +154,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => { return redirectToLogin("No user found with that account"); } - const user = await findFirstUser({ - where: (user, { eq }) => eq(user.id, userId), - }); + const user = await User.fromId(userId); if (!user) { return redirectToLogin("No user found with that account"); diff --git a/server/api/users/[uuid]/inbox/index.ts b/server/api/users/[uuid]/inbox/index.ts index 36a15ba8..bde3b396 100644 --- a/server/api/users/[uuid]/inbox/index.ts +++ b/server/api/users/[uuid]/inbox/index.ts @@ -1,18 +1,19 @@ import { apiRoute, applyConfig } from "@api"; import { dualLogger } from "@loggers"; -import { errorResponse, response } from "@response"; +import { errorResponse, jsonResponse, response } from "@response"; import { eq } from "drizzle-orm"; import type * as Lysand from "lysand-types"; +import { isValidationError } from "zod-validation-error"; import { resolveNote } from "~database/entities/Status"; import { - findFirstUser, getRelationshipToOtherUser, - resolveUser, sendFollowAccept, } from "~database/entities/User"; import { db } from "~drizzle/db"; import { Notifications, Relationships } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; import { LogLevel } from "~packages/log-manager"; +import { EntityValidator, SignatureValidator } from "~packages/lysand-utils"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -29,227 +30,215 @@ export const meta = applyConfig({ export default apiRoute(async (req, matchedRoute, extraData) => { const uuid = matchedRoute.params.uuid; - const user = await findFirstUser({ - where: (user, { eq }) => eq(user.id, uuid), - }); + const user = await User.fromId(uuid); if (!user) { return errorResponse("User not found", 404); } - // Process incoming request - const body = extraData.parsedRequest as Lysand.Entity; - // Verify request signature // TODO: Check if instance is defederated + // TODO: Reverse DNS lookup with Origin header // biome-ignore lint/correctness/noConstantCondition: Temporary if (true) { - // request is a Request object containing the previous request + const Signature = req.headers.get("Signature"); + const DateHeader = req.headers.get("Date"); - const signatureHeader = req.headers.get("Signature"); - const origin = req.headers.get("Origin"); - const date = req.headers.get("Date"); - - if (!signatureHeader) { + if (!Signature) { return errorResponse("Missing Signature header", 400); } - if (!origin) { - return errorResponse("Missing Origin header", 400); - } - - if (!date) { + if (!DateHeader) { return errorResponse("Missing Date header", 400); } - const signature = signatureHeader - .split("signature=")[1] - .replace(/"/g, ""); - - const digest = await crypto.subtle.digest( - "SHA-256", - new TextEncoder().encode(JSON.stringify(body)), - ); - - const keyId = signatureHeader - .split("keyId=")[1] + const keyId = Signature.split("keyId=")[1] .split(",")[0] .replace(/"/g, ""); - console.log(`Resolving keyId ${keyId}`); - - const sender = await resolveUser(keyId); + const sender = await User.resolve(keyId); if (!sender) { - return errorResponse("Invalid keyId", 400); + return errorResponse("Could not resolve keyId", 400); } - const public_key = await crypto.subtle.importKey( - "spki", - Buffer.from(sender.publicKey, "base64"), - "Ed25519", - false, - ["verify"], + const validator = await SignatureValidator.fromStringKey( + sender.getUser().publicKey, + Signature, + DateHeader, + req.method, + new URL(req.url), + await req.text(), ); - const expectedSignedString = - `(request-target): ${req.method.toLowerCase()} ${ - new URL(req.url).pathname - }\n` + - `host: ${new URL(req.url).host}\n` + - `date: ${date}\n` + - `digest: SHA-256=${btoa( - String.fromCharCode(...new Uint8Array(digest)), - )}\n`; - - // Check if signed string is valid - const isValid = await crypto.subtle.verify( - "Ed25519", - public_key, - Uint8Array.from(atob(signature), (c) => c.charCodeAt(0)), - new TextEncoder().encode(expectedSignedString), - ); + const isValid = await validator.validate(); if (!isValid) { return errorResponse("Invalid signature", 400); } } - // Add sent data to database - switch (body.type) { - case "Note": { - const note = body as Lysand.Note; + const validator = new EntityValidator( + extraData.parsedRequest as Lysand.Entity, + ); - const account = await resolveUser(note.author); + try { + // Add sent data to database + switch (validator.getType()) { + case "Note": { + const note = await validator.validate(); - if (!account) { - return errorResponse("Author not found", 400); - } + const account = await User.resolve(note.author); - const newStatus = await resolveNote(undefined, note).catch((e) => { - dualLogger.logError( - LogLevel.ERROR, - "Inbox.NoteResolve", - e as Error, + if (!account) { + return errorResponse("Author not found", 404); + } + + const newStatus = await resolveNote(undefined, note).catch( + (e) => { + dualLogger.logError( + LogLevel.ERROR, + "Inbox.NoteResolve", + e as Error, + ); + return null; + }, ); - return null; - }); - if (!newStatus) { - return errorResponse("Failed to add status", 500); + if (!newStatus) { + return errorResponse("Failed to add status", 500); + } + + return response("Note created", 201); } + case "Follow": { + const follow = await validator.validate(); - return response("Note created", 201); + const account = await User.resolve(follow.author); + + if (!account) { + return errorResponse("Author not found", 400); + } + + const foundRelationship = await getRelationshipToOtherUser( + account, + user, + ); + + // Check if already following + if (foundRelationship.following) { + return response("Already following", 200); + } + + await db + .update(Relationships) + .set({ + following: !user.getUser().isLocked, + requested: user.getUser().isLocked, + showingReblogs: true, + notifying: true, + languages: [], + }) + .where(eq(Relationships.id, foundRelationship.id)); + + await db.insert(Notifications).values({ + accountId: account.id, + type: user.getUser().isLocked ? "follow_request" : "follow", + notifiedId: user.id, + }); + + if (!user.getUser().isLocked) { + // Federate FollowAccept + await sendFollowAccept(account, user); + } + + return response("Follow request sent", 200); + } + case "FollowAccept": { + const followAccept = + await validator.validate(); + + console.log(followAccept); + + const account = await User.resolve(followAccept.author); + + if (!account) { + return errorResponse("Author not found", 400); + } + + console.log(account); + + const foundRelationship = await getRelationshipToOtherUser( + user, + account, + ); + + console.log(foundRelationship); + + if (!foundRelationship.requested) { + return response( + "There is no follow request to accept", + 200, + ); + } + + await db + .update(Relationships) + .set({ + following: true, + requested: false, + }) + .where(eq(Relationships.id, foundRelationship.id)); + + return response("Follow request accepted", 200); + } + case "FollowReject": { + const followReject = + await validator.validate(); + + const account = await User.resolve(followReject.author); + + if (!account) { + return errorResponse("Author not found", 400); + } + + const foundRelationship = await getRelationshipToOtherUser( + user, + account, + ); + + if (!foundRelationship.requested) { + return response( + "There is no follow request to reject", + 200, + ); + } + + await db + .update(Relationships) + .set({ + requested: false, + following: false, + }) + .where(eq(Relationships.id, foundRelationship.id)); + + return response("Follow request rejected", 200); + } + default: { + return errorResponse("Object has not been implemented", 400); + } } - case "Follow": { - const follow = body as Lysand.Follow; - - const account = await resolveUser(follow.author); - - if (!account) { - return errorResponse("Author not found", 400); - } - - const foundRelationship = await getRelationshipToOtherUser( - account, - user, - ); - - // Check if already following - if (foundRelationship.following) { - return response("Already following", 200); - } - - await db - .update(Relationships) - .set({ - following: !user.isLocked, - requested: user.isLocked, - showingReblogs: true, - notifying: true, - languages: [], - }) - .where(eq(Relationships.id, foundRelationship.id)); - - await db.insert(Notifications).values({ - accountId: account.id, - type: user.isLocked ? "follow_request" : "follow", - notifiedId: user.id, - }); - - if (!user.isLocked) { - // Federate FollowAccept - await sendFollowAccept(account, user); - } - - return response("Follow request sent", 200); - } - case "FollowAccept": { - const followAccept = body as Lysand.FollowAccept; - - console.log(followAccept); - - const account = await resolveUser(followAccept.author); - - if (!account) { - return errorResponse("Author not found", 400); - } - - console.log(account); - - const foundRelationship = await getRelationshipToOtherUser( - user, - account, - ); - - console.log(foundRelationship); - - if (!foundRelationship.requested) { - return response("There is no follow request to accept", 200); - } - - await db - .update(Relationships) - .set({ - following: true, - requested: false, - }) - .where(eq(Relationships.id, foundRelationship.id)); - - return response("Follow request accepted", 200); - } - case "FollowReject": { - const followReject = body as Lysand.FollowReject; - - const account = await resolveUser(followReject.author); - - if (!account) { - return errorResponse("Author not found", 400); - } - - const foundRelationship = await getRelationshipToOtherUser( - user, - account, - ); - - if (!foundRelationship.requested) { - return response("There is no follow request to reject", 200); - } - - await db - .update(Relationships) - .set({ - requested: false, - following: false, - }) - .where(eq(Relationships.id, foundRelationship.id)); - - return response("Follow request rejected", 200); - } - default: { - return errorResponse("Unknown object type", 400); + } catch (e) { + if (isValidationError(e)) { + return errorResponse(e.message, 400); } + dualLogger.logError(LogLevel.ERROR, "Inbox", e as Error); + return jsonResponse( + { + error: "Failed to process request", + message: (e as Error).message, + }, + 500, + ); } - - //return jsonResponse(userToLysand(user)); }); diff --git a/server/api/users/[uuid]/index.ts b/server/api/users/[uuid]/index.ts index 34cd81e7..373e4307 100644 --- a/server/api/users/[uuid]/index.ts +++ b/server/api/users/[uuid]/index.ts @@ -1,6 +1,6 @@ import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import { findFirstUser, userToLysand } from "~database/entities/User"; +import { User } from "~packages/database-interface/user"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -17,13 +17,11 @@ export const meta = applyConfig({ export default apiRoute(async (req, matchedRoute) => { const uuid = matchedRoute.params.uuid; - const user = await findFirstUser({ - where: (user, { eq }) => eq(user.id, uuid), - }); + const user = await User.fromId(uuid); if (!user) { return errorResponse("User not found", 404); } - return jsonResponse(userToLysand(user)); + return jsonResponse(user.toLysand()); }); diff --git a/server/api/well-known/webfinger/index.ts b/server/api/well-known/webfinger/index.ts index 2b6feace..a40a2579 100644 --- a/server/api/well-known/webfinger/index.ts +++ b/server/api/well-known/webfinger/index.ts @@ -1,8 +1,10 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import { z } from "zod"; -import { findFirstUser, getAvatarUrl } from "~database/entities/User"; +import { eq } from "drizzle-orm"; import { lookup } from "mime-types"; +import { z } from "zod"; +import { Users } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -44,20 +46,18 @@ export default apiRoute( const isUuid = requestedUser.split("@")[0].match(idValidator); - const user = await findFirstUser({ - where: (user, { eq }) => - eq( - isUuid ? user.id : user.username, - requestedUser.split("@")[0], - ), - }); + const user = await User.fromSql( + eq(isUuid ? Users.id : Users.username, requestedUser.split("@")[0]), + ); if (!user) { return errorResponse("User not found", 404); } return jsonResponse({ - subject: `acct:${isUuid ? user.id : user.username}@${host}`, + subject: `acct:${ + isUuid ? user.id : user.getUser().username + }@${host}`, links: [ { @@ -70,8 +70,8 @@ export default apiRoute( }, { rel: "avatar", - type: lookup(getAvatarUrl(user, config)), - href: getAvatarUrl(user, config), + type: lookup(user.getAvatarUrl(config)), + href: user.getAvatarUrl(config), }, ], }); diff --git a/tests/api/accounts.test.ts b/tests/api/accounts.test.ts index 661264ff..f77b002f 100644 --- a/tests/api/accounts.test.ts +++ b/tests/api/accounts.test.ts @@ -72,7 +72,7 @@ describe("API Tests", () => { const account = (await response.json()) as APIAccount; - expect(account.username).toBe(user.username); + expect(account.username).toBe(user.getUser().username); expect(account.bot).toBe(false); expect(account.locked).toBe(false); expect(account.created_at).toBeDefined(); @@ -81,7 +81,10 @@ describe("API Tests", () => { expect(account.statuses_count).toBe(0); expect(account.note).toBe(""); expect(account.url).toBe( - new URL(`/@${user.username}`, config.http.base_url).toString(), + new URL( + `/@${user.getUser().username}`, + config.http.base_url, + ).toString(), ); expect(account.avatar).toBeDefined(); expect(account.avatar_static).toBeDefined(); diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts index d97f8aac..2f278494 100644 --- a/tests/oauth.test.ts +++ b/tests/oauth.test.ts @@ -61,7 +61,7 @@ describe("POST /api/auth/login/", () => { test("should get a JWT", async () => { const formData = new FormData(); - formData.append("email", users[0]?.email ?? ""); + formData.append("email", users[0]?.getUser().email ?? ""); formData.append("password", passwords[0]); const response = await sendTestRequest( diff --git a/tests/utils.ts b/tests/utils.ts index d46f6daf..2bbf5260 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -1,15 +1,12 @@ import { randomBytes } from "node:crypto"; import { asc, inArray, like } from "drizzle-orm"; import type { Status } from "~database/entities/Status"; -import { - type User, - type UserWithRelations, - createNewLocalUser, -} from "~database/entities/User"; +import { createNewLocalUser } from "~database/entities/User"; import { db } from "~drizzle/db"; import { Notes, Tokens, Users } from "~drizzle/schema"; import { server } from "~index"; import { Note } from "~packages/database-interface/note"; +import type { User } from "~packages/database-interface/user"; /** * This allows us to send a test request to the server even when it isnt running * CURRENTLY NOT WORKING, NEEDS TO BE FIXED @@ -30,7 +27,7 @@ export const deleteOldTestUsers = async () => { }; export const getTestUsers = async (count: number) => { - const users: UserWithRelations[] = []; + const users: User[] = []; const passwords: string[] = []; for (let i = 0; i < count; i++) { diff --git a/utils/meilisearch.ts b/utils/meilisearch.ts index 2234385b..c4ff79b1 100644 --- a/utils/meilisearch.ts +++ b/utils/meilisearch.ts @@ -4,9 +4,10 @@ import { count } from "drizzle-orm"; import { LogLevel, type LogManager, type MultiLogManager } from "log-manager"; import { Meilisearch } from "meilisearch"; import type { Status } from "~database/entities/Status"; -import type { User } from "~database/entities/User"; +import type { UserType } from "~database/entities/User"; import { db } from "~drizzle/db"; import { Notes, Users } from "~drizzle/schema"; +import type { User } from "~packages/database-interface/user"; export const meilisearch = new Meilisearch({ host: `${config.meilisearch.host}:${config.meilisearch.port}`, @@ -71,10 +72,10 @@ export const addUserToMeilisearch = async (user: User) => { await meilisearch.index(MeiliIndexType.Accounts).addDocuments([ { id: user.id, - username: user.username, - displayName: user.displayName, - note: user.note, - createdAt: user.createdAt, + username: user.getUser().username, + displayName: user.getUser().displayName, + note: user.getUser().note, + createdAt: user.getUser().createdAt, }, ]); }; diff --git a/utils/timelines.ts b/utils/timelines.ts index 69e879ed..c80c6a43 100644 --- a/utils/timelines.ts +++ b/utils/timelines.ts @@ -4,10 +4,10 @@ import type { findManyNotifications, } from "~database/entities/Notification"; import type { Status, findManyNotes } from "~database/entities/Status"; -import type { User, findManyUsers } from "~database/entities/User"; +import type { UserType, findManyUsers } from "~database/entities/User"; import type { db } from "~drizzle/db"; -export async function fetchTimeline( +export async function fetchTimeline( model: | typeof findManyNotes | typeof findManyUsers