diff --git a/database/entities/Emoji.ts b/database/entities/Emoji.ts index f0a27dea..83833ec5 100644 --- a/database/entities/Emoji.ts +++ b/database/entities/Emoji.ts @@ -1,5 +1,12 @@ -import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm"; +import { + BaseEntity, + Column, + Entity, + ManyToOne, + PrimaryGeneratedColumn, +} from "typeorm"; import { APIEmoji } from "~types/entities/emoji"; +import { Instance } from "./Instance"; /** * Represents an emoji entity in the database. @@ -20,6 +27,15 @@ export class Emoji extends BaseEntity { @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; + /** * The URL for the emoji. */ diff --git a/database/entities/Instance.ts b/database/entities/Instance.ts index 71efe017..145eab9d 100644 --- a/database/entities/Instance.ts +++ b/database/entities/Instance.ts @@ -1,12 +1,62 @@ -import { - BaseEntity, - Column, - Entity, - ManyToOne, - PrimaryGeneratedColumn, -} from "typeorm"; +import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm"; import { APIInstance } from "~types/entities/instance"; -import { User } from "./User"; +import { APIAccount } from "~types/entities/account"; + +export interface NodeInfo { + software: { + name: string; + version: string; + }; + protocols: string[]; + version: string; + services: { + inbound: string[]; + outbound: string[]; + }; + openRegistrations: boolean; + usage: { + users: { + total: number; + activeHalfyear: number; + activeMonth: number; + }; + localPosts: number; + localComments?: number; + remotePosts?: number; + remoteComments?: number; + }; + metadata: Partial<{ + nodeName: string; + nodeDescription: string; + maintainer: { + name: string; + email: string; + }; + langs: string[]; + tosUrl: string; + repositoryUrl: string; + feedbackUrl: string; + disableRegistration: boolean; + disableLocalTimeline: boolean; + disableRecommendedTimeline: boolean; + disableGlobalTimeline: boolean; + emailRequiredForSignup: boolean; + searchFilters: boolean; + postEditing: boolean; + postImports: boolean; + enableHcaptcha: boolean; + enableRecaptcha: boolean; + maxNoteTextLength: number; + maxCaptionTextLength: number; + enableTwitterIntegration: boolean; + enableGithubIntegration: boolean; + enableDiscordIntegration: boolean; + enableEmail: boolean; + enableServiceWorker: boolean; + proxyAccountName: string | null; + themeColor: string; + }>; +} /** * Represents an instance in the database. @@ -22,67 +72,122 @@ export class Instance extends BaseEntity { id!: string; /** - * The contact account associated with the instance. + * The base URL of the instance. + * Must not have the https:// or http:// prefix. */ - @ManyToOne(() => User, user => user.id) - contact_account!: User; + @Column("varchar") + base_url!: string; /** * The configuration of the instance. */ @Column("jsonb", { - default: { - media_attachments: { - image_matrix_limit: 0, - image_size_limit: 0, - supported_mime_types: [], - video_frame_limit: 0, - video_matrix_limit: 0, - video_size_limit: 0, - }, - polls: { - max_options: 0, - max_characters_per_option: 0, - max_expiration: 0, - min_expiration: 0, - }, - statuses: { - characters_reserved_per_url: 0, - max_characters: 0, - max_media_attachments: 0, - }, - }, + nullable: true, }) - configuration!: APIInstance["configuration"]; + instance_data?: APIInstance; + + /** + * Instance nodeinfo data + */ + @Column("jsonb") + nodeinfo!: NodeInfo; + + /** + * 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 nodeinfo: NodeInfo = await fetch(`${origin}/nodeinfo/2.0`).then( + res => res.json() + ); + + // Try to fetch configuration from Mastodon-compatible instances + if ( + ["firefish", "iceshrimp", "mastodon", "akkoma", "pleroma"].includes( + nodeinfo.software.name + ) + ) { + const instanceData: APIInstance = await fetch( + `${origin}/api/v1/instance` + ).then(res => res.json()); + + instance.instance_data = instanceData; + } + + instance.nodeinfo = nodeinfo; + + await instance.save(); + + return instance; + } /** * Converts the instance to an API instance. * @returns The API instance. */ + // eslint-disable-next-line @typescript-eslint/require-await async toAPI(): Promise { return { - uri: "", - approval_required: false, - email: "", - thumbnail: "", - title: "", - version: "", - configuration: this.configuration, - contact_account: await this.contact_account.toAPI(), - description: "", - invites_enabled: false, - languages: [], - registrations: false, - rules: [], + uri: this.instance_data?.uri || this.base_url, + approval_required: this.instance_data?.approval_required || false, + email: this.instance_data?.email || "", + thumbnail: this.instance_data?.thumbnail || "", + title: this.instance_data?.title || "", + version: this.instance_data?.version || "", + configuration: this.instance_data?.configuration || { + media_attachments: { + image_matrix_limit: 0, + image_size_limit: 0, + supported_mime_types: [], + video_frame_limit: 0, + video_matrix_limit: 0, + video_size_limit: 0, + }, + polls: { + max_characters_per_option: 0, + max_expiration: 0, + max_options: 0, + min_expiration: 0, + }, + statuses: { + characters_reserved_per_url: 0, + max_characters: 0, + max_media_attachments: 0, + }, + }, + contact_account: + this.instance_data?.contact_account || ({} as APIAccount), + description: this.instance_data?.description || "", + invites_enabled: this.instance_data?.invites_enabled || false, + languages: this.instance_data?.languages || [], + registrations: this.instance_data?.registrations || false, + rules: this.instance_data?.rules || [], stats: { - domain_count: 0, - status_count: 0, - user_count: 0, + domain_count: this.instance_data?.stats.domain_count || 0, + status_count: this.instance_data?.stats.status_count || 0, + user_count: this.instance_data?.stats.user_count || 0, }, urls: { - streaming_api: "", + streaming_api: this.instance_data?.urls.streaming_api || "", }, - max_toot_chars: 0, + max_toot_chars: this.instance_data?.max_toot_chars || 0, }; } } diff --git a/database/entities/RawActor.ts b/database/entities/RawActor.ts index b45f3a6e..2b448327 100644 --- a/database/entities/RawActor.ts +++ b/database/entities/RawActor.ts @@ -5,6 +5,7 @@ import { getConfig, getHost } from "@config"; import { appendFile } from "fs/promises"; import { errorResponse } from "@response"; import { APIAccount } from "~types/entities/account"; +import { RawActivity } from "./RawActivity"; /** * Represents a raw actor entity in the database. @@ -126,6 +127,15 @@ export class RawActor extends BaseEntity { const { preferredUsername, name, summary, published, icon, image } = this.data; + const statusCount = await RawActivity.createQueryBuilder("activity") + .leftJoinAndSelect("activity.actor", "actor") + .where("actor.data @> :data", { + data: JSON.stringify({ + id: this.data.id, + }), + }) + .getCount(); + return { id: this.id, username: preferredUsername ?? "", @@ -142,9 +152,9 @@ export class RawActor extends BaseEntity { config.defaults.header, locked: false, created_at: new Date(published ?? 0).toISOString(), - followers_count: 0, + followers_count: this.followers.length, following_count: 0, - statuses_count: 0, + statuses_count: statusCount, emojis: [], fields: [], bot: false, diff --git a/database/entities/Status.ts b/database/entities/Status.ts index 82d34790..d25f3d4b 100644 --- a/database/entities/Status.ts +++ b/database/entities/Status.ts @@ -161,6 +161,26 @@ export class Status extends BaseEntity { return await super.remove(options); } + async parseEmojis(string: string) { + const emojis = [...string.matchAll(/:([a-zA-Z0-9_]+):/g)].map( + match => match[1] + ); + + const emojiObjects = await Promise.all( + emojis.map(async emoji => { + const emojiObject = await Emoji.findOne({ + where: { + shortcode: emoji, + }, + }); + + return emojiObject; + }) + ); + + return emojiObjects.filter(emoji => emoji !== null) as Emoji[]; + } + /** * Creates a new status and saves it to the database. * @param data The data for the new status. diff --git a/database/entities/User.ts b/database/entities/User.ts index 9ad0afa2..38e6af36 100644 --- a/database/entities/User.ts +++ b/database/entities/User.ts @@ -1,4 +1,4 @@ -import { getConfig, getHost } from "@config"; +import { getConfig } from "@config"; import { BaseEntity, Column, @@ -21,8 +21,6 @@ import { Status } from "./Status"; import { APISource } from "~types/entities/source"; import { Relationship } from "./Relationship"; -const config = getConfig(); - /** * Represents a user in the database. * Stores local and remote users @@ -382,6 +380,7 @@ export class User extends BaseEntity { const privateKey = btoa( String.fromCharCode.apply(null, [ ...new Uint8Array( + // jesus help me what do these letters mean await crypto.subtle.exportKey("pkcs8", keys.privateKey) ), ]) @@ -389,6 +388,7 @@ export class User extends BaseEntity { const publicKey = btoa( String.fromCharCode( ...new Uint8Array( + // why is exporting a key so hard await crypto.subtle.exportKey("spki", keys.publicKey) ) ) @@ -402,33 +402,6 @@ export class User extends BaseEntity { // eslint-disable-next-line @typescript-eslint/require-await async toAPI(): Promise { - return { - acct: `@${this.username}@${getHost()}`, - avatar: "", - avatar_static: "", - bot: false, - created_at: this.created_at.toISOString(), - display_name: this.display_name, - followers_count: 0, - following_count: 0, - group: false, - header: "", - header_static: "", - id: this.id, - locked: false, - moved: null, - noindex: false, - note: this.note, - suspended: false, - url: `${config.http.base_url}/@${this.username}`, - username: this.username, - emojis: [], - fields: [], - limited: false, - statuses_count: 0, - discoverable: undefined, - role: undefined, - mute_expires_at: undefined, - }; + return await this.actor.toAPIAccount(); } } diff --git a/tests/entities/Instance.test.ts b/tests/entities/Instance.test.ts new file mode 100644 index 00000000..724c36d6 --- /dev/null +++ b/tests/entities/Instance.test.ts @@ -0,0 +1,40 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { AppDataSource } from "~database/datasource"; +import { Instance } from "~database/entities/Instance"; + +let instance: Instance; + +beforeAll(async () => { + if (!AppDataSource.isInitialized) await AppDataSource.initialize(); +}); + +describe("Instance", () => { + it("should add an instance to the database if it doesn't already exist", async () => { + const url = "https://mastodon.social"; + instance = await Instance.addIfNotExists(url); + expect(instance.base_url).toBe("mastodon.social"); + }); + + it("should convert the instance to an API instance", async () => { + const apiInstance = await instance.toAPI(); + expect(apiInstance.uri).toBe("mastodon.social"); + expect(apiInstance.approval_required).toBe(false); + expect(apiInstance.email).toBe("staff@mastodon.social"); + expect(apiInstance.thumbnail).toBeDefined(); + expect(apiInstance.title).toBeDefined(); + expect(apiInstance.configuration).toBeDefined(); + expect(apiInstance.contact_account).toBeDefined(); + expect(apiInstance.description).toBeDefined(); + expect(apiInstance.invites_enabled).toBeDefined(); + expect(apiInstance.languages).toBeDefined(); + expect(apiInstance.registrations).toBeDefined(); + expect(apiInstance.rules).toBeDefined(); + expect(apiInstance.stats).toBeDefined(); + expect(apiInstance.urls).toBeDefined(); + expect(apiInstance.max_toot_chars).toBeDefined(); + }); +}); + +afterAll(async () => { + await instance.remove(); +});