diff --git a/benchmarks/posting.ts b/benchmarks/posting.ts index fdb405ab..f3e90d46 100644 --- a/benchmarks/posting.ts +++ b/benchmarks/posting.ts @@ -2,10 +2,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { getConfig } from "@config"; import { AppDataSource } from "~database/datasource"; -import { Application } from "~database/entities/Application"; +import { ApplicationAction } from "~database/entities/Application"; import { RawActivity } from "~database/entities/RawActivity"; import { Token, TokenType } from "~database/entities/Token"; -import { User } from "~database/entities/User"; +import { UserAction } from "~database/entities/User"; const config = getConfig(); @@ -13,14 +13,14 @@ let token: Token; if (!AppDataSource.isInitialized) await AppDataSource.initialize(); // Initialize test user -const user = await User.createNewLocal({ +const user = await UserAction.createNewLocal({ email: "test@test.com", username: "test", password: "test", display_name: "", }); -const app = new Application(); +const app = new ApplicationAction(); app.name = "Test Application"; app.website = "https://example.com"; diff --git a/bun.lockb b/bun.lockb index 9fb7e5c1..50befd6c 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/database/datasource.ts b/database/datasource.ts index 70447cac..1bec9a7b 100644 --- a/database/datasource.ts +++ b/database/datasource.ts @@ -1,5 +1,6 @@ import { DataSource } from "typeorm"; import { getConfig } from "../utils/config"; +import { PrismaClient } from "@prisma/client"; const config = getConfig(); @@ -14,4 +15,8 @@ const AppDataSource = new DataSource({ entities: [process.cwd() + "/database/entities/*.ts"], }); -export { AppDataSource }; +const client = new PrismaClient({ + datasourceUrl: `postgresql://${config.database.username}:${config.database.password}@${config.database.host}:${config.database.port}/${config.database.database}`, +}); + +export { AppDataSource, client }; diff --git a/database/entities/Application.ts b/database/entities/Application.ts index 16eb7689..94a79cf6 100644 --- a/database/entities/Application.ts +++ b/database/entities/Application.ts @@ -1,76 +1,42 @@ -import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm"; import { APIApplication } from "~types/entities/application"; -import { Token } from "./Token"; +import { Application } from "@prisma/client"; +import { client } from "~database/datasource"; /** * Represents an application that can authenticate with the API. */ -@Entity({ - name: "applications", -}) -export class Application extends BaseEntity { - /** The unique identifier for this application. */ - @PrimaryGeneratedColumn("uuid") - id!: string; - /** The name of this application. */ - @Column("varchar") - name!: string; +/** + * Retrieves the application associated with the given access token. + * @param token The access token to retrieve the application for. + * @returns The application associated with the given access token, or null if no such application exists. + */ +export const getFromToken = async ( + token: string +): Promise => { + const dbToken = await client.token.findFirst({ + where: { + access_token: token, + }, + include: { + application: true, + }, + }); - /** The website associated with this application, if any. */ - @Column("varchar", { - nullable: true, - }) - website!: string | null; + return dbToken?.application || null; +}; - /** The VAPID key associated with this application, if any. */ - @Column("varchar", { - nullable: true, - }) - vapid_key!: string | null; - - /** The client ID associated with this application. */ - @Column("varchar") - client_id!: string; - - /** The secret associated with this application. */ - @Column("varchar") - secret!: string; - - /** The scopes associated with this application. */ - @Column("varchar") - scopes = "read"; - - /** The redirect URIs associated with this application. */ - @Column("varchar") - redirect_uris = "urn:ietf:wg:oauth:2.0:oob"; - - /** - * Retrieves the application associated with the given access token. - * @param token The access token to retrieve the application for. - * @returns The application associated with the given access token, or null if no such application exists. - */ - static async getFromToken(token: string): Promise { - const dbToken = await Token.findOne({ - where: { - access_token: token, - }, - relations: ["application"], - }); - - return dbToken?.application || null; - } - - /** - * Converts this application to an API application. - * @returns The API application representation of this application. - */ +/** + * Converts this application to an API application. + * @returns The API application representation of this application. + */ +export const applicationToAPI = async ( + app: Application // eslint-disable-next-line @typescript-eslint/require-await - async toAPI(): Promise { - return { - name: this.name, - website: this.website, - vapid_key: this.vapid_key, - }; - } -} +): Promise => { + return { + name: app.name, + website: app.website, + vapid_key: app.vapid_key, + }; +}; diff --git a/database/entities/Emoji.ts b/database/entities/Emoji.ts index 45d3e5ef..c2359dfc 100644 --- a/database/entities/Emoji.ts +++ b/database/entities/Emoji.ts @@ -1,137 +1,78 @@ -import { - BaseEntity, - Column, - Entity, - IsNull, - ManyToOne, - PrimaryGeneratedColumn, -} from "typeorm"; import { APIEmoji } from "~types/entities/emoji"; -import { Instance } from "./Instance"; import { Emoji as LysandEmoji } from "~types/lysand/extensions/org.lysand/custom_emojis"; +import { client } from "~database/datasource"; +import { Emoji } from "@prisma/client"; /** * Represents an emoji entity in the database. */ -@Entity({ - name: "emojis", -}) -export class Emoji extends BaseEntity { - /** - * The unique identifier for the emoji. - */ - @PrimaryGeneratedColumn("uuid") - id!: string; - /** - * The shortcode for the emoji. - */ - @Column("varchar") - shortcode!: string; - - /** - * The instance that the emoji is from. - * If is null, the emoji is from the server's instance - */ - @ManyToOne(() => Instance, { - nullable: true, - }) - instance!: Instance | null; - - /** - * The URL for the emoji. - */ - @Column("varchar") - url!: string; - - /** - * The alt text for the emoji. - */ - @Column("varchar", { - nullable: true, - }) - alt!: string | null; - - /** - * The content type of the emoji. - */ - @Column("varchar") - content_type!: string; - - /** - * Whether the emoji is visible in the picker. - */ - @Column("boolean") - visible_in_picker!: boolean; - - /** - * Used for parsing emojis from local text - * @param text The text to parse - * @returns An array of emojis - */ - static async parseEmojis(text: string): Promise { - const regex = /:[a-zA-Z0-9_]+:/g; - const matches = text.match(regex); - if (!matches) return []; - return ( - await Promise.all( - matches.map(match => - Emoji.findOne({ - where: { - shortcode: match.slice(1, -1), - instance: IsNull(), - }, - relations: ["instance"], - }) - ) - ) - ).filter(emoji => emoji !== null) as Emoji[]; - } - - static async addIfNotExists(emoji: LysandEmoji) { - const existingEmoji = await Emoji.findOne({ - where: { - shortcode: emoji.name, - instance: IsNull(), +/** + * Used for parsing emojis from local text + * @param text The text to parse + * @returns An array of emojis + */ +export const parseEmojis = async (text: string): Promise => { + const regex = /:[a-zA-Z0-9_]+:/g; + const matches = text.match(regex); + if (!matches) return []; + return await client.emoji.findMany({ + where: { + shortcode: { + in: matches.map(match => match.replace(/:/g, "")), }, - }); - if (existingEmoji) return existingEmoji; - const newEmoji = new Emoji(); - newEmoji.shortcode = emoji.name; - // TODO: Content types - newEmoji.url = emoji.url[0].content; - newEmoji.alt = emoji.alt || null; - newEmoji.content_type = emoji.url[0].content_type; - newEmoji.visible_in_picker = true; - await newEmoji.save(); - return newEmoji; - } + }, + include: { + instance: true, + }, + }); +}; - /** - * Converts the emoji to an APIEmoji object. - * @returns The APIEmoji object. - */ - // eslint-disable-next-line @typescript-eslint/require-await - async toAPI(): Promise { - return { - shortcode: this.shortcode, - static_url: this.url, // TODO: Add static version - url: this.url, - visible_in_picker: this.visible_in_picker, - category: undefined, - }; - } +export const addEmojiIfNotExists = async (emoji: LysandEmoji) => { + const existingEmoji = await client.emoji.findFirst({ + where: { + shortcode: emoji.name, + instance: null, + }, + }); - toLysand(): LysandEmoji { - return { - name: this.shortcode, - url: [ - { - content: this.url, - content_type: this.content_type, - }, - ], - alt: this.alt || undefined, - }; - } -} + if (existingEmoji) return existingEmoji; + + return await client.emoji.create({ + data: { + shortcode: emoji.name, + url: emoji.url[0].content, + alt: emoji.alt || null, + content_type: emoji.url[0].content_type, + visible_in_picker: true, + }, + }); +}; + +/** + * Converts the emoji to an APIEmoji object. + * @returns The APIEmoji object. + */ +// eslint-disable-next-line @typescript-eslint/require-await +export const emojiToAPI = async (emoji: Emoji): Promise => { + return { + shortcode: emoji.shortcode, + static_url: emoji.url, // TODO: Add static version + url: emoji.url, + visible_in_picker: emoji.visible_in_picker, + category: undefined, + }; +}; + +export const emojiToLysand = (emoji: Emoji): LysandEmoji => { + return { + name: emoji.shortcode, + url: [ + { + content: emoji.url, + content_type: emoji.content_type, + }, + ], + alt: emoji.alt || undefined, + }; +}; diff --git a/database/entities/Instance.ts b/database/entities/Instance.ts index 36ee42fb..2d6d7152 100644 --- a/database/entities/Instance.ts +++ b/database/entities/Instance.ts @@ -1,90 +1,49 @@ -import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm"; -import { ContentFormat, ServerMetadata } from "~types/lysand/Object"; +import { Instance } from "@prisma/client"; +import { client } from "~database/datasource"; +import { ServerMetadata } from "~types/lysand/Object"; /** * Represents an instance in the database. */ -@Entity({ - name: "instances", -}) -export class Instance extends BaseEntity { - /** - * The unique identifier of the instance. - */ - @PrimaryGeneratedColumn("uuid") - id!: string; - /** - * The base URL of the instance. - * Must not have the https:// or http:// prefix. - */ - @Column("varchar") - base_url!: string; +/** + * Adds an instance to the database if it doesn't already exist. + * @param url + * @returns Either the database instance if it already exists, or a newly created instance. + */ +export const addInstanceIfNotExists = async ( + url: string +): Promise => { + const origin = new URL(url).origin; + const hostname = new URL(url).hostname; - /** - * The name of the instance. - */ - @Column("varchar") - name!: string; + const found = await client.instance.findFirst({ + where: { + base_url: hostname, + }, + }); - /** - * The description of the instance. - */ - @Column("varchar") - version!: string; + if (found) return found; - /** - * The logo of the instance. - */ - @Column("jsonb") - logo?: ContentFormat[]; + // Fetch the instance configuration + const metadata = (await fetch(`${origin}/.well-known/lysand`).then(res => + res.json() + )) as Partial; - /** - * The banner of the instance. - */ - banner?: ContentFormat[]; - - /** - * Adds an instance to the database if it doesn't already exist. - * @param url - * @returns Either the database instance if it already exists, or a newly created instance. - */ - static async addIfNotExists(url: string): Promise { - const origin = new URL(url).origin; - const hostname = new URL(url).hostname; - - const found = await Instance.findOne({ - where: { - base_url: hostname, - }, - }); - - if (found) return found; - - const instance = new Instance(); - - instance.base_url = hostname; - - // Fetch the instance configuration - const metadata = (await fetch(`${origin}/.well-known/lysand`).then( - res => res.json() - )) as Partial; - - if (metadata.type !== "ServerMetadata") { - throw new Error("Invalid instance metadata"); - } - - if (!(metadata.name && metadata.version)) { - throw new Error("Invalid instance metadata"); - } - - instance.name = metadata.name; - instance.version = metadata.version; - instance.logo = metadata.logo; - instance.banner = metadata.banner; - - await instance.save(); - - return instance; + if (metadata.type !== "ServerMetadata") { + throw new Error("Invalid instance metadata"); } -} + + if (!(metadata.name && metadata.version)) { + throw new Error("Invalid instance metadata"); + } + + return await client.instance.create({ + data: { + base_url: hostname, + name: metadata.name, + version: metadata.version, + logo: metadata.logo as any, + }, + }); +}; diff --git a/database/entities/Like.ts b/database/entities/Like.ts index a4bb72d1..1d0d7ceb 100644 --- a/database/entities/Like.ts +++ b/database/entities/Like.ts @@ -1,45 +1,19 @@ -import { - BaseEntity, - CreateDateColumn, - Entity, - ManyToOne, - PrimaryGeneratedColumn, -} from "typeorm"; -import { User } from "./User"; -import { Status } from "./Status"; +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { Like as LysandLike } from "~types/lysand/Object"; import { getConfig } from "@config"; +import { Like } from "@prisma/client"; /** * Represents a Like entity in the database. */ -@Entity({ - name: "likes", -}) -export class Like extends BaseEntity { - /** The unique identifier of the Like. */ - @PrimaryGeneratedColumn("uuid") - id!: string; - /** The User who liked the Status. */ - @ManyToOne(() => User) - liker!: User; - - /** The Status that was liked. */ - @ManyToOne(() => Status) - liked!: Status; - - @CreateDateColumn() - created_at!: Date; - - toLysand(): LysandLike { - return { - id: this.id, - author: this.liker.uri, - type: "Like", - created_at: new Date(this.created_at).toISOString(), - object: this.liked.toLysand().uri, - uri: `${getConfig().http.base_url}/actions/${this.id}`, - }; - } -} +export const toLysand = (like: Like): LysandLike => { + return { + id: like.id, + author: (like as any).liker?.uri, + type: "Like", + created_at: new Date(like.createdAt).toISOString(), + object: (like as any).liked?.uri, + uri: `${getConfig().http.base_url}/actions/${like.id}`, + }; +}; diff --git a/database/entities/Object.ts b/database/entities/Object.ts index a2ab1ee3..aae884d2 100644 --- a/database/entities/Object.ts +++ b/database/entities/Object.ts @@ -1,147 +1,87 @@ -import { - BaseEntity, - Column, - Entity, - ManyToOne, - PrimaryGeneratedColumn, -} from "typeorm"; +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { LysandObject } from "@prisma/client"; +import { client } from "~database/datasource"; import { LysandObjectType } from "~types/lysand/Object"; /** * Represents a Lysand object in the database. */ -@Entity({ - name: "objects", -}) -export class LysandObject extends BaseEntity { - /** - * The unique identifier for the object. If local, same as `remote_id` - */ - @PrimaryGeneratedColumn("uuid") - id!: string; - /** - * UUID of the object across the network. If the object is local, same as `id` - */ - remote_id!: string; +export const createFromObject = async (object: LysandObjectType) => { + const foundObject = await client.lysandObject.findFirst({ + where: { remote_id: object.id }, + include: { + author: true, + }, + }); - /** - * Any valid Lysand type, such as `Note`, `Like`, `Follow`, etc. - */ - @Column("varchar") - type!: string; - - /** - * Remote URI for the object - * Example: `https://example.com/publications/ef235cc6-d68c-4756-b0df-4e6623c4d51c` - */ - @Column("varchar") - uri!: string; - - @Column("timestamp") - created_at!: Date; - - /** - * References an Actor object - */ - @ManyToOne(() => LysandObject, object => object.uri, { - nullable: true, - }) - author!: LysandObject | null; - - @Column("jsonb") - extra_data!: Omit< - Omit, "id">, "uri">, - "type" - >; - - @Column("jsonb") - extensions!: Record; - - static new(type: string, uri: string): LysandObject { - const object = new LysandObject(); - object.type = type; - object.uri = uri; - object.created_at = new Date(); - return object; + if (foundObject) { + return foundObject; } - static async createFromObject(object: LysandObjectType) { - let newObject: LysandObject; + const author = await client.lysandObject.findFirst({ + where: { uri: (object as any).author }, + }); - const foundObject = await LysandObject.findOne({ - where: { remote_id: object.id }, - relations: ["author"], - }); + return await client.lysandObject.create({ + data: { + authorId: author?.id, + created_at: new Date(object.created_at), + extensions: object.extensions || {}, + remote_id: object.id, + type: object.type, + uri: object.uri, + // Rest of data (remove id, author, created_at, extensions, type, uri) + extra_data: Object.fromEntries( + Object.entries(object).filter( + ([key]) => + ![ + "id", + "author", + "created_at", + "extensions", + "type", + "uri", + ].includes(key) + ) + ), + }, + }); +}; - if (foundObject) { - newObject = foundObject; - } else { - newObject = new LysandObject(); - } +export const toLysand = (lyObject: LysandObject): LysandObjectType => { + return { + id: lyObject.remote_id || lyObject.id, + created_at: new Date(lyObject.created_at).toISOString(), + type: lyObject.type, + uri: lyObject.uri, + // @ts-expect-error This works, I promise + ...lyObject.extra_data, + extensions: lyObject.extensions, + }; +}; - const author = await LysandObject.findOne({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - where: { uri: (object as any).author }, - }); +export const isPublication = (lyObject: LysandObject): boolean => { + return lyObject.type === "Note" || lyObject.type === "Patch"; +}; - newObject.author = author; - newObject.created_at = new Date(object.created_at); - newObject.extensions = object.extensions || {}; - newObject.remote_id = object.id; - newObject.type = object.type; - newObject.uri = object.uri; - // Rest of data (remove id, author, created_at, extensions, type, uri) - newObject.extra_data = Object.fromEntries( - Object.entries(object).filter( - ([key]) => - ![ - "id", - "author", - "created_at", - "extensions", - "type", - "uri", - ].includes(key) - ) - ); +export const isAction = (lyObject: LysandObject): boolean => { + return [ + "Like", + "Follow", + "Dislike", + "FollowAccept", + "FollowReject", + "Undo", + "Announce", + ].includes(lyObject.type); +}; - await newObject.save(); - return newObject; - } +export const isActor = (lyObject: LysandObject): boolean => { + return lyObject.type === "User"; +}; - toLysand(): LysandObjectType { - return { - id: this.remote_id || this.id, - created_at: new Date(this.created_at).toISOString(), - type: this.type, - uri: this.uri, - ...this.extra_data, - extensions: this.extensions, - }; - } - - isPublication(): boolean { - return this.type === "Note" || this.type === "Patch"; - } - - isAction(): boolean { - return [ - "Like", - "Follow", - "Dislike", - "FollowAccept", - "FollowReject", - "Undo", - "Announce", - ].includes(this.type); - } - - isActor(): boolean { - return this.type === "User"; - } - - isExtension(): boolean { - return this.type === "Extension"; - } -} +export const isExtension = (lyObject: LysandObject): boolean => { + return lyObject.type === "Extension"; +}; diff --git a/database/entities/Relationship.ts b/database/entities/Relationship.ts index 5bfe53bd..7b3162c3 100644 --- a/database/entities/Relationship.ts +++ b/database/entities/Relationship.ts @@ -1,144 +1,63 @@ -import { - BaseEntity, - Column, - CreateDateColumn, - Entity, - ManyToOne, - PrimaryGeneratedColumn, - UpdateDateColumn, -} from "typeorm"; -import { User } from "./User"; +import { Relationship, User } from "@prisma/client"; import { APIRelationship } from "~types/entities/relationship"; +import { client } from "~database/datasource"; /** * Stores Mastodon API relationships */ -@Entity({ - name: "relationships", -}) -export class Relationship extends BaseEntity { - /** The unique identifier for the relationship. */ - @PrimaryGeneratedColumn("uuid") - id!: string; - /** The user who owns the relationship. */ - @ManyToOne(() => User, user => user.relationships) - owner!: User; +/** + * Creates a new relationship between two users. + * @param owner The user who owns the relationship. + * @param other The user who is the subject of the relationship. + * @returns The newly created relationship. + */ +export const createNew = async ( + owner: User, + other: User +): Promise => { + return await client.relationship.create({ + data: { + ownerId: owner.id, + subjectId: other.id, + languages: [], + following: false, + showingReblogs: false, + notifying: false, + followedBy: false, + blocking: false, + blockedBy: false, + muting: false, + mutingNotifications: false, + requested: false, + domainBlocking: false, + endorsed: false, + note: "", + }, + }); +}; - /** The user who is the subject of the relationship. */ - @ManyToOne(() => User) - subject!: User; - - /** Whether the owner is following the subject. */ - @Column("boolean") - following!: boolean; - - /** Whether the owner is showing reblogs from the subject. */ - @Column("boolean") - showing_reblogs!: boolean; - - /** Whether the owner is receiving notifications from the subject. */ - @Column("boolean") - notifying!: boolean; - - /** Whether the owner is followed by the subject. */ - @Column("boolean") - followed_by!: boolean; - - /** Whether the owner is blocking the subject. */ - @Column("boolean") - blocking!: boolean; - - /** Whether the owner is blocked by the subject. */ - @Column("boolean") - blocked_by!: boolean; - - /** Whether the owner is muting the subject. */ - @Column("boolean") - muting!: boolean; - - /** Whether the owner is muting notifications from the subject. */ - @Column("boolean") - muting_notifications!: boolean; - - /** Whether the owner has requested to follow the subject. */ - @Column("boolean") - requested!: boolean; - - /** Whether the owner is blocking the subject's domain. */ - @Column("boolean") - domain_blocking!: boolean; - - /** Whether the owner has endorsed the subject. */ - @Column("boolean") - endorsed!: boolean; - - /** The languages the owner has specified for the subject. */ - @Column("jsonb") - languages!: string[]; - - /** A note the owner has added for the subject. */ - @Column("varchar") - note!: string; - - /** The date the relationship was created. */ - @CreateDateColumn() - created_at!: Date; - - /** The date the relationship was last updated. */ - @UpdateDateColumn() - updated_at!: Date; - - /** - * Creates a new relationship between two users. - * @param owner The user who owns the relationship. - * @param other The user who is the subject of the relationship. - * @returns The newly created relationship. - */ - static async createNew(owner: User, other: User): Promise { - const newRela = new Relationship(); - newRela.owner = owner; - newRela.subject = other; - newRela.languages = []; - newRela.following = false; - newRela.showing_reblogs = false; - newRela.notifying = false; - newRela.followed_by = false; - newRela.blocking = false; - newRela.blocked_by = false; - newRela.muting = false; - newRela.muting_notifications = false; - newRela.requested = false; - newRela.domain_blocking = false; - newRela.endorsed = false; - newRela.note = ""; - - await newRela.save(); - - return newRela; - } - - /** - * Converts the relationship to an API-friendly format. - * @returns The API-friendly relationship. - */ - // eslint-disable-next-line @typescript-eslint/require-await - async toAPI(): Promise { - return { - blocked_by: this.blocked_by, - blocking: this.blocking, - domain_blocking: this.domain_blocking, - endorsed: this.endorsed, - followed_by: this.followed_by, - following: this.following, - id: this.subject.id, - muting: this.muting, - muting_notifications: this.muting_notifications, - notifying: this.notifying, - requested: this.requested, - showing_reblogs: this.showing_reblogs, - languages: this.languages, - note: this.note, - }; - } -} +/** + * Converts the relationship to an API-friendly format. + * @returns The API-friendly relationship. + */ +// eslint-disable-next-line @typescript-eslint/require-await +export const toAPI = async (rel: Relationship): Promise => { + return { + blocked_by: rel.blockedBy, + blocking: rel.blocking, + domain_blocking: rel.domainBlocking, + endorsed: rel.endorsed, + followed_by: rel.followedBy, + following: rel.following, + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + id: (rel as any).subject.id, + muting: rel.muting, + muting_notifications: rel.mutingNotifications, + notifying: rel.notifying, + requested: rel.requested, + showing_reblogs: rel.showingReblogs, + languages: rel.languages, + note: rel.note, + }; +}; diff --git a/database/entities/Status.ts b/database/entities/Status.ts index 69791eaa..2cecbe3c 100644 --- a/database/entities/Status.ts +++ b/database/entities/Status.ts @@ -1,583 +1,456 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { getConfig } from "@config"; import { - BaseEntity, - Column, - CreateDateColumn, - Entity, - JoinTable, - ManyToMany, - ManyToOne, - PrimaryGeneratedColumn, - RemoveOptions, - Tree, - TreeChildren, - TreeParent, - UpdateDateColumn, -} from "typeorm"; -import { APIStatus } from "~types/entities/status"; -import { User, userRelations } from "./User"; -import { Application } from "./Application"; -import { Emoji } from "./Emoji"; -import { Instance } from "./Instance"; -import { Like } from "./Like"; -import { AppDataSource } from "~database/datasource"; + UserWithRelations, + fetchRemoteUser, + parseMentionsUris, + userRelations, + userToAPI, +} from "./User"; +import { client } from "~database/datasource"; import { LysandPublication, Note } from "~types/lysand/Object"; import { htmlToText } from "html-to-text"; import { getBestContentType } from "@content_types"; +import { + Application, + Emoji, + Instance, + Like, + Relationship, + Status, + User, +} from "@prisma/client"; +import { emojiToAPI, emojiToLysand, parseEmojis } from "./Emoji"; +import { APIStatus } from "~types/entities/status"; +import { applicationToAPI } from "./Application"; const config = getConfig(); -export const statusRelations = [ - "account", - "reblog", - "in_reply_to_post", - "instance", - "in_reply_to_post.account", - "application", - "emojis", - "mentions", -]; +export const statusAndUserRelations = { + author: { + include: userRelations, + }, + application: true, + emojis: true, + inReplyToPost: { + include: { + author: { + include: userRelations, + }, + application: true, + emojis: true, + inReplyToPost: { + include: { + author: true, + }, + }, + instance: true, + mentions: true, + pinnedBy: true, + replies: { + include: { + _count: true, + }, + }, + }, + }, + instance: true, + mentions: true, + pinnedBy: true, + replies: { + include: { + _count: true, + }, + }, + reblog: { + include: { + author: { + include: userRelations, + }, + application: true, + emojis: true, + inReplyToPost: { + include: { + author: true, + }, + }, + instance: true, + mentions: true, + pinnedBy: true, + replies: { + include: { + _count: true, + }, + }, + }, + }, + quotingPost: { + include: { + author: { + include: userRelations, + }, + application: true, + emojis: true, + inReplyToPost: { + include: { + author: true, + }, + }, + instance: true, + mentions: true, + pinnedBy: true, + replies: { + include: { + _count: true, + }, + }, + }, + }, + likes: { + include: { + liker: true, + }, + }, +}; -export const statusAndUserRelations = [ - ...statusRelations, - // Can't directly map to userRelations as variable isnt yet initialized - ...[ - "account.relationships", - "account.pinned_notes", - "account.instance", - "account.emojis", - ], -]; +type StatusWithRelations = Status & { + author: UserWithRelations; + application: Application | null; + emojis: Emoji[]; + inReplyToPost: + | (Status & { + author: UserWithRelations; + application: Application | null; + emojis: Emoji[]; + inReplyToPost: Status | null; + instance: Instance | null; + mentions: User[]; + pinnedBy: User[]; + replies: Status[] & { + _count: number; + }; + }) + | null; + instance: Instance | null; + mentions: User[]; + pinnedBy: User[]; + replies: Status[] & { + _count: number; + }; + reblog: + | (Status & { + author: UserWithRelations; + application: Application | null; + emojis: Emoji[]; + inReplyToPost: Status | null; + instance: Instance | null; + mentions: User[]; + pinnedBy: User[]; + replies: Status[] & { + _count: number; + }; + }) + | null; + quotingPost: + | (Status & { + author: UserWithRelations; + application: Application | null; + emojis: Emoji[]; + inReplyToPost: Status | null; + instance: Instance | null; + mentions: User[]; + pinnedBy: User[]; + replies: Status[] & { + _count: number; + }; + }) + | null; + likes: (Like & { + liker: User; + })[]; +}; /** * Represents a status (i.e. a post) */ -@Entity({ - name: "statuses", -}) -@Tree("closure-table") -export class Status extends BaseEntity { - /** - * The unique identifier for this status. - */ - @PrimaryGeneratedColumn("uuid") - id!: string; - /** - * The URI for this status. - */ - @Column("varchar") - uri!: string; - - /** - * The user account that created this status. - */ - @ManyToOne(() => User, user => user.id) - account!: User; - - /** - * The date and time when this status was created. - */ - @CreateDateColumn() - created_at!: Date; - - /** - * The date and time when this status was last updated. - */ - @UpdateDateColumn() - updated_at!: Date; - - /** - * The status that this status is a reblog of, if any. - */ - @ManyToOne(() => Status, status => status.id, { - nullable: true, - onDelete: "SET NULL", - }) - reblog?: Status | null; - - /** - * Whether this status is a reblog. - */ - @Column("boolean") - isReblog!: boolean; - - /** - * The content of this status. - */ - @Column("varchar", { - default: "", - }) - content!: string; - - /** - * The content type of this status. - */ - @Column("varchar", { - default: "text/plain", - }) - content_type!: string; - - /** - * The visibility of this status. - */ - @Column("varchar") - visibility!: APIStatus["visibility"]; - - /** - * The raw object that this status is a reply to, if any. - */ - @TreeParent({ - onDelete: "SET NULL", - }) - in_reply_to_post!: Status | null; - - /** - * The status that this status is quoting, if any - */ - @ManyToOne(() => Status, status => status.id, { - nullable: true, - }) - quoting_post!: Status | null; - - @TreeChildren() - replies!: Status[]; - - /** - * The status' instance - */ - @ManyToOne(() => Instance, { - nullable: true, - }) - instance!: Instance | null; - - /** - * Whether this status is sensitive. - */ - @Column("boolean") - sensitive!: boolean; - - /** - * The spoiler text for this status. - */ - @Column("varchar", { - default: "", - }) - spoiler_text!: string; - - /** - * The application associated with this status, if any. - */ - @ManyToOne(() => Application, app => app.id, { - nullable: true, - }) - application!: Application | null; - - /** - * The emojis associated with this status. - */ - @ManyToMany(() => Emoji, emoji => emoji.id) - @JoinTable() - emojis!: Emoji[]; - - /** - * The users mentioned (excluding followers and such) - */ - @ManyToMany(() => User, user => user.id) - @JoinTable() - mentions!: User[]; - - /** - * Removes this status from the database. - * @param options The options for removing this status. - * @returns A promise that resolves when the status has been removed. - */ - async remove(options?: RemoveOptions | undefined) { - // Delete object - - // Get all associated Likes and remove them as well - await Like.delete({ - liked: { - id: this.id, - }, - }); - - return await super.remove(options); - } - - async parseEmojis(string: string) { - const emojis = [...string.matchAll(/:([a-zA-Z0-9_]+):/g)].map( - match => match[1] +/** + * Returns whether this status is viewable by a user. + * @param user The user to check. + * @returns Whether this status is viewable by the user. + */ +export const isViewableByUser = (status: Status, user: User | null) => { + if (status.visibility === "public") return true; + else if (status.visibility === "unlisted") return true; + else if (status.visibility === "private") { + // @ts-expect-error Prisma TypeScript types dont include relations + return !!(user?.relationships as Relationship[]).find( + rel => rel.id === status.authorId ); + } else { + // @ts-expect-error Prisma TypeScript types dont include relations + return user && (status.mentions as User[]).includes(user); + } +}; - const emojiObjects = await Promise.all( - emojis.map(async emoji => { - const emojiObject = await Emoji.findOne({ - where: { - shortcode: emoji, - }, - }); +export const fetchFromRemote = async (uri: string): Promise => { + // Check if already in database - return emojiObject; - }) - ); + const existingStatus = await client.status.findFirst({ + where: { + uri: uri, + }, + include: statusAndUserRelations, + }); - return emojiObjects.filter(emoji => emoji !== null) as Emoji[]; + if (existingStatus) return existingStatus; + + const status = await fetch(uri); + + if (status.status === 404) return null; + + const body = (await status.json()) as LysandPublication; + + const content = getBestContentType(body.contents); + + const emojis = await parseEmojis(content?.content || ""); + + const author = await fetchRemoteUser(body.author); + + let replyStatus: Status | null = null; + let quotingStatus: Status | null = null; + + if (body.replies_to.length > 0) { + replyStatus = await fetchFromRemote(body.replies_to[0]); } - /** - * Returns whether this status is viewable by a user. - * @param user The user to check. - * @returns Whether this status is viewable by the user. - */ - isViewableByUser(user: User | null) { - const relationship = user?.relationships.find( - rel => rel.id === this.account.id - ); - - if (this.visibility === "public") return true; - else if (this.visibility === "unlisted") return true; - else if (this.visibility === "private") { - return !!relationship?.following; - } else { - return user && this.mentions.includes(user); - } + if (body.quotes.length > 0) { + quotingStatus = await fetchFromRemote(body.quotes[0]); } - static async fetchFromRemote(uri: string) { - // Check if already in database + return await createNew({ + account: author, + content: content?.content || "", + content_type: content?.content_type, + application: null, + // TODO: Add visibility + visibility: "public", + spoiler_text: body.subject || "", + uri: body.uri, + sensitive: body.is_sensitive, + emojis: emojis, + mentions: await parseMentionsUris(body.mentions), + reply: replyStatus + ? { + status: replyStatus, + user: (replyStatus as any).author, + } + : undefined, + quote: quotingStatus || undefined, + }); +}; - const existingStatus = await Status.findOne({ - where: { - uri: uri, - }, - }); +/** + * Return all the ancestors of this post, + */ +// eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars +export const getAncestors = async (fetcher: UserWithRelations | null) => { + // TODO: Implement + return []; +}; - if (existingStatus) return existingStatus; +/** + * Return all the descendants of this post, + */ +// eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars +export const getDescendants = async (fetcher: UserWithRelations | null) => { + // TODO: Implement + return []; +}; - const status = await fetch(uri); - - if (status.status === 404) return null; - - const body = (await status.json()) as LysandPublication; - - const content = getBestContentType(body.contents); - - const emojis = await Emoji.parseEmojis(content?.content || ""); - - const author = await User.fetchRemoteUser(body.author); - - let replyStatus: Status | null = null; - let quotingStatus: Status | null = null; - - if (body.replies_to.length > 0) { - replyStatus = await Status.fetchFromRemote(body.replies_to[0]); - } - - if (body.quotes.length > 0) { - quotingStatus = await Status.fetchFromRemote(body.quotes[0]); - } - - const newStatus = await Status.createNew({ - account: author, - content: content?.content || "", - content_type: content?.content_type, - application: null, - // TODO: Add visibility - visibility: "public", - spoiler_text: body.subject || "", - uri: body.uri, - sensitive: body.is_sensitive, - emojis: emojis, - mentions: await User.parseMentions(body.mentions), - reply: replyStatus - ? { - status: replyStatus, - user: replyStatus.account, - } - : undefined, - quote: quotingStatus || undefined, - }); - - return await newStatus.save(); - } - - /** - * Return all the ancestors of this post, - */ - async getAncestors(fetcher: User | null) { - const max = fetcher ? 4096 : 40; - const ancestors = []; - - let id = this.in_reply_to_post?.id; - - while (ancestors.length < max && id) { - const currentStatus = await Status.findOne({ - where: { - id: id, - }, - relations: statusAndUserRelations, - }); - - if (currentStatus) { - if (currentStatus.isViewableByUser(fetcher)) { - ancestors.push(currentStatus); - } - id = currentStatus.in_reply_to_post?.id; - } else { - break; - } - } - - return ancestors; - } - - /** - * Return all the descendants of this post, - */ - async getDescendants(fetcher: User | null) { - const max = fetcher ? 4096 : 60; - - const descendants = await AppDataSource.getTreeRepository( - Status - ).findDescendantsTree(this, { - depth: fetcher ? 20 : undefined, - relations: statusAndUserRelations, - }); - - // Go through .replies of each descendant recursively and add them to the list - const flatten = (descendants: Status): Status[] => { - const flattened = []; - - for (const descendant of descendants.replies) { - if (descendant.isViewableByUser(fetcher)) { - flattened.push(descendant); - } - - flattened.push(...flatten(descendant)); - } - - return flattened; - }; - - const flattened = flatten(descendants); - - return flattened.slice(0, max); - } - - /** - * Creates a new status and saves it to the database. - * @param data The data for the new status. - * @returns A promise that resolves with the new status. - */ - static async createNew(data: { - account: User; - application: Application | null; - content: string; - visibility: APIStatus["visibility"]; - sensitive: boolean; - spoiler_text: string; - emojis: Emoji[]; - content_type?: string; - uri?: string; - mentions?: User[]; - reply?: { - status: Status; - user: User; - }; - quote?: Status; - }) { - const newStatus = new Status(); - - newStatus.account = data.account; - newStatus.application = data.application ?? null; - newStatus.content = data.content; - newStatus.content_type = data.content_type ?? "text/plain"; - newStatus.visibility = data.visibility; - newStatus.sensitive = data.sensitive; - newStatus.spoiler_text = data.spoiler_text; - newStatus.emojis = data.emojis; - newStatus.isReblog = false; - newStatus.mentions = []; - newStatus.instance = data.account.instance; - newStatus.uri = - data.uri || `${config.http.base_url}/statuses/${newStatus.id}`; - newStatus.quoting_post = data.quote || null; - - if (data.reply) { - newStatus.in_reply_to_post = data.reply.status; - } - // Get people mentioned in the content - const mentionedPeople = [ - ...data.content.matchAll(/@([a-zA-Z0-9_]+)/g), - ].map(match => { +/** + * Creates a new status and saves it to the database. + * @param data The data for the new status. + * @returns A promise that resolves with the new status. + */ +const createNew = async (data: { + account: User; + application: Application | null; + content: string; + visibility: APIStatus["visibility"]; + sensitive: boolean; + spoiler_text: string; + emojis: Emoji[]; + content_type?: string; + uri?: string; + mentions?: User[]; + reply?: { + status: Status; + user: User; + }; + quote?: Status; +}) => { + // Get people mentioned in the content + const mentionedPeople = [...data.content.matchAll(/@([a-zA-Z0-9_]+)/g)].map( + match => { return `${config.http.base_url}/users/${match[1]}`; - }); - - // Get list of mentioned users - if (!data.mentions) { - await Promise.all( - mentionedPeople.map(async person => { - // Check if post is in format @username or @username@instance.com - // If is @username, the user is a local user - const instanceUrl = - person.split("@").length === 3 - ? person.split("@")[2] - : null; - - if (instanceUrl) { - const user = await User.findOne({ - where: { - username: person.split("@")[1], - // If contains instanceUrl - instance: { - base_url: instanceUrl, - }, - }, - relations: userRelations, - }); - - newStatus.mentions.push(user as User); - } else { - const user = await User.findOne({ - where: { - username: person.split("@")[1], - }, - relations: userRelations, - }); - - newStatus.mentions.push(user as User); - } - }) - ); - } else { - newStatus.mentions = data.mentions; } + ); - await newStatus.save(); - return newStatus; + let mentions = data.mentions || []; + + // Get list of mentioned users + if (mentions.length === 0) { + mentions = await client.user.findMany({ + where: { + OR: mentionedPeople.map(person => ({ + username: person.split("@")[1], + instance: { + base_url: person.split("@")[2], + }, + })), + }, + include: userRelations, + }); } - async isFavouritedBy(user: User) { - const like = await Like.findOne({ + const status = await client.status.create({ + data: { + authorId: data.account.id, + applicationId: data.application?.id, + content: data.content, + contentType: data.content_type, + visibility: data.visibility, + sensitive: data.sensitive, + spoilerText: data.spoiler_text, + emojis: { + connect: data.emojis.map(emoji => { + return { + id: emoji.id, + }; + }), + }, + inReplyToPostId: data.reply?.status.id, + quotingPostId: data.quote?.id, + instanceId: data.account.instanceId || undefined, + isReblog: false, + uri: data.uri || `${config.http.base_url}/statuses/xxx`, + mentions: { + connect: mentions.map(mention => { + return { + id: mention.id, + }; + }), + }, + }, + include: statusAndUserRelations, + }); + + if (!data.uri) status.uri = `${config.http.base_url}/statuses/${status.id}`; + + return status; +}; + +export const isFavouritedBy = async (status: Status, user: User) => { + return !!(await client.like.findFirst({ + where: { + likerId: user.id, + likedId: status.id, + }, + })); +}; + +/** + * Converts this status to an API status. + * @returns A promise that resolves with the API status. + */ +export const statusToAPI = async ( + status: StatusWithRelations, + user?: UserWithRelations +): Promise => { + return { + id: status.id, + in_reply_to_id: status.inReplyToPostId || null, + in_reply_to_account_id: status.inReplyToPost?.authorId || null, + account: await userToAPI(status.author), + created_at: new Date(status.createdAt).toISOString(), + application: status.application + ? await applicationToAPI(status.application) + : null, + card: null, + content: status.content, + emojis: await Promise.all( + status.emojis.map(emoji => emojiToAPI(emoji)) + ), + favourited: !!status.likes.find(like => like.likerId === user?.id), + favourites_count: status.likes.length, + media_attachments: [], + mentions: [], + language: null, + muted: user + ? user.relationships.find(r => r.subjectId == status.authorId) + ?.muting || false + : false, + pinned: status.author.pinnedNotes.some(note => note.id === status.id), + // TODO: Add pols + poll: null, + reblog: status.reblog + ? await statusToAPI(status.reblog as unknown as StatusWithRelations) + : null, + reblogged: !!(await client.status.findFirst({ where: { - liker: { - id: user.id, - }, - liked: { - id: this.id, - }, + authorId: user?.id, + reblogId: status.id, }, - relations: ["liker"], - }); - - return !!like; - } - - /** - * Converts this status to an API status. - * @returns A promise that resolves with the API status. - */ - async toAPI(user?: User): Promise { - const reblogCount = await Status.count({ + })), + reblogs_count: await client.status.count({ where: { - reblog: { - id: this.id, - }, + reblogId: status.id, }, - relations: ["reblog"], - }); + }), + replies_count: status.replies._count, + sensitive: status.sensitive, + spoiler_text: status.spoilerText, + tags: [], + uri: `${config.http.base_url}/statuses/${status.id}`, + visibility: "public", + url: `${config.http.base_url}/statuses/${status.id}`, + bookmarked: false, + quote: null, + quote_id: undefined, + }; +}; - const repliesCount = await Status.count({ - where: { - in_reply_to_post: { - id: this.id, - }, +export const statusToLysand = (status: StatusWithRelations): Note => { + return { + type: "Note", + created_at: new Date(status.createdAt).toISOString(), + id: status.id, + author: status.authorId, + uri: `${config.http.base_url}/users/${status.authorId}/statuses/${status.id}`, + contents: [ + { + content: status.content, + content_type: "text/html", }, - relations: ["in_reply_to_post"], - }); - - const favourited = user ? await this.isFavouritedBy(user) : false; - - const favourites_count = await Like.count({ - where: { - liked: { - id: this.id, - }, + { + // Content converted to plaintext + content: htmlToText(status.content), + content_type: "text/plain", }, - relations: ["liked"], - }); - - return { - id: this.id, - in_reply_to_id: this.in_reply_to_post?.id || null, - in_reply_to_account_id: this.in_reply_to_post?.account.id || null, - account: await this.account.toAPI(), - created_at: new Date(this.created_at).toISOString(), - application: (await this.application?.toAPI()) || null, - card: null, - content: this.content, - emojis: await Promise.all(this.emojis.map(emoji => emoji.toAPI())), - favourited, - favourites_count: favourites_count, - media_attachments: [], - mentions: await Promise.all( - this.mentions.map(async m => await m.toAPI()) - ), - language: null, - muted: false, - pinned: this.account.pinned_notes.some(note => note.id === this.id), - poll: null, - reblog: this.reblog ? await this.reblog.toAPI() : null, - reblogged: !!this.reblog, - reblogs_count: reblogCount, - replies_count: repliesCount, - sensitive: this.sensitive, - spoiler_text: this.spoiler_text, - tags: [], - uri: `${config.http.base_url}/users/${this.account.username}/statuses/${this.id}`, - visibility: "public", - url: `${config.http.base_url}/users/${this.account.username}/statuses/${this.id}`, - bookmarked: false, - quote: null, - quote_id: undefined, - }; - } - - toLysand(): Note { - return { - type: "Note", - created_at: new Date(this.created_at).toISOString(), - id: this.id, - author: this.account.uri, - uri: `${config.http.base_url}/users/${this.account.id}/statuses/${this.id}`, - contents: [ - { - content: this.content, - content_type: "text/html", - }, - { - // Content converted to plaintext - content: htmlToText(this.content), - content_type: "text/plain", - }, - ], - // TODO: Add attachments - attachments: [], - is_sensitive: this.sensitive, - mentions: this.mentions.map(mention => mention.id), - // TODO: Add quotes - quotes: [], - replies_to: this.in_reply_to_post?.id - ? [this.in_reply_to_post.id] - : [], - subject: this.spoiler_text, - extensions: { - "org.lysand:custom_emojis": { - emojis: this.emojis.map(emoji => emoji.toLysand()), - }, - // TODO: Add polls and reactions + ], + // TODO: Add attachments + attachments: [], + is_sensitive: status.sensitive, + mentions: status.mentions.map(mention => mention.uri), + quotes: status.quotingPost ? [status.quotingPost.uri] : [], + replies_to: status.inReplyToPostId ? [status.inReplyToPostId] : [], + subject: status.spoilerText, + extensions: { + "org.lysand:custom_emojis": { + emojis: status.emojis.map(emoji => emojiToLysand(emoji)), }, - }; - } -} + // TODO: Add polls and reactions + }, + }; +}; diff --git a/database/entities/Token.ts b/database/entities/Token.ts index db14c330..a46b9ab2 100644 --- a/database/entities/Token.ts +++ b/database/entities/Token.ts @@ -1,54 +1,3 @@ -import { - Entity, - BaseEntity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - ManyToOne, -} from "typeorm"; -import { User } from "./User"; -import { Application } from "./Application"; - -/** - * Represents an access token for a user or application. - */ -@Entity({ - name: "tokens", -}) -export class Token extends BaseEntity { - /** The unique identifier for the token. */ - @PrimaryGeneratedColumn("uuid") - id!: string; - - /** The type of token. */ - @Column("varchar") - token_type: TokenType = TokenType.BEARER; - - /** The scope of the token. */ - @Column("varchar") - scope!: string; - - /** The access token string. */ - @Column("varchar") - access_token!: string; - - /** The authorization code used to obtain the token. */ - @Column("varchar") - code!: string; - - /** The date and time the token was created. */ - @CreateDateColumn() - created_at!: Date; - - /** The user associated with the token. */ - @ManyToOne(() => User, user => user.id) - user!: User; - - /** The application associated with the token. */ - @ManyToOne(() => Application, application => application.id) - application!: Application; -} - /** * The type of token. */ diff --git a/database/entities/User.ts b/database/entities/User.ts index 6e08df94..13c31bf9 100644 --- a/database/entities/User.ts +++ b/database/entities/User.ts @@ -1,655 +1,462 @@ import { ConfigType, getConfig } from "@config"; -import { - BaseEntity, - Column, - CreateDateColumn, - Entity, - JoinTable, - ManyToMany, - ManyToOne, - OneToMany, - PrimaryGeneratedColumn, - RemoveOptions, - UpdateDateColumn, -} from "typeorm"; import { APIAccount } from "~types/entities/account"; -import { Token } from "./Token"; -import { Status, statusRelations } from "./Status"; -import { APISource } from "~types/entities/source"; -import { Relationship } from "./Relationship"; -import { Instance } from "./Instance"; import { User as LysandUser } from "~types/lysand/Object"; import { htmlToText } from "html-to-text"; -import { Emoji } from "./Emoji"; +import { + Emoji, + Instance, + Like, + Relationship, + Status, + User, +} from "@prisma/client"; +import { client } from "~database/datasource"; +import { addEmojiIfNotExists, emojiToAPI, emojiToLysand } from "./Emoji"; +import { addInstanceIfNotExists } from "./Instance"; +import { APISource } from "~types/entities/source"; -export const userRelations = [ - "relationships", - "pinned_notes", - "instance", - "emojis", -]; +export interface AuthData { + user: UserWithRelations | null; + token: string; +} /** * Represents a user in the database. * Stores local and remote users */ -@Entity({ - name: "users", -}) -export class User extends BaseEntity { - /** - * The unique identifier for the user. - */ - @PrimaryGeneratedColumn("uuid") - id!: string; - /** - * The user URI on the global network - */ - @Column("varchar") - uri!: string; +export const userRelations = { + emojis: true, + instance: true, + likes: true, + relationships: true, + relationshipSubjects: true, + pinnedNotes: true, + statuses: { + select: { + _count: true, + }, + }, +}; - /** - * The username for the user. - */ - @Column("varchar", { - unique: true, - }) - username!: string; +export type UserWithRelations = User & { + emojis: Emoji[]; + instance: Instance | null; + likes: Like[]; + relationships: Relationship[]; + relationshipSubjects: Relationship[]; + pinnedNotes: Status[]; + statuses: { + length: number; + }; +}; - /** - * The display name for the user. - */ - @Column("varchar") - display_name!: string; +/** + * 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: ConfigType) => { + if (!user.avatar) return config.defaults.avatar; + if (config.media.backend === "local") { + return `${config.http.base_url}/media/${user.avatar}`; + } else if (config.media.backend === "s3") { + return `${config.s3.public_url}/${user.avatar}`; + } + return ""; +}; - /** - * The password for the user. - */ - @Column("varchar", { - nullable: true, - }) - password!: string | null; +/** + * 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: ConfigType) => { + if (!user.header) return config.defaults.header; + if (config.media.backend === "local") { + return `${config.http.base_url}/media/${user.header}`; + } else if (config.media.backend === "s3") { + return `${config.s3.public_url}/${user.header}`; + } + return ""; +}; - /** - * The email address for the user. - */ - @Column("varchar", { - unique: true, - nullable: true, - }) - email!: string | null; +export const getFromRequest = async (req: Request): Promise => { + // Check auth token + const token = req.headers.get("Authorization")?.split(" ")[1] || ""; - /** - * The note for the user. - */ - @Column("varchar", { - default: "", - }) - note!: string; + return { user: await retrieveUserFromToken(token), token }; +}; - /** - * Whether the user is an admin or not. - */ - @Column("boolean", { - default: false, - }) - is_admin!: boolean; +export const fetchRemoteUser = async (uri: string) => { + // Check if user not already in database + const foundUser = await client.user.findUnique({ + where: { + uri, + }, + include: userRelations, + }); - @Column("jsonb", { - nullable: true, - }) - endpoints!: { - liked: string; - disliked: string; - featured: string; - followers: string; - following: string; - inbox: string; - outbox: string; - } | null; + if (foundUser) return foundUser; - /** - * The source for the user. - */ - @Column("jsonb") - source!: APISource; + const response = await fetch(uri, { + method: "GET", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + }); - /** - * The avatar for the user (filename, as UUID) - */ - @Column("varchar") - avatar!: string; + const data = (await response.json()) as Partial; - /** - * The header for the user (filename, as UUID) - */ - @Column("varchar") - header!: string; - - /** - * The date the user was created. - */ - @CreateDateColumn() - created_at!: Date; - - /** - * The date the user was last updated. - */ - @UpdateDateColumn() - updated_at!: Date; - - /** - * The public key for the user. - */ - @Column("varchar") - public_key!: string; - - /** - * The private key for the user. - */ - @Column("varchar", { - nullable: true, - }) - private_key!: string | null; - - /** - * The relationships for the user. - */ - @OneToMany(() => Relationship, relationship => relationship.owner) - relationships!: Relationship[]; - - /** - * User's instance, null if local user - */ - @ManyToOne(() => Instance, { - nullable: true, - }) - instance!: Instance | null; - - /** - * The pinned notes for the user. - */ - @ManyToMany(() => Status, status => status.id) - @JoinTable() - pinned_notes!: Status[]; - - /** - * The emojis for the user. - */ - @ManyToMany(() => Emoji, emoji => emoji.id) - @JoinTable() - emojis!: Emoji[]; - - /** - * 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: ConfigType) { - if (config.media.backend === "local") { - return `${config.http.base_url}/media/${this.avatar}`; - } else if (config.media.backend === "s3") { - return `${config.s3.public_url}/${this.avatar}`; - } + if ( + !( + data.id && + data.username && + data.uri && + data.created_at && + data.disliked && + data.featured && + data.liked && + data.followers && + data.following && + data.inbox && + data.outbox && + data.public_key + ) + ) { + throw new Error("Invalid user data"); } - /** - * 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: ConfigType) { - if (config.media.backend === "local") { - return `${config.http.base_url}/media/${this.header}`; - } else if (config.media.backend === "s3") { - return `${config.s3.public_url}/${this.header}`; - } - } + // Parse emojis and add them to database + const userEmojis = + data.extensions?.["org.lysand:custom_emojis"]?.emojis ?? []; - static async getFromRequest(req: Request) { - // Check auth token - const token = req.headers.get("Authorization")?.split(" ")[1] || ""; - - return { user: await User.retrieveFromToken(token), token }; - } - - static async fetchRemoteUser(uri: string) { - // Check if user not already in database - const foundUser = await User.findOne({ - where: { - uri, + const user = await client.user.create({ + data: { + username: data.username, + uri: data.uri, + createdAt: new Date(data.created_at), + endpoints: { + disliked: data.disliked, + featured: data.featured, + liked: data.liked, + followers: data.followers, + following: data.following, + inbox: data.inbox, + outbox: data.outbox, }, - }); - - if (foundUser) return foundUser; - - const response = await fetch(uri, { - method: "GET", - headers: { - "Content-Type": "application/json", - Accept: "application/json", + avatar: (data.avatar && data.avatar[0].content) || "", + header: (data.header && data.header[0].content) || "", + displayName: data.display_name ?? "", + note: data.bio?.[0].content ?? "", + publicKey: data.public_key.public_key, + source: { + language: null, + note: "", + privacy: "public", + sensitive: false, + fields: [], }, - }); + }, + }); - const data = (await response.json()) as Partial; + const emojis = []; - const user = new User(); - - if ( - !( - data.id && - data.username && - data.uri && - data.created_at && - data.disliked && - data.featured && - data.liked && - data.followers && - data.following && - data.inbox && - data.outbox && - data.public_key - ) - ) { - throw new Error("Invalid user data"); - } - - user.id = data.id; - user.username = data.username; - user.uri = data.uri; - user.created_at = new Date(data.created_at); - user.endpoints = { - disliked: data.disliked, - featured: data.featured, - liked: data.liked, - followers: data.followers, - following: data.following, - inbox: data.inbox, - outbox: data.outbox, - }; - - user.avatar = (data.avatar && data.avatar[0].content) || ""; - user.header = (data.header && data.header[0].content) || ""; - user.display_name = data.display_name ?? ""; - // TODO: Add bio content types - user.note = data.bio?.[0].content ?? ""; - - // Parse emojis and add them to database - const emojis = - data.extensions?.["org.lysand:custom_emojis"]?.emojis ?? []; - - for (const emoji of emojis) { - user.emojis.push(await Emoji.addIfNotExists(emoji)); - } - - user.public_key = data.public_key.public_key; - - const uriData = new URL(data.uri); - - user.instance = await Instance.addIfNotExists(uriData.origin); - - await user.save(); - return user; + for (const emoji of userEmojis) { + emojis.push(await addEmojiIfNotExists(emoji)); } - /** - * Fetches the list of followers associated with the actor and updates the user's followers - */ - async fetchFollowers() { - // - } + const uriData = new URL(data.uri); - /** - * Gets a user by actor ID. - * @param id The actor ID to search for. - * @returns The user with the given actor ID. - */ - static async getByActorId(id: string) { - return await User.createQueryBuilder("user") - // Objects is a many-to-many relationship - .leftJoinAndSelect("user.actor", "actor") - .leftJoinAndSelect("user.relationships", "relationships") - .where("actor.data @> :data", { - data: JSON.stringify({ - id, - }), - }) - .getOne(); - } - - /** - * Creates a new LOCAL user. - * @param data The data for the new user. - * @returns The newly created user. - */ - static async createNewLocal(data: { - username: string; - display_name?: string; - password: string; - email: string; - bio?: string; - avatar?: string; - header?: string; - }) { - const config = getConfig(); - const user = new User(); - - user.username = data.username; - user.display_name = data.display_name ?? data.username; - user.password = await Bun.password.hash(data.password); - user.email = data.email; - user.note = data.bio ?? ""; - user.avatar = data.avatar ?? config.defaults.avatar; - user.header = data.header ?? config.defaults.avatar; - user.uri = `${config.http.base_url}/users/${user.id}`; - user.emojis = []; - - user.relationships = []; - user.instance = null; - - user.source = { - language: null, - note: "", - privacy: "public", - sensitive: false, - fields: [], - }; - - user.pinned_notes = []; - - await user.generateKeys(); - await user.save(); - - return user; - } - - static async parseMentions(mentions: string[]) { - return await Promise.all( - mentions.map(async mention => { - const user = await User.findOne({ - where: { - uri: mention, - }, - relations: userRelations, - }); - - if (user) return user; - else return await User.fetchRemoteUser(mention); - }) - ); - } - - /** - * Retrieves a user from a token. - * @param access_token The access token to retrieve the user from. - * @returns The user associated with the given access token. - */ - static async retrieveFromToken(access_token: string) { - if (!access_token) return null; - - const token = await Token.findOne({ - where: { - access_token, + return await client.user.update({ + where: { + id: user.id, + }, + data: { + emojis: { + connect: emojis.map(emoji => ({ + id: emoji.id, + })), }, - relations: userRelations.map(r => `user.${r}`), - }); + instanceId: (await addInstanceIfNotExists(uriData.origin)).id, + }, + include: userRelations, + }); +}; - if (!token) return null; +/** + * Fetches the list of followers associated with the actor and updates the user's followers + */ +export const fetchFollowers = () => { + // +}; - return token.user; - } +/** + * Creates a new LOCAL user. + * @param data The data for the new user. + * @returns The newly created user. + */ +export const createNewLocalUser = async (data: { + username: string; + display_name?: string; + password: string; + email: string; + bio?: string; + avatar?: string; + header?: string; +}) => { + const config = getConfig(); - /** - * Gets the relationship to another user. - * @param other The other user to get the relationship to. - * @returns The relationship to the other user. - */ - async getRelationshipToOtherUser(other: User) { - const relationship = await Relationship.findOne({ - where: { - owner: { - id: this.id, - }, - subject: { - id: other.id, - }, + const keys = await generateUserKeys(); + + const user = await client.user.create({ + data: { + username: data.username, + displayName: data.display_name ?? data.username, + password: await Bun.password.hash(data.password), + email: data.email, + note: data.bio ?? "", + avatar: data.avatar ?? config.defaults.avatar, + header: data.header ?? config.defaults.avatar, + uri: "", + publicKey: keys.public_key, + privateKey: keys.private_key, + source: { + language: null, + note: "", + privacy: "public", + sensitive: false, + fields: [], }, - relations: ["owner", "subject"], - }); + }, + }); - return relationship; - } + return await client.user.update({ + where: { + id: user.id, + }, + data: { + uri: `${config.http.base_url}/users/${user.id}`, + }, + include: userRelations, + }); +}; - /** - * Removes the user. - * @param options The options for removing the user. - * @returns The removed user. - */ - async remove(options?: RemoveOptions | undefined) { - // Clean up tokens - const tokens = await Token.findBy({ +/** + * Parses mentions from a list of URIs + */ +export const parseMentionsUris = async (mentions: string[]) => { + return await client.user.findMany({ + where: { + uri: { + in: mentions, + }, + }, + include: userRelations, + }); +}; + +/** + * Retrieves a user from a token. + * @param access_token The access token to retrieve the user from. + * @returns The user associated with the given access token. + */ +export const retrieveUserFromToken = async (access_token: string) => { + if (!access_token) return null; + + const token = await client.token.findFirst({ + where: { + access_token, + }, + include: { user: { - id: this.id, + include: userRelations, }, - }); + }, + }); - const statuses = await Status.find({ - where: { - account: { - id: this.id, - }, - }, - relations: statusRelations, - }); + if (!token) return null; - // Delete both - await Promise.all(tokens.map(async token => await token.remove())); + return token.user; +}; - await Promise.all(statuses.map(async status => await status.remove())); +/** + * Gets the relationship to another user. + * @param other The other user to get the relationship to. + * @returns The relationship to the other user. + */ +export const getRelationshipToOtherUser = async ( + user: UserWithRelations, + other: User +) => { + return await client.relationship.findFirst({ + where: { + ownerId: user.id, + subjectId: other.id, + }, + }); +}; - // Get relationships - const relationships = await this.getRelationships(); - // Delete them all - await Promise.all( - relationships.map(async relationship => await relationship.remove()) - ); +/** + * Generates keys for the user. + */ +export const generateUserKeys = async () => { + const keys = (await crypto.subtle.generateKey("Ed25519", true, [ + "sign", + "verify", + ])) as CryptoKeyPair; - return await super.remove(options); - } - - /** - * Gets the relationships for the user. - * @returns The relationships for the user. - */ - async getRelationships() { - const relationships = await Relationship.find({ - where: { - owner: { - id: this.id, - }, - }, - relations: ["subject"], - }); - - return relationships; - } - - /** - * Generates keys for the user. - */ - async generateKeys(): Promise { - const keys = (await crypto.subtle.generateKey("Ed25519", true, [ - "sign", - "verify", - ])) as CryptoKeyPair; - - const privateKey = btoa( - String.fromCharCode.apply(null, [ - ...new Uint8Array( - // jesus help me what do these letters mean - await crypto.subtle.exportKey("pkcs8", keys.privateKey) - ), - ]) - ); - const publicKey = btoa( - String.fromCharCode( - ...new Uint8Array( - // why is exporting a key so hard - await crypto.subtle.exportKey("spki", keys.publicKey) - ) + const privateKey = btoa( + String.fromCharCode.apply(null, [ + ...new Uint8Array( + // jesus help me what do these letters mean + await crypto.subtle.exportKey("pkcs8", keys.privateKey) + ), + ]) + ); + const publicKey = btoa( + String.fromCharCode( + ...new Uint8Array( + // why is exporting a key so hard + await crypto.subtle.exportKey("spki", keys.publicKey) ) - ); + ) + ); - // Add header, footer and newlines later on - // These keys are base64 encrypted - this.private_key = privateKey; - this.public_key = publicKey; + // Add header, footer and newlines later on + // These keys are base64 encrypted + return { + private_key: privateKey, + public_key: publicKey, + }; +}; + +// eslint-disable-next-line @typescript-eslint/require-await +export const userToAPI = async ( + user: UserWithRelations, + isOwnAccount = false +): Promise => { + const config = getConfig(); + + return { + id: user.id, + username: user.username, + display_name: user.displayName, + note: user.note, + url: user.uri, + avatar: getAvatarUrl(user, config), + header: getHeaderUrl(user, config), + // TODO: Add locked + locked: false, + created_at: new Date(user.createdAt).toISOString(), + followers_count: user.relationshipSubjects.filter(r => r.following) + .length, + following_count: user.relationships.filter(r => r.following).length, + statuses_count: user.statuses.length, + emojis: await Promise.all(user.emojis.map(emoji => emojiToAPI(emoji))), + // TODO: Add fields + fields: [], + // TODO: Add bot + bot: false, + source: + isOwnAccount && user.source + ? (user.source as any as APISource) + : undefined, + // TODO: Add static avatar and header + avatar_static: "", + header_static: "", + acct: + user.instance === null + ? `${user.username}` + : `${user.username}@${user.instance.base_url}`, + // TODO: Add these fields + limited: false, + moved: null, + noindex: false, + suspended: false, + discoverable: undefined, + mute_expires_at: undefined, + group: false, + role: undefined, + }; +}; + +/** + * Should only return local users + */ +export const userToLysand = (user: UserWithRelations): LysandUser => { + if (user.instanceId !== null) { + throw new Error("Cannot convert remote user to Lysand format"); } - // eslint-disable-next-line @typescript-eslint/require-await - async toAPI(isOwnAccount = false): Promise { - const follower_count = await Relationship.count({ - where: { - subject: { - id: this.id, - }, - following: true, + return { + id: user.id, + type: "User", + uri: user.uri, + bio: [ + { + content: user.note, + content_type: "text/html", }, - relations: ["subject"], - }); - - const following_count = await Relationship.count({ - where: { - owner: { - id: this.id, - }, - following: true, + { + content: htmlToText(user.note), + content_type: "text/plain", }, - relations: ["owner"], - }); - - const statusCount = await Status.count({ - where: { - account: { - id: this.id, - }, + ], + created_at: new Date(user.createdAt).toISOString(), + disliked: `${user.uri}/disliked`, + featured: `${user.uri}/featured`, + liked: `${user.uri}/liked`, + followers: `${user.uri}/followers`, + following: `${user.uri}/following`, + inbox: `${user.uri}/inbox`, + outbox: `${user.uri}/outbox`, + indexable: false, + username: user.username, + avatar: [ + { + content: getAvatarUrl(user, getConfig()) || "", + content_type: `image/${user.avatar.split(".")[1]}`, }, - relations: ["account"], - }); - - const config = getConfig(); - - return { - id: this.id, - username: this.username, - display_name: this.display_name, - note: this.note, - url: `${config.http.base_url}/users/${this.username}`, - avatar: this.getAvatarUrl(config) || config.defaults.avatar, - header: this.getHeaderUrl(config) || config.defaults.header, - locked: false, - created_at: new Date(this.created_at).toISOString(), - followers_count: follower_count, - following_count: following_count, - statuses_count: statusCount, - emojis: await Promise.all(this.emojis.map(emoji => emoji.toAPI())), - fields: [], - bot: false, - source: isOwnAccount ? this.source : undefined, - avatar_static: "", - header_static: "", - acct: - this.instance === null - ? `${this.username}` - : `${this.username}@${this.instance.base_url}`, - limited: false, - moved: null, - noindex: false, - suspended: false, - discoverable: undefined, - mute_expires_at: undefined, - group: false, - role: undefined, - }; - } - - /** - * Should only return local users - */ - toLysand(): LysandUser { - if (this.instance !== null) { - throw new Error("Cannot convert remote user to Lysand format"); - } - - return { - id: this.id, - type: "User", - uri: this.uri, - bio: [ + ], + header: [ + { + content: getHeaderUrl(user, getConfig()) || "", + content_type: `image/${user.header.split(".")[1]}`, + }, + ], + display_name: user.displayName, + fields: (user.source as any as APISource).fields.map(field => ({ + key: [ { - content: this.note, + content: field.name, content_type: "text/html", }, { - content: htmlToText(this.note), + content: htmlToText(field.name), content_type: "text/plain", }, ], - created_at: new Date(this.created_at).toISOString(), - disliked: `${this.uri}/disliked`, - featured: `${this.uri}/featured`, - liked: `${this.uri}/liked`, - followers: `${this.uri}/followers`, - following: `${this.uri}/following`, - inbox: `${this.uri}/inbox`, - outbox: `${this.uri}/outbox`, - indexable: false, - username: this.username, - avatar: [ + value: [ { - content: this.getAvatarUrl(getConfig()) || "", - content_type: `image/${this.avatar.split(".")[1]}`, + content: field.value, + content_type: "text/html", + }, + { + content: htmlToText(field.value), + content_type: "text/plain", }, ], - header: [ - { - content: this.getHeaderUrl(getConfig()) || "", - content_type: `image/${this.header.split(".")[1]}`, - }, - ], - display_name: this.display_name, - fields: this.source.fields.map(field => ({ - key: [ - { - content: field.name, - content_type: "text/html", - }, - { - content: htmlToText(field.name), - content_type: "text/plain", - }, - ], - value: [ - { - content: field.value, - content_type: "text/html", - }, - { - content: htmlToText(field.value), - content_type: "text/plain", - }, - ], - })), - public_key: { - actor: `${getConfig().http.base_url}/users/${this.id}`, - public_key: this.public_key, + })), + public_key: { + actor: `${getConfig().http.base_url}/users/${user.id}`, + public_key: user.publicKey, + }, + extensions: { + "org.lysand:custom_emojis": { + emojis: user.emojis.map(emoji => emojiToLysand(emoji)), }, - extensions: { - "org.lysand:custom_emojis": { - emojis: this.emojis.map(emoji => emoji.toLysand()), - }, - }, - }; - } -} + }, + }; +}; diff --git a/index.ts b/index.ts index 9e0ce494..db3b80d8 100644 --- a/index.ts +++ b/index.ts @@ -6,7 +6,7 @@ import { appendFile } from "fs/promises"; import { matches } from "ip-matching"; import "reflect-metadata"; import { AppDataSource } from "~database/datasource"; -import { User } from "~database/entities/User"; +import { AuthData, UserAction } from "~database/entities/User"; import { APIRouteMeta } from "~types/api"; const router = new Bun.FileSystemRouter({ @@ -60,7 +60,8 @@ Bun.serve({ meta: APIRouteMeta; default: ( req: Request, - matchedRoute: MatchedRoute + matchedRoute: MatchedRoute, + auth: AuthData ) => Response | Promise; } = await import(matchedRoute.filePath); @@ -78,11 +79,11 @@ Bun.serve({ // TODO: Check for ratelimits + const auth = await UserAction.getFromRequest(req); + // Check for authentication if required if (meta.auth.required) { - const { user } = await User.getFromRequest(req); - - if (!user) { + if (!auth.user) { return new Response(undefined, { status: 401, statusText: "Unauthorized", @@ -91,9 +92,7 @@ Bun.serve({ } else if ( (meta.auth.requiredOnMethods ?? []).includes(req.method as any) ) { - const { user } = await User.getFromRequest(req); - - if (!user) { + if (!auth.user) { return new Response(undefined, { status: 401, statusText: "Unauthorized", @@ -101,7 +100,7 @@ Bun.serve({ } } - return file.default(req, matchedRoute); + return file.default(req, matchedRoute, auth); } else { return new Response(undefined, { status: 404, diff --git a/package.json b/package.json index 4aca90e9..e854ea14 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "start": "bun run index.ts" }, "trustedDependencies": [ - "sharp" + "sharp", + "@prisma/client" ], "devDependencies": { "@julr/unocss-preset-forms": "^0.0.5", @@ -61,6 +62,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.429.0", + "@prisma/client": "^5.5.2", "chalk": "^5.3.0", "html-to-text": "^9.0.5", "ip-matching": "^2.1.2", @@ -68,6 +70,7 @@ "jsonld": "^8.3.1", "marked": "^9.1.2", "pg": "^8.11.3", + "prisma": "^5.5.2", "reflect-metadata": "^0.1.13", "typeorm": "^0.3.17" } diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 00000000..a3b2bc0d --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,166 @@ +generator client { + provider = "prisma-client-js" + previewFeatures = ["postgresqlExtensions"] +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") + extensions = [pg_uuidv7] +} + +model Application { + id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid + name String + website String? + vapid_key String? + client_id String + secret String + scopes String + redirect_uris String + statuses Status[] // One to many relation with Status + tokens Token[] // One to many relation with Token +} + +model Emoji { + id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid + shortcode String + url String + visible_in_picker Boolean + instance Instance? @relation(fields: [instanceId], references: [id], onDelete: Cascade) + instanceId String? @db.Uuid + alt String? + content_type String + users User[] // Many to many relation with User + statuses Status[] // Many to many relation with Status +} + +model Instance { + id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid + base_url String + name String + version String + logo Json + emojis Emoji[] // One to many relation with Emoji + statuses Status[] // One to many relation with Status + users User[] // One to many relation with User +} + +model Like { + id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid + liker User @relation("UserLiked", fields: [likerId], references: [id], onDelete: Cascade) + likerId String @db.Uuid + liked Status @relation("LikedToStatus", fields: [likedId], references: [id], onDelete: Cascade) + likedId String @db.Uuid + createdAt DateTime @default(now()) +} + +model LysandObject { + id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid + remote_id String @unique + type String + uri String @unique + created_at DateTime @default(now()) + author LysandObject? @relation("LysandObjectToAuthor", fields: [authorId], references: [id], onDelete: Cascade) + authorId String? @db.Uuid + extra_data Json + extensions Json + children LysandObject[] @relation("LysandObjectToAuthor") +} + +model Relationship { + id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid + owner User @relation("OwnerToRelationship", fields: [ownerId], references: [id], onDelete: Cascade) + ownerId String @db.Uuid + subject User @relation("SubjectToRelationship", fields: [subjectId], references: [id], onDelete: Cascade) + subjectId String @db.Uuid + following Boolean + showingReblogs Boolean + notifying Boolean + followedBy Boolean + blocking Boolean + blockedBy Boolean + muting Boolean + mutingNotifications Boolean + requested Boolean + domainBlocking Boolean + endorsed Boolean + languages String[] + note String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Status { + id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid + uri String @unique + author User @relation("UserStatuses", fields: [authorId], references: [id], onDelete: Cascade) + authorId String @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + reblog Status? @relation("StatusToStatus", fields: [reblogId], references: [id], onDelete: Cascade) + reblogId String? @db.Uuid + isReblog Boolean + content String @default("") + contentType String @default("text/plain") + visibility String + inReplyToPost Status? @relation("StatusToStatusReply", fields: [inReplyToPostId], references: [id], onDelete: SetNull) + inReplyToPostId String? @db.Uuid + quotingPost Status? @relation("StatusToStatusQuote", fields: [quotingPostId], references: [id], onDelete: SetNull) + quotingPostId String? @db.Uuid + instance Instance? @relation(fields: [instanceId], references: [id], onDelete: Cascade) + instanceId String? @db.Uuid + sensitive Boolean + spoilerText String @default("") + application Application? @relation(fields: [applicationId], references: [id], onDelete: SetNull) + applicationId String? @db.Uuid + emojis Emoji[] @relation + mentions User[] + likes Like[] @relation("LikedToStatus") + reblogs Status[] @relation("StatusToStatus") + replies Status[] @relation("StatusToStatusReply") + quotes Status[] @relation("StatusToStatusQuote") + pinnedBy User[] @relation("UserPinnedNotes") +} + +model Token { + id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid + token_type String + scope String + access_token String + code String + created_at DateTime @default(now()) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String? @db.Uuid + application Application? @relation(fields: [applicationId], references: [id], onDelete: Cascade) + applicationId String? @db.Uuid +} + +model User { + id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid + uri String @unique + username String @unique + displayName String + password String? // Nullable + email String? @unique // Nullable + note String @default("") + isAdmin Boolean @default(false) + endpoints Json? // Nullable + source Json + avatar String + header String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + publicKey String + privateKey String? // Nullable + relationships Relationship[] @relation("OwnerToRelationship") // One to many relation with Relationship + relationshipSubjects Relationship[] @relation("SubjectToRelationship") // One to many relation with Relationship + instance Instance? @relation(fields: [instanceId], references: [id], onDelete: Cascade) // Many to one relation with Instance + instanceId String? @db.Uuid + pinnedNotes Status[] @relation("UserPinnedNotes") // Many to many relation with Status + emojis Emoji[] // Many to many relation with Emoji + statuses Status[] @relation("UserStatuses") // One to many relation with Status + tokens Token[] // One to many relation with Token + likes Like[] @relation("UserLiked") // One to many relation with Like + statusesMentioned Status[] // Many to many relation with Status +} diff --git a/server/api/.well-known/webfinger/index.ts b/server/api/.well-known/webfinger/index.ts index d9ecb5c6..15e28d1b 100644 --- a/server/api/.well-known/webfinger/index.ts +++ b/server/api/.well-known/webfinger/index.ts @@ -1,8 +1,8 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; -import { User, userRelations } from "~database/entities/User"; import { getConfig, getHost } from "@config"; import { applyConfig } from "@api"; +import { client } from "~database/datasource"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -34,9 +34,8 @@ export default async ( return errorResponse("User is a remote user", 404); } - const user = await User.findOne({ + const user = await client.user.findUnique({ where: { username: requestedUser.split("@")[0] }, - relations: userRelations }); if (!user) { diff --git a/server/api/api/v1/accounts/[id]/block.ts b/server/api/api/v1/accounts/[id]/block.ts index fe4cba5f..583ce511 100644 --- a/server/api/api/v1/accounts/[id]/block.ts +++ b/server/api/api/v1/accounts/[id]/block.ts @@ -1,7 +1,7 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; import { Relationship } from "~database/entities/Relationship"; -import { User, userRelations } from "~database/entities/User"; +import { UserAction, userRelations } from "~database/entities/User"; import { applyConfig } from "@api"; export const meta = applyConfig({ @@ -25,11 +25,11 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const { user: self } = await User.getFromRequest(req); + const { user: self } = await UserAction.getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); - const user = await User.findOne({ + const user = await UserAction.findOne({ where: { id, }, diff --git a/server/api/api/v1/accounts/[id]/follow.ts b/server/api/api/v1/accounts/[id]/follow.ts index 3ac51e43..bc5f1e70 100644 --- a/server/api/api/v1/accounts/[id]/follow.ts +++ b/server/api/api/v1/accounts/[id]/follow.ts @@ -2,7 +2,7 @@ import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; import { Relationship } from "~database/entities/Relationship"; -import { User, userRelations } from "~database/entities/User"; +import { UserAction, userRelations } from "~database/entities/User"; import { applyConfig } from "@api"; export const meta = applyConfig({ @@ -26,7 +26,7 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const { user: self } = await User.getFromRequest(req); + const { user: self } = await UserAction.getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); @@ -36,7 +36,7 @@ export default async ( languages?: string[]; }>(req); - const user = await User.findOne({ + const user = await UserAction.findOne({ where: { id, }, diff --git a/server/api/api/v1/accounts/[id]/index.ts b/server/api/api/v1/accounts/[id]/index.ts index 6dc4e479..f9d52e20 100644 --- a/server/api/api/v1/accounts/[id]/index.ts +++ b/server/api/api/v1/accounts/[id]/index.ts @@ -1,6 +1,6 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; -import { User, userRelations } from "~database/entities/User"; +import { UserAction, userRelations } from "~database/entities/User"; import { applyConfig } from "@api"; export const meta = applyConfig({ @@ -24,11 +24,11 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const { user } = await User.getFromRequest(req); + const { user } = await UserAction.getFromRequest(req); - let foundUser: User | null; + let foundUser: UserAction | null; try { - foundUser = await User.findOne({ + foundUser = await UserAction.findOne({ where: { id, }, diff --git a/server/api/api/v1/accounts/[id]/mute.ts b/server/api/api/v1/accounts/[id]/mute.ts index 39a99dbe..eabacd52 100644 --- a/server/api/api/v1/accounts/[id]/mute.ts +++ b/server/api/api/v1/accounts/[id]/mute.ts @@ -2,7 +2,7 @@ import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; import { Relationship } from "~database/entities/Relationship"; -import { User, userRelations } from "~database/entities/User"; +import { UserAction, userRelations } from "~database/entities/User"; import { applyConfig } from "@api"; export const meta = applyConfig({ @@ -26,7 +26,7 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const { user: self } = await User.getFromRequest(req); + const { user: self } = await UserAction.getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); @@ -36,7 +36,7 @@ export default async ( duration: number; }>(req); - const user = await User.findOne({ + const user = await UserAction.findOne({ where: { id, }, diff --git a/server/api/api/v1/accounts/[id]/note.ts b/server/api/api/v1/accounts/[id]/note.ts index 76b1fdea..54f8378d 100644 --- a/server/api/api/v1/accounts/[id]/note.ts +++ b/server/api/api/v1/accounts/[id]/note.ts @@ -2,7 +2,7 @@ import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; import { Relationship } from "~database/entities/Relationship"; -import { User, userRelations } from "~database/entities/User"; +import { UserAction, userRelations } from "~database/entities/User"; import { applyConfig } from "@api"; export const meta = applyConfig({ @@ -26,7 +26,7 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const { user: self } = await User.getFromRequest(req); + const { user: self } = await UserAction.getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); @@ -34,7 +34,7 @@ export default async ( comment: string; }>(req); - const user = await User.findOne({ + const user = await UserAction.findOne({ where: { id, }, diff --git a/server/api/api/v1/accounts/[id]/pin.ts b/server/api/api/v1/accounts/[id]/pin.ts index d894129d..82ee42cb 100644 --- a/server/api/api/v1/accounts/[id]/pin.ts +++ b/server/api/api/v1/accounts/[id]/pin.ts @@ -1,7 +1,7 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; import { Relationship } from "~database/entities/Relationship"; -import { User, userRelations } from "~database/entities/User"; +import { UserAction, userRelations } from "~database/entities/User"; import { applyConfig } from "@api"; export const meta = applyConfig({ @@ -25,11 +25,11 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const { user: self } = await User.getFromRequest(req); + const { user: self } = await UserAction.getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); - const user = await User.findOne({ + const user = await UserAction.findOne({ where: { id, }, 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 d2318dca..9bf94a9c 100644 --- a/server/api/api/v1/accounts/[id]/remove_from_followers.ts +++ b/server/api/api/v1/accounts/[id]/remove_from_followers.ts @@ -1,7 +1,7 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; import { Relationship } from "~database/entities/Relationship"; -import { User, userRelations } from "~database/entities/User"; +import { UserAction, userRelations } from "~database/entities/User"; import { applyConfig } from "@api"; export const meta = applyConfig({ @@ -25,11 +25,11 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const { user: self } = await User.getFromRequest(req); + const { user: self } = await UserAction.getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); - const user = await User.findOne({ + const user = await UserAction.findOne({ where: { id, }, diff --git a/server/api/api/v1/accounts/[id]/statuses.ts b/server/api/api/v1/accounts/[id]/statuses.ts index 120cfc43..8b663b5a 100644 --- a/server/api/api/v1/accounts/[id]/statuses.ts +++ b/server/api/api/v1/accounts/[id]/statuses.ts @@ -2,7 +2,7 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; import { Status, statusAndUserRelations } from "~database/entities/Status"; -import { User, userRelations } from "~database/entities/User"; +import { UserAction, userRelations } from "~database/entities/User"; import { applyConfig } from "@api"; import { FindManyOptions } from "typeorm"; @@ -47,7 +47,7 @@ export default async ( tagged?: string; } = matchedRoute.query; - const user = await User.findOne({ + const user = await UserAction.findOne({ where: { id, }, diff --git a/server/api/api/v1/accounts/[id]/unblock.ts b/server/api/api/v1/accounts/[id]/unblock.ts index b03d2121..d28f16a4 100644 --- a/server/api/api/v1/accounts/[id]/unblock.ts +++ b/server/api/api/v1/accounts/[id]/unblock.ts @@ -1,7 +1,7 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; import { Relationship } from "~database/entities/Relationship"; -import { User, userRelations } from "~database/entities/User"; +import { UserAction, userRelations } from "~database/entities/User"; import { applyConfig } from "@api"; export const meta = applyConfig({ @@ -25,11 +25,11 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const { user: self } = await User.getFromRequest(req); + const { user: self } = await UserAction.getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); - const user = await User.findOne({ + const user = await UserAction.findOne({ where: { id, }, diff --git a/server/api/api/v1/accounts/[id]/unfollow.ts b/server/api/api/v1/accounts/[id]/unfollow.ts index 60ac83cc..0d189824 100644 --- a/server/api/api/v1/accounts/[id]/unfollow.ts +++ b/server/api/api/v1/accounts/[id]/unfollow.ts @@ -1,7 +1,7 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; import { Relationship } from "~database/entities/Relationship"; -import { User, userRelations } from "~database/entities/User"; +import { UserAction, userRelations } from "~database/entities/User"; import { applyConfig } from "@api"; export const meta = applyConfig({ @@ -25,11 +25,11 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const { user: self } = await User.getFromRequest(req); + const { user: self } = await UserAction.getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); - const user = await User.findOne({ + const user = await UserAction.findOne({ where: { id, }, diff --git a/server/api/api/v1/accounts/[id]/unmute.ts b/server/api/api/v1/accounts/[id]/unmute.ts index 2a84bc7a..7a79bae0 100644 --- a/server/api/api/v1/accounts/[id]/unmute.ts +++ b/server/api/api/v1/accounts/[id]/unmute.ts @@ -1,7 +1,7 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; import { Relationship } from "~database/entities/Relationship"; -import { User, userRelations } from "~database/entities/User"; +import { UserAction, userRelations } from "~database/entities/User"; import { applyConfig } from "@api"; export const meta = applyConfig({ @@ -25,11 +25,11 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const { user: self } = await User.getFromRequest(req); + const { user: self } = await UserAction.getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); - const user = await User.findOne({ + const user = await UserAction.findOne({ where: { id, }, diff --git a/server/api/api/v1/accounts/[id]/unpin.ts b/server/api/api/v1/accounts/[id]/unpin.ts index f51d0e0f..1462f145 100644 --- a/server/api/api/v1/accounts/[id]/unpin.ts +++ b/server/api/api/v1/accounts/[id]/unpin.ts @@ -1,7 +1,7 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; import { Relationship } from "~database/entities/Relationship"; -import { User, userRelations } from "~database/entities/User"; +import { UserAction, userRelations } from "~database/entities/User"; import { applyConfig } from "@api"; export const meta = applyConfig({ @@ -25,11 +25,11 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const { user: self } = await User.getFromRequest(req); + const { user: self } = await UserAction.getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); - const user = await User.findOne({ + const user = await UserAction.findOne({ where: { id, }, diff --git a/server/api/api/v1/accounts/familiar_followers/index.ts b/server/api/api/v1/accounts/familiar_followers/index.ts index 2961fc47..c012d85f 100644 --- a/server/api/api/v1/accounts/familiar_followers/index.ts +++ b/server/api/api/v1/accounts/familiar_followers/index.ts @@ -1,6 +1,6 @@ import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; -import { User } from "~database/entities/User"; +import { UserAction } from "~database/entities/User"; import { APIAccount } from "~types/entities/account"; import { applyConfig } from "@api"; @@ -20,7 +20,7 @@ export const meta = applyConfig({ * Find familiar followers (followers of a user that you also follow) */ export default async (req: Request): Promise => { - const { user: self } = await User.getFromRequest(req); + const { user: self } = await UserAction.getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); @@ -39,7 +39,7 @@ export default async (req: Request): Promise => { // Find followers of user that you also follow // Get user - const user = await User.findOne({ + const user = await UserAction.findOne({ where: { id }, relations: { relationships: { diff --git a/server/api/api/v1/accounts/index.ts b/server/api/api/v1/accounts/index.ts index ce81d0ab..69d997cf 100644 --- a/server/api/api/v1/accounts/index.ts +++ b/server/api/api/v1/accounts/index.ts @@ -2,7 +2,7 @@ import { getConfig } from "@config"; import { parseRequest } from "@request"; import { jsonResponse } from "@response"; import { tempmailDomains } from "@tempmail"; -import { User } from "~database/entities/User"; +import { UserAction } from "~database/entities/User"; import { applyConfig } from "@api"; export const meta = applyConfig({ @@ -115,7 +115,7 @@ export default async (req: Request): Promise => { }); // Check if username is taken - if (await User.findOne({ where: { username: body.username } })) + if (await UserAction.findOne({ where: { username: body.username } })) errors.details.username.push({ error: "ERR_TAKEN", description: `is already taken`, @@ -170,7 +170,7 @@ export default async (req: Request): Promise => { // TODO: Check if locale is valid - await User.createNewLocal({ + await UserAction.createNewLocal({ username: body.username ?? "", password: body.password ?? "", email: body.email ?? "", diff --git a/server/api/api/v1/accounts/relationships/index.ts b/server/api/api/v1/accounts/relationships/index.ts index 6e55e815..b9dde030 100644 --- a/server/api/api/v1/accounts/relationships/index.ts +++ b/server/api/api/v1/accounts/relationships/index.ts @@ -1,7 +1,7 @@ import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; import { Relationship } from "~database/entities/Relationship"; -import { User } from "~database/entities/User"; +import { UserAction } from "~database/entities/User"; import { applyConfig } from "@api"; export const meta = applyConfig({ @@ -20,7 +20,7 @@ export const meta = applyConfig({ * Find relationships */ export default async (req: Request): Promise => { - const { user: self } = await User.getFromRequest(req); + const { user: self } = await UserAction.getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); @@ -38,7 +38,7 @@ export default async (req: Request): Promise => { const relationships = ( await Promise.all( ids.map(async id => { - const user = await User.findOneBy({ id }); + const user = await UserAction.findOneBy({ id }); if (!user) return null; let relationship = await self.getRelationshipToOtherUser(user); diff --git a/server/api/api/v1/accounts/update_credentials/index.ts b/server/api/api/v1/accounts/update_credentials/index.ts index 105c6b07..8ac804c6 100644 --- a/server/api/api/v1/accounts/update_credentials/index.ts +++ b/server/api/api/v1/accounts/update_credentials/index.ts @@ -1,12 +1,12 @@ import { getConfig } from "@config"; import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; -import { User } from "~database/entities/User"; +import { UserAction } from "~database/entities/User"; import { applyConfig } from "@api"; import { sanitize } from "isomorphic-dompurify"; import { sanitizeHtml } from "@sanitization"; import { uploadFile } from "~classes/media"; -import { Emoji } from "~database/entities/Emoji"; +import { EmojiAction } from "~database/entities/Emoji"; export const meta = applyConfig({ allowedMethods: ["PATCH"], @@ -24,7 +24,7 @@ export const meta = applyConfig({ * Patches a user */ export default async (req: Request): Promise => { - const { user } = await User.getFromRequest(req); + const { user } = await UserAction.getFromRequest(req); if (!user) return errorResponse("Unauthorized", 401); @@ -202,8 +202,8 @@ export default async (req: Request): Promise => { // Parse emojis - const displaynameEmojis = await Emoji.parseEmojis(sanitizedDisplayName); - const noteEmojis = await Emoji.parseEmojis(sanitizedNote); + const displaynameEmojis = await EmojiAction.parseEmojis(sanitizedDisplayName); + const noteEmojis = await EmojiAction.parseEmojis(sanitizedNote); user.emojis = [...displaynameEmojis, ...noteEmojis]; diff --git a/server/api/api/v1/accounts/verify_credentials/index.ts b/server/api/api/v1/accounts/verify_credentials/index.ts index 1c82b064..102d49fd 100644 --- a/server/api/api/v1/accounts/verify_credentials/index.ts +++ b/server/api/api/v1/accounts/verify_credentials/index.ts @@ -1,5 +1,5 @@ import { errorResponse, jsonResponse } from "@response"; -import { User } from "~database/entities/User"; +import { UserAction } from "~database/entities/User"; import { applyConfig } from "@api"; export const meta = applyConfig({ @@ -17,7 +17,7 @@ export const meta = applyConfig({ export default async (req: Request): Promise => { // TODO: Add checks for disabled or not email verified accounts - const { user } = await User.getFromRequest(req); + const { user } = await UserAction.getFromRequest(req); if (!user) return errorResponse("Unauthorized", 401); diff --git a/server/api/api/v1/apps/index.ts b/server/api/api/v1/apps/index.ts index 62ee7206..c1b06dec 100644 --- a/server/api/api/v1/apps/index.ts +++ b/server/api/api/v1/apps/index.ts @@ -2,7 +2,7 @@ import { applyConfig } from "@api"; import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; import { randomBytes } from "crypto"; -import { Application } from "~database/entities/Application"; +import { ApplicationAction } from "~database/entities/Application"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -27,7 +27,7 @@ export default async (req: Request): Promise => { website: string; }>(req); - const application = new Application(); + const application = new ApplicationAction(); application.name = client_name || ""; diff --git a/server/api/api/v1/apps/verify_credentials/index.ts b/server/api/api/v1/apps/verify_credentials/index.ts index 14cf5437..bd606cfb 100644 --- a/server/api/api/v1/apps/verify_credentials/index.ts +++ b/server/api/api/v1/apps/verify_credentials/index.ts @@ -1,7 +1,7 @@ import { applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import { Application } from "~database/entities/Application"; -import { User } from "~database/entities/User"; +import { ApplicationAction } from "~database/entities/Application"; +import { UserAction } from "~database/entities/User"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -19,8 +19,8 @@ export const meta = applyConfig({ * Returns OAuth2 credentials */ export default async (req: Request): Promise => { - const { user, token } = await User.getFromRequest(req); - const application = await Application.getFromToken(token); + const { user, token } = await UserAction.getFromRequest(req); + const application = await ApplicationAction.getFromToken(token); if (!user) return errorResponse("Unauthorized", 401); if (!application) return errorResponse("Unauthorized", 401); diff --git a/server/api/api/v1/custom_emojis/index.ts b/server/api/api/v1/custom_emojis/index.ts index 2de2a801..b19e24dc 100644 --- a/server/api/api/v1/custom_emojis/index.ts +++ b/server/api/api/v1/custom_emojis/index.ts @@ -1,7 +1,7 @@ import { applyConfig } from "@api"; import { jsonResponse } from "@response"; import { IsNull } from "typeorm"; -import { Emoji } from "~database/entities/Emoji"; +import { EmojiAction } from "~database/entities/Emoji"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -20,7 +20,7 @@ export const meta = applyConfig({ */ // eslint-disable-next-line @typescript-eslint/require-await export default async (): Promise => { - const emojis = await Emoji.findBy({ + const emojis = await EmojiAction.findBy({ instance: IsNull(), }); diff --git a/server/api/api/v1/instance/index.ts b/server/api/api/v1/instance/index.ts index a1c20de8..75237801 100644 --- a/server/api/api/v1/instance/index.ts +++ b/server/api/api/v1/instance/index.ts @@ -2,7 +2,7 @@ import { applyConfig } from "@api"; import { getConfig } from "@config"; import { jsonResponse } from "@response"; import { Status } from "~database/entities/Status"; -import { User } from "~database/entities/User"; +import { UserAction } from "~database/entities/User"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -24,7 +24,7 @@ export default async (): Promise => { const config = getConfig(); const statusCount = await Status.count(); - const userCount = await User.count(); + const userCount = await UserAction.count(); // TODO: fill in more values return jsonResponse({ diff --git a/server/api/api/v1/statuses/[id]/context.ts b/server/api/api/v1/statuses/[id]/context.ts index 8cfd8266..f62d30b0 100644 --- a/server/api/api/v1/statuses/[id]/context.ts +++ b/server/api/api/v1/statuses/[id]/context.ts @@ -2,7 +2,7 @@ import { applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; import { Status, statusAndUserRelations } from "~database/entities/Status"; -import { User } from "~database/entities/User"; +import { UserAction } from "~database/entities/User"; import { APIRouteMeta } from "~types/api"; export const meta: APIRouteMeta = applyConfig({ @@ -28,7 +28,7 @@ export default async ( // User token + read:statuses for up to 4,096 ancestors, 4,096 descendants, unlimited depth, and private statuses. const id = matchedRoute.params.id; - const { user } = await User.getFromRequest(req); + const { user } = await UserAction.getFromRequest(req); let foundStatus: Status | null; try { diff --git a/server/api/api/v1/statuses/[id]/favourite.ts b/server/api/api/v1/statuses/[id]/favourite.ts index c4d16847..348a57c1 100644 --- a/server/api/api/v1/statuses/[id]/favourite.ts +++ b/server/api/api/v1/statuses/[id]/favourite.ts @@ -4,7 +4,7 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; import { Like } from "~database/entities/Like"; import { Status, statusAndUserRelations } from "~database/entities/Status"; -import { User, userRelations } from "~database/entities/User"; +import { UserAction, userRelations } from "~database/entities/User"; import { APIRouteMeta } from "~types/api"; export const meta: APIRouteMeta = applyConfig({ @@ -28,7 +28,7 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const { user } = await User.getFromRequest(req); + const { user } = await UserAction.getFromRequest(req); if (!user) return errorResponse("Unauthorized", 401); diff --git a/server/api/api/v1/statuses/[id]/favourited_by.ts b/server/api/api/v1/statuses/[id]/favourited_by.ts index b6dd8187..c64b5de9 100644 --- a/server/api/api/v1/statuses/[id]/favourited_by.ts +++ b/server/api/api/v1/statuses/[id]/favourited_by.ts @@ -6,7 +6,7 @@ import { MatchedRoute } from "bun"; import { FindManyOptions } from "typeorm"; import { Like } from "~database/entities/Like"; import { Status, statusAndUserRelations } from "~database/entities/Status"; -import { User, userRelations } from "~database/entities/User"; +import { UserAction, userRelations } from "~database/entities/User"; import { APIRouteMeta } from "~types/api"; export const meta: APIRouteMeta = applyConfig({ @@ -30,7 +30,7 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const { user } = await User.getFromRequest(req); + const { user } = await UserAction.getFromRequest(req); let foundStatus: Status | null; try { diff --git a/server/api/api/v1/statuses/[id]/index.ts b/server/api/api/v1/statuses/[id]/index.ts index c862da7c..ae0058e4 100644 --- a/server/api/api/v1/statuses/[id]/index.ts +++ b/server/api/api/v1/statuses/[id]/index.ts @@ -2,7 +2,7 @@ import { applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; import { Status, statusAndUserRelations } from "~database/entities/Status"; -import { User } from "~database/entities/User"; +import { UserAction } from "~database/entities/User"; import { APIRouteMeta } from "~types/api"; export const meta: APIRouteMeta = applyConfig({ @@ -27,7 +27,7 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const { user } = await User.getFromRequest(req); + const { user } = await UserAction.getFromRequest(req); let foundStatus: Status | null; try { diff --git a/server/api/api/v1/statuses/[id]/reblogged_by.ts b/server/api/api/v1/statuses/[id]/reblogged_by.ts index d3593ba6..cf30e4ff 100644 --- a/server/api/api/v1/statuses/[id]/reblogged_by.ts +++ b/server/api/api/v1/statuses/[id]/reblogged_by.ts @@ -5,7 +5,7 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; import { FindManyOptions } from "typeorm"; import { Status, statusAndUserRelations } from "~database/entities/Status"; -import { User } from "~database/entities/User"; +import { UserAction } from "~database/entities/User"; import { APIRouteMeta } from "~types/api"; export const meta: APIRouteMeta = applyConfig({ @@ -29,7 +29,7 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const { user } = await User.getFromRequest(req); + const { user } = await UserAction.getFromRequest(req); let foundStatus: Status | null; try { diff --git a/server/api/api/v1/statuses/[id]/unfavourite.ts b/server/api/api/v1/statuses/[id]/unfavourite.ts index 52fdd85d..7d349d94 100644 --- a/server/api/api/v1/statuses/[id]/unfavourite.ts +++ b/server/api/api/v1/statuses/[id]/unfavourite.ts @@ -4,7 +4,7 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; import { Like } from "~database/entities/Like"; import { Status, statusAndUserRelations } from "~database/entities/Status"; -import { User } from "~database/entities/User"; +import { UserAction } from "~database/entities/User"; import { APIRouteMeta } from "~types/api"; export const meta: APIRouteMeta = applyConfig({ @@ -28,7 +28,7 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const { user } = await User.getFromRequest(req); + const { user } = await UserAction.getFromRequest(req); if (!user) return errorResponse("Unauthorized", 401); diff --git a/server/api/api/v1/statuses/index.ts b/server/api/api/v1/statuses/index.ts index d84623b5..0c9865d6 100644 --- a/server/api/api/v1/statuses/index.ts +++ b/server/api/api/v1/statuses/index.ts @@ -6,10 +6,11 @@ import { getConfig } from "@config"; import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; import { sanitizeHtml } from "@sanitization"; +import { MatchedRoute } from "bun"; import { parse } from "marked"; -import { Application } from "~database/entities/Application"; +import { ApplicationAction } from "~database/entities/Application"; import { Status, statusRelations } from "~database/entities/Status"; -import { User } from "~database/entities/User"; +import { AuthData, UserAction } from "~database/entities/User"; import { APIRouteMeta } from "~types/api"; export const meta: APIRouteMeta = applyConfig({ @@ -27,9 +28,13 @@ export const meta: APIRouteMeta = applyConfig({ /** * Post new status */ -export default async (req: Request): Promise => { - const { user, token } = await User.getFromRequest(req); - const application = await Application.getFromToken(token); +export default async ( + req: Request, + matchedRoute: MatchedRoute, + authData: AuthData +): Promise => { + const { user, token } = authData; + const application = await ApplicationAction.getFromToken(token); if (!user) return errorResponse("Unauthorized", 401); @@ -122,7 +127,7 @@ export default async (req: Request): Promise => { // Get reply account and status if exists let replyStatus: Status | null = null; - let replyUser: User | null = null; + let replyUser: UserAction | null = null; if (in_reply_to_id) { replyStatus = await Status.findOne({ diff --git a/server/api/api/v1/timelines/home.ts b/server/api/api/v1/timelines/home.ts index dc389e95..6a571189 100644 --- a/server/api/api/v1/timelines/home.ts +++ b/server/api/api/v1/timelines/home.ts @@ -2,9 +2,10 @@ import { applyConfig } from "@api"; import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; +import { MatchedRoute } from "bun"; import { FindManyOptions } from "typeorm"; import { Status, statusAndUserRelations } from "~database/entities/Status"; -import { User } from "~database/entities/User"; +import { AuthData } from "~database/entities/User"; import { APIRouteMeta } from "~types/api"; export const meta: APIRouteMeta = applyConfig({ @@ -22,7 +23,11 @@ export const meta: APIRouteMeta = applyConfig({ /** * Fetch home timeline statuses */ -export default async (req: Request): Promise => { +export default async ( + req: Request, + matchedRoute: MatchedRoute, + authData: AuthData +): Promise => { const { limit = 20, max_id, @@ -35,7 +40,7 @@ export default async (req: Request): Promise => { limit?: number; }>(req); - const { user } = await User.getFromRequest(req); + const { user } = authData; if (limit < 1 || limit > 40) { return errorResponse("Limit must be between 1 and 40", 400); diff --git a/server/api/api/v1/timelines/public.ts b/server/api/api/v1/timelines/public.ts index fb5a4e11..ad69380a 100644 --- a/server/api/api/v1/timelines/public.ts +++ b/server/api/api/v1/timelines/public.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { applyConfig } from "@api"; import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; @@ -18,9 +17,29 @@ export const meta: APIRouteMeta = applyConfig({ }, }); -/** - * Fetch public timeline statuses - */ +const updateQuery = async ( + id: string | undefined, + operator: string, + query: FindManyOptions +) => { + if (!id) return query; + const post = await Status.findOneBy({ id }); + if (post) { + query = { + ...query, + where: { + ...query.where, + created_at: { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + ...(query.where as any)?.created_at, + [operator]: post.created_at, + }, + }, + }; + } + return query; +}; + export default async (req: Request): Promise => { const { local, @@ -59,53 +78,9 @@ export default async (req: Request): Promise => { relations: statusAndUserRelations, }; - if (max_id) { - const maxPost = await Status.findOneBy({ id: max_id }); - if (maxPost) { - query = { - ...query, - where: { - ...query.where, - created_at: { - ...(query.where as any)?.created_at, - $lt: maxPost.created_at, - }, - }, - }; - } - } - - if (min_id) { - const minPost = await Status.findOneBy({ id: min_id }); - if (minPost) { - query = { - ...query, - where: { - ...query.where, - created_at: { - ...(query.where as any)?.created_at, - $gt: minPost.created_at, - }, - }, - }; - } - } - - if (since_id) { - const sincePost = await Status.findOneBy({ id: since_id }); - if (sincePost) { - query = { - ...query, - where: { - ...query.where, - created_at: { - ...(query.where as any)?.created_at, - $gte: sincePost.created_at, - }, - }, - }; - } - } + query = await updateQuery(max_id, "$lt", query); + query = await updateQuery(min_id, "$gt", query); + query = await updateQuery(since_id, "$gte", query); if (only_media) { // TODO: add diff --git a/server/api/auth/login/index.ts b/server/api/auth/login/index.ts index 2f631e66..3fe279e7 100644 --- a/server/api/auth/login/index.ts +++ b/server/api/auth/login/index.ts @@ -2,9 +2,9 @@ import { applyConfig } from "@api"; import { errorResponse } from "@response"; import { MatchedRoute } from "bun"; import { randomBytes } from "crypto"; -import { Application } from "~database/entities/Application"; +import { ApplicationAction } from "~database/entities/Application"; import { Token } from "~database/entities/Token"; -import { User, userRelations } from "~database/entities/User"; +import { UserAction, userRelations } from "~database/entities/User"; import { APIRouteMeta } from "~types/api"; export const meta: APIRouteMeta = applyConfig({ @@ -45,7 +45,7 @@ export default async ( return errorResponse("Missing username or password", 400); // Get user - const user = await User.findOne({ + const user = await UserAction.findOne({ where: { email, }, @@ -56,7 +56,7 @@ export default async ( return errorResponse("Invalid username or password", 401); // Get application - const application = await Application.findOneBy({ + const application = await ApplicationAction.findOneBy({ client_id, }); diff --git a/server/api/users/[uuid]/inbox/index.ts b/server/api/users/[uuid]/inbox/index.ts index 9701b399..1a143b7c 100644 --- a/server/api/users/[uuid]/inbox/index.ts +++ b/server/api/users/[uuid]/inbox/index.ts @@ -5,10 +5,10 @@ import { getConfig } from "@config"; import { getBestContentType } from "@content_types"; import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; -import { Emoji } from "~database/entities/Emoji"; +import { EmojiAction } from "~database/entities/Emoji"; import { LysandObject } from "~database/entities/Object"; import { Status } from "~database/entities/Status"; -import { User, userRelations } from "~database/entities/User"; +import { UserAction, userRelations } from "~database/entities/User"; import { ContentFormat, LysandAction, @@ -61,7 +61,7 @@ export default async ( // Process request body const body = (await req.json()) as LysandPublication | LysandAction; - const author = await User.findOne({ + const author = await UserAction.findOne({ where: { uri: body.author, }, @@ -145,7 +145,7 @@ export default async ( const content = getBestContentType(body.contents); - const emojis = await Emoji.parseEmojis(content?.content || ""); + const emojis = await EmojiAction.parseEmojis(content?.content || ""); const newStatus = await Status.createNew({ account: author, @@ -158,7 +158,7 @@ export default async ( sensitive: body.is_sensitive, uri: body.uri, emojis: emojis, - mentions: await User.parseMentions(body.mentions), + mentions: await UserAction.parseMentions(body.mentions), }); // If there is a reply, fetch all the reply parents and add them to the database @@ -187,7 +187,7 @@ export default async ( const content = getBestContentType(patch.contents); - const emojis = await Emoji.parseEmojis(content?.content || ""); + const emojis = await EmojiAction.parseEmojis(content?.content || ""); const status = await Status.findOneBy({ id: patch.patched_id, diff --git a/server/api/users/[uuid]/index.ts b/server/api/users/[uuid]/index.ts index 8c6bc1c9..f80eca54 100644 --- a/server/api/users/[uuid]/index.ts +++ b/server/api/users/[uuid]/index.ts @@ -4,7 +4,7 @@ import { applyConfig } from "@api"; import { getConfig } from "@config"; import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; -import { User, userRelations } from "~database/entities/User"; +import { UserAction, userRelations } from "~database/entities/User"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -29,7 +29,7 @@ export default async ( const config = getConfig(); - const user = await User.findOne({ + const user = await UserAction.findOne({ where: { id: uuid, }, diff --git a/tests/api.test.ts b/tests/api.test.ts index 738bf971..a7f939f8 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -3,25 +3,25 @@ import { getConfig } from "@config"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { AppDataSource } from "~database/datasource"; -import { Application } from "~database/entities/Application"; -import { Emoji } from "~database/entities/Emoji"; +import { ApplicationAction } from "~database/entities/Application"; +import { EmojiAction } from "~database/entities/Emoji"; import { Token, TokenType } from "~database/entities/Token"; -import { User } from "~database/entities/User"; +import { UserAction } from "~database/entities/User"; import { APIEmoji } from "~types/entities/emoji"; import { APIInstance } from "~types/entities/instance"; const config = getConfig(); let token: Token; -let user: User; -let user2: User; +let user: UserAction; +let user2: UserAction; describe("API Tests", () => { beforeAll(async () => { if (!AppDataSource.isInitialized) await AppDataSource.initialize(); // Initialize test user - user = await User.createNewLocal({ + user = await UserAction.createNewLocal({ email: "test@test.com", username: "test", password: "test", @@ -29,14 +29,14 @@ describe("API Tests", () => { }); // Initialize second test user - user2 = await User.createNewLocal({ + user2 = await UserAction.createNewLocal({ email: "test2@test.com", username: "test2", password: "test2", display_name: "", }); - const app = new Application(); + const app = new ApplicationAction(); app.name = "Test Application"; app.website = "https://example.com"; @@ -106,7 +106,7 @@ describe("API Tests", () => { describe("GET /api/v1/custom_emojis", () => { beforeAll(async () => { - const emoji = new Emoji(); + const emoji = new EmojiAction(); emoji.instance = null; emoji.url = "https://example.com/test.png"; @@ -139,7 +139,7 @@ describe("API Tests", () => { expect(emojis[0].url).toBe("https://example.com/test.png"); }); afterAll(async () => { - await Emoji.delete({ shortcode: "test" }); + await EmojiAction.delete({ shortcode: "test" }); }); }); }); diff --git a/tests/api/accounts.test.ts b/tests/api/accounts.test.ts index 83d86029..44af9feb 100644 --- a/tests/api/accounts.test.ts +++ b/tests/api/accounts.test.ts @@ -3,9 +3,9 @@ import { getConfig } from "@config"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { AppDataSource } from "~database/datasource"; -import { Application } from "~database/entities/Application"; +import { ApplicationAction } from "~database/entities/Application"; import { Token, TokenType } from "~database/entities/Token"; -import { User } from "~database/entities/User"; +import { UserAction } from "~database/entities/User"; import { APIAccount } from "~types/entities/account"; import { APIRelationship } from "~types/entities/relationship"; import { APIStatus } from "~types/entities/status"; @@ -13,15 +13,15 @@ import { APIStatus } from "~types/entities/status"; const config = getConfig(); let token: Token; -let user: User; -let user2: User; +let user: UserAction; +let user2: UserAction; describe("API Tests", () => { beforeAll(async () => { if (!AppDataSource.isInitialized) await AppDataSource.initialize(); // Initialize test user - user = await User.createNewLocal({ + user = await UserAction.createNewLocal({ email: "test@test.com", username: "test", password: "test", @@ -29,14 +29,14 @@ describe("API Tests", () => { }); // Initialize second test user - user2 = await User.createNewLocal({ + user2 = await UserAction.createNewLocal({ email: "test2@test.com", username: "test2", password: "test2", display_name: "", }); - const app = new Application(); + const app = new ApplicationAction(); app.name = "Test Application"; app.website = "https://example.com"; diff --git a/tests/api/statuses.test.ts b/tests/api/statuses.test.ts index 484a3979..168a9b27 100644 --- a/tests/api/statuses.test.ts +++ b/tests/api/statuses.test.ts @@ -3,17 +3,17 @@ import { getConfig } from "@config"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { AppDataSource } from "~database/datasource"; -import { Application } from "~database/entities/Application"; +import { ApplicationAction } from "~database/entities/Application"; import { Token, TokenType } from "~database/entities/Token"; -import { User } from "~database/entities/User"; +import { UserAction } from "~database/entities/User"; import { APIContext } from "~types/entities/context"; import { APIStatus } from "~types/entities/status"; const config = getConfig(); let token: Token; -let user: User; -let user2: User; +let user: UserAction; +let user2: UserAction; let status: APIStatus | null = null; let status2: APIStatus | null = null; @@ -22,7 +22,7 @@ describe("API Tests", () => { if (!AppDataSource.isInitialized) await AppDataSource.initialize(); // Initialize test user - user = await User.createNewLocal({ + user = await UserAction.createNewLocal({ email: "test@test.com", username: "test", password: "test", @@ -30,14 +30,14 @@ describe("API Tests", () => { }); // Initialize second test user - user2 = await User.createNewLocal({ + user2 = await UserAction.createNewLocal({ email: "test2@test.com", username: "test2", password: "test2", display_name: "", }); - const app = new Application(); + const app = new ApplicationAction(); app.name = "Test Application"; app.website = "https://example.com"; @@ -157,7 +157,7 @@ describe("API Tests", () => { expect(status2.card).toBeNull(); expect(status2.poll).toBeNull(); expect(status2.emojis).toEqual([]); - expect(status2.in_reply_to_id).toEqual(status?.id); + expect(status2.in_reply_to_id).toEqual(status?.id || null); expect(status2.in_reply_to_account_id).toEqual(user.id); }); }); @@ -181,7 +181,7 @@ describe("API Tests", () => { const statusJson = (await response.json()) as APIStatus; - expect(statusJson.id).toBe(status?.id); + expect(statusJson.id).toBe(status?.id || ""); expect(statusJson.content).toBeDefined(); expect(statusJson.created_at).toBeDefined(); expect(statusJson.account).toBeDefined(); @@ -231,7 +231,7 @@ describe("API Tests", () => { expect(context.descendants.length).toBe(1); // First descendant should be status2 - expect(context.descendants[0].id).toBe(status2?.id); + expect(context.descendants[0].id).toBe(status2?.id || ""); }); }); @@ -322,7 +322,7 @@ describe("API Tests", () => { "application/json" ); - const users = (await response.json()) as User[]; + const users = (await response.json()) as UserAction[]; expect(users.length).toBe(1); expect(users[0].id).toBe(user.id); diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts index e7bf0e56..a0d132d9 100644 --- a/tests/oauth.test.ts +++ b/tests/oauth.test.ts @@ -1,9 +1,9 @@ import { getConfig } from "@config"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { AppDataSource } from "~database/datasource"; -import { Application } from "~database/entities/Application"; +import { ApplicationAction } from "~database/entities/Application"; import { Token } from "~database/entities/Token"; -import { User, userRelations } from "~database/entities/User"; +import { UserAction, userRelations } from "~database/entities/User"; const config = getConfig(); @@ -16,7 +16,7 @@ beforeAll(async () => { if (!AppDataSource.isInitialized) await AppDataSource.initialize(); // Initialize test user - await User.createNewLocal({ + await UserAction.createNewLocal({ email: "test@test.com", username: "test", password: "test", @@ -139,7 +139,7 @@ describe("GET /api/v1/apps/verify_credentials", () => { expect(response.status).toBe(200); expect(response.headers.get("content-type")).toBe("application/json"); - const credentials = (await response.json()) as Partial; + const credentials = (await response.json()) as Partial; expect(credentials.name).toBe("Test Application"); expect(credentials.website).toBe("https://example.com"); @@ -150,7 +150,7 @@ describe("GET /api/v1/apps/verify_credentials", () => { afterAll(async () => { // Clean up user - const user = await User.findOne({ + const user = await UserAction.findOne({ where: { username: "test", }, @@ -164,7 +164,7 @@ afterAll(async () => { }, }); - const applications = await Application.findBy({ + const applications = await ApplicationAction.findBy({ client_id, secret: client_secret, });