From f44f5d7ac2d24ff836c2e1d4b2fa58da04b13052 Mon Sep 17 00:00:00 2001 From: Madeline <46743919+MaddyUnderStars@users.noreply.github.com> Date: Sun, 25 Sep 2022 18:24:21 +1000 Subject: Refactor to mono-repo + upgrade packages --- src/util/entities/Application.ts | 109 +++++++++ src/util/entities/Attachment.ts | 43 ++++ src/util/entities/AuditLog.ts | 194 +++++++++++++++ src/util/entities/BackupCodes.ts | 35 +++ src/util/entities/Ban.ts | 41 ++++ src/util/entities/BaseClass.ts | 52 ++++ src/util/entities/Categories.ts | 33 +++ src/util/entities/Channel.ts | 390 ++++++++++++++++++++++++++++++ src/util/entities/ClientRelease.ts | 26 ++ src/util/entities/Config.ts | 416 ++++++++++++++++++++++++++++++++ src/util/entities/ConnectedAccount.ts | 42 ++++ src/util/entities/Emoji.ts | 46 ++++ src/util/entities/Encryption.ts | 35 +++ src/util/entities/Guild.ts | 363 ++++++++++++++++++++++++++++ src/util/entities/Invite.ts | 85 +++++++ src/util/entities/Member.ts | 430 ++++++++++++++++++++++++++++++++++ src/util/entities/Message.ts | 296 +++++++++++++++++++++++ src/util/entities/Migration.ts | 18 ++ src/util/entities/Note.ts | 18 ++ src/util/entities/RateLimit.ts | 17 ++ src/util/entities/ReadState.ts | 55 +++++ src/util/entities/Recipient.ts | 30 +++ src/util/entities/Relationship.ts | 49 ++++ src/util/entities/Role.ts | 51 ++++ src/util/entities/Session.ts | 46 ++++ src/util/entities/Sticker.ts | 66 ++++++ src/util/entities/StickerPack.ts | 31 +++ src/util/entities/Team.ts | 27 +++ src/util/entities/TeamMember.ts | 37 +++ src/util/entities/Template.ts | 44 ++++ src/util/entities/User.ts | 430 ++++++++++++++++++++++++++++++++++ src/util/entities/VoiceState.ts | 77 ++++++ src/util/entities/Webhook.ts | 76 ++++++ src/util/entities/index.ts | 32 +++ 34 files changed, 3740 insertions(+) create mode 100644 src/util/entities/Application.ts create mode 100644 src/util/entities/Attachment.ts create mode 100644 src/util/entities/AuditLog.ts create mode 100644 src/util/entities/BackupCodes.ts create mode 100644 src/util/entities/Ban.ts create mode 100644 src/util/entities/BaseClass.ts create mode 100644 src/util/entities/Categories.ts create mode 100644 src/util/entities/Channel.ts create mode 100644 src/util/entities/ClientRelease.ts create mode 100644 src/util/entities/Config.ts create mode 100644 src/util/entities/ConnectedAccount.ts create mode 100644 src/util/entities/Emoji.ts create mode 100644 src/util/entities/Encryption.ts create mode 100644 src/util/entities/Guild.ts create mode 100644 src/util/entities/Invite.ts create mode 100644 src/util/entities/Member.ts create mode 100644 src/util/entities/Message.ts create mode 100644 src/util/entities/Migration.ts create mode 100644 src/util/entities/Note.ts create mode 100644 src/util/entities/RateLimit.ts create mode 100644 src/util/entities/ReadState.ts create mode 100644 src/util/entities/Recipient.ts create mode 100644 src/util/entities/Relationship.ts create mode 100644 src/util/entities/Role.ts create mode 100644 src/util/entities/Session.ts create mode 100644 src/util/entities/Sticker.ts create mode 100644 src/util/entities/StickerPack.ts create mode 100644 src/util/entities/Team.ts create mode 100644 src/util/entities/TeamMember.ts create mode 100644 src/util/entities/Template.ts create mode 100644 src/util/entities/User.ts create mode 100644 src/util/entities/VoiceState.ts create mode 100644 src/util/entities/Webhook.ts create mode 100644 src/util/entities/index.ts (limited to 'src/util/entities') diff --git a/src/util/entities/Application.ts b/src/util/entities/Application.ts new file mode 100644 index 00000000..fab3d93f --- /dev/null +++ b/src/util/entities/Application.ts @@ -0,0 +1,109 @@ +import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm"; +import { BaseClass } from "./BaseClass"; +import { Guild } from "./Guild"; +import { Team } from "./Team"; +import { User } from "./User"; + +@Entity("applications") +export class Application extends BaseClass { + @Column() + name: string; + + @Column({ nullable: true }) + icon?: string; + + @Column() + description: string; + + @Column({ type: "simple-array", nullable: true }) + rpc_origins?: string[]; + + @Column() + bot_public: boolean; + + @Column() + bot_require_code_grant: boolean; + + @Column({ nullable: true }) + terms_of_service_url?: string; + + @Column({ nullable: true }) + privacy_policy_url?: string; + + @JoinColumn({ name: "owner_id" }) + @ManyToOne(() => User) + owner?: User; + + @Column({ nullable: true }) + summary?: string; + + @Column() + verify_key: string; + + @JoinColumn({ name: "team_id" }) + @ManyToOne(() => Team, { + onDelete: "CASCADE", + }) + team?: Team; + + @JoinColumn({ name: "guild_id" }) + @ManyToOne(() => Guild) + guild: Guild; // if this application is a game sold, this field will be the guild to which it has been linked + + @Column({ nullable: true }) + primary_sku_id?: string; // if this application is a game sold, this field will be the id of the "Game SKU" that is created, + + @Column({ nullable: true }) + slug?: string; // if this application is a game sold, this field will be the URL slug that links to the store page + + @Column({ nullable: true }) + cover_image?: string; // the application's default rich presence invite cover image hash + + @Column() + flags: string; // the application's public flags +} + +export interface ApplicationCommand { + id: string; + application_id: string; + name: string; + description: string; + options?: ApplicationCommandOption[]; +} + +export interface ApplicationCommandOption { + type: ApplicationCommandOptionType; + name: string; + description: string; + required?: boolean; + choices?: ApplicationCommandOptionChoice[]; + options?: ApplicationCommandOption[]; +} + +export interface ApplicationCommandOptionChoice { + name: string; + value: string | number; +} + +export enum ApplicationCommandOptionType { + SUB_COMMAND = 1, + SUB_COMMAND_GROUP = 2, + STRING = 3, + INTEGER = 4, + BOOLEAN = 5, + USER = 6, + CHANNEL = 7, + ROLE = 8, +} + +export interface ApplicationCommandInteractionData { + id: string; + name: string; + options?: ApplicationCommandInteractionDataOption[]; +} + +export interface ApplicationCommandInteractionDataOption { + name: string; + value?: any; + options?: ApplicationCommandInteractionDataOption[]; +} diff --git a/src/util/entities/Attachment.ts b/src/util/entities/Attachment.ts new file mode 100644 index 00000000..7b4b17eb --- /dev/null +++ b/src/util/entities/Attachment.ts @@ -0,0 +1,43 @@ +import { BeforeRemove, Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm"; +import { URL } from "url"; +import { deleteFile } from "../util/cdn"; +import { BaseClass } from "./BaseClass"; + +@Entity("attachments") +export class Attachment extends BaseClass { + @Column() + filename: string; // name of file attached + + @Column() + size: number; // size of file in bytes + + @Column() + url: string; // source url of file + + @Column() + proxy_url: string; // a proxied url of file + + @Column({ nullable: true }) + height?: number; // height of file (if image) + + @Column({ nullable: true }) + width?: number; // width of file (if image) + + @Column({ nullable: true }) + content_type?: string; + + @Column({ nullable: true }) + @RelationId((attachment: Attachment) => attachment.message) + message_id: string; + + @JoinColumn({ name: "message_id" }) + @ManyToOne(() => require("./Message").Message, (message: import("./Message").Message) => message.attachments, { + onDelete: "CASCADE", + }) + message: import("./Message").Message; + + @BeforeRemove() + onDelete() { + return deleteFile(new URL(this.url).pathname); + } +} diff --git a/src/util/entities/AuditLog.ts b/src/util/entities/AuditLog.ts new file mode 100644 index 00000000..b003e7ba --- /dev/null +++ b/src/util/entities/AuditLog.ts @@ -0,0 +1,194 @@ +import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm"; +import { BaseClass } from "./BaseClass"; +import { ChannelPermissionOverwrite } from "./Channel"; +import { User } from "./User"; + +export enum AuditLogEvents { + // guild level + GUILD_UPDATE = 1, + GUILD_IMPORT = 2, + GUILD_EXPORTED = 3, + GUILD_ARCHIVE = 4, + GUILD_UNARCHIVE = 5, + // join-leave + USER_JOIN = 6, + USER_LEAVE = 7, + // channels + CHANNEL_CREATE = 10, + CHANNEL_UPDATE = 11, + CHANNEL_DELETE = 12, + // permission overrides + CHANNEL_OVERWRITE_CREATE = 13, + CHANNEL_OVERWRITE_UPDATE = 14, + CHANNEL_OVERWRITE_DELETE = 15, + // kick and ban + MEMBER_KICK = 20, + MEMBER_PRUNE = 21, + MEMBER_BAN_ADD = 22, + MEMBER_BAN_REMOVE = 23, + // member updates + MEMBER_UPDATE = 24, + MEMBER_ROLE_UPDATE = 25, + MEMBER_MOVE = 26, + MEMBER_DISCONNECT = 27, + BOT_ADD = 28, + // roles + ROLE_CREATE = 30, + ROLE_UPDATE = 31, + ROLE_DELETE = 32, + ROLE_SWAP = 33, + // invites + INVITE_CREATE = 40, + INVITE_UPDATE = 41, + INVITE_DELETE = 42, + // webhooks + WEBHOOK_CREATE = 50, + WEBHOOK_UPDATE = 51, + WEBHOOK_DELETE = 52, + WEBHOOK_SWAP = 53, + // custom emojis + EMOJI_CREATE = 60, + EMOJI_UPDATE = 61, + EMOJI_DELETE = 62, + EMOJI_SWAP = 63, + // deletion + MESSAGE_CREATE = 70, // messages sent using non-primary seat of the user only + MESSAGE_EDIT = 71, // non-self edits only + MESSAGE_DELETE = 72, + MESSAGE_BULK_DELETE = 73, + // pinning + MESSAGE_PIN = 74, + MESSAGE_UNPIN = 75, + // integrations + INTEGRATION_CREATE = 80, + INTEGRATION_UPDATE = 81, + INTEGRATION_DELETE = 82, + // stage actions + STAGE_INSTANCE_CREATE = 83, + STAGE_INSTANCE_UPDATE = 84, + STAGE_INSTANCE_DELETE = 85, + // stickers + STICKER_CREATE = 90, + STICKER_UPDATE = 91, + STICKER_DELETE = 92, + STICKER_SWAP = 93, + // threads + THREAD_CREATE = 110, + THREAD_UPDATE = 111, + THREAD_DELETE = 112, + // application commands + APPLICATION_COMMAND_PERMISSION_UPDATE = 121, + // automod + POLICY_CREATE = 140, + POLICY_UPDATE = 141, + POLICY_DELETE = 142, + MESSAGE_BLOCKED_BY_POLICIES = 143, // in fosscord, blocked messages are stealth-dropped + // instance policies affecting the guild + GUILD_AFFECTED_BY_POLICIES = 216, + // message moves + IN_GUILD_MESSAGE_MOVE = 223, + CROSS_GUILD_MESSAGE_MOVE = 224, + // message routing + ROUTE_CREATE = 225, + ROUTE_UPDATE = 226, +} + +@Entity("audit_logs") +export class AuditLog extends BaseClass { + @JoinColumn({ name: "target_id" }) + @ManyToOne(() => User) + target?: User; + + @Column({ nullable: true }) + @RelationId((auditlog: AuditLog) => auditlog.user) + user_id: string; + + @JoinColumn({ name: "user_id" }) + @ManyToOne(() => User, (user: User) => user.id) + user: User; + + @Column({ type: "int" }) + action_type: AuditLogEvents; + + @Column({ type: "simple-json", nullable: true }) + options?: { + delete_member_days?: string; + members_removed?: string; + channel_id?: string; + messaged_id?: string; + count?: string; + id?: string; + type?: string; + role_name?: string; + }; + + @Column() + @Column({ type: "simple-json" }) + changes: AuditLogChange[]; + + @Column({ nullable: true }) + reason?: string; +} + +export interface AuditLogChange { + new_value?: AuditLogChangeValue; + old_value?: AuditLogChangeValue; + key: string; +} + +export interface AuditLogChangeValue { + name?: string; + description?: string; + icon_hash?: string; + splash_hash?: string; + discovery_splash_hash?: string; + banner_hash?: string; + owner_id?: string; + region?: string; + preferred_locale?: string; + afk_channel_id?: string; + afk_timeout?: number; + rules_channel_id?: string; + public_updates_channel_id?: string; + mfa_level?: number; + verification_level?: number; + explicit_content_filter?: number; + default_message_notifications?: number; + vanity_url_code?: string; + $add?: {}[]; + $remove?: {}[]; + prune_delete_days?: number; + widget_enabled?: boolean; + widget_channel_id?: string; + system_channel_id?: string; + position?: number; + topic?: string; + bitrate?: number; + permission_overwrites?: ChannelPermissionOverwrite[]; + nsfw?: boolean; + application_id?: string; + rate_limit_per_user?: number; + permissions?: string; + color?: number; + hoist?: boolean; + mentionable?: boolean; + allow?: string; + deny?: string; + code?: string; + channel_id?: string; + inviter_id?: string; + max_uses?: number; + uses?: number; + max_age?: number; + temporary?: boolean; + deaf?: boolean; + mute?: boolean; + nick?: string; + avatar_hash?: string; + id?: string; + type?: number; + enable_emoticons?: boolean; + expire_behavior?: number; + expire_grace_period?: number; + user_limit?: number; +} diff --git a/src/util/entities/BackupCodes.ts b/src/util/entities/BackupCodes.ts new file mode 100644 index 00000000..d532a39a --- /dev/null +++ b/src/util/entities/BackupCodes.ts @@ -0,0 +1,35 @@ +import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm"; +import { BaseClass } from "./BaseClass"; +import { User } from "./User"; +import crypto from "crypto"; + +@Entity("backup_codes") +export class BackupCode extends BaseClass { + @JoinColumn({ name: "user_id" }) + @ManyToOne(() => User, { onDelete: "CASCADE" }) + user: User; + + @Column() + code: string; + + @Column() + consumed: boolean; + + @Column() + expired: boolean; +} + +export function generateMfaBackupCodes(user_id: string) { + let backup_codes: BackupCode[] = []; + for (let i = 0; i < 10; i++) { + const code = BackupCode.create({ + user: { id: user_id }, + code: crypto.randomBytes(4).toString("hex"), // 8 characters + consumed: false, + expired: false, + }); + backup_codes.push(code); + } + + return backup_codes; +} \ No newline at end of file diff --git a/src/util/entities/Ban.ts b/src/util/entities/Ban.ts new file mode 100644 index 00000000..9504bd8e --- /dev/null +++ b/src/util/entities/Ban.ts @@ -0,0 +1,41 @@ +import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm"; +import { BaseClass } from "./BaseClass"; +import { Guild } from "./Guild"; +import { User } from "./User"; + +@Entity("bans") +export class Ban extends BaseClass { + @Column({ nullable: true }) + @RelationId((ban: Ban) => ban.user) + user_id: string; + + @JoinColumn({ name: "user_id" }) + @ManyToOne(() => User, { + onDelete: "CASCADE", + }) + user: User; + + @Column({ nullable: true }) + @RelationId((ban: Ban) => ban.guild) + guild_id: string; + + @JoinColumn({ name: "guild_id" }) + @ManyToOne(() => Guild, { + onDelete: "CASCADE", + }) + guild: Guild; + + @Column({ nullable: true }) + @RelationId((ban: Ban) => ban.executor) + executor_id: string; + + @JoinColumn({ name: "executor_id" }) + @ManyToOne(() => User) + executor: User; + + @Column() + ip: string; + + @Column({ nullable: true }) + reason?: string; +} diff --git a/src/util/entities/BaseClass.ts b/src/util/entities/BaseClass.ts new file mode 100644 index 00000000..d5a7c2bf --- /dev/null +++ b/src/util/entities/BaseClass.ts @@ -0,0 +1,52 @@ +import "reflect-metadata"; +import { BaseEntity, BeforeInsert, BeforeUpdate, FindOptionsWhere, ObjectIdColumn, PrimaryColumn } from "typeorm"; +import { Snowflake } from "../util/Snowflake"; +import "missing-native-js-functions"; +import { getDatabase } from ".."; +import { OrmUtils } from "@fosscord/util"; + +export class BaseClassWithoutId extends BaseEntity { + private get construct(): any { + return this.constructor; + } + + private get metadata() { + return getDatabase()?.getMetadata(this.construct); + } + + assign(props: any) { + OrmUtils.mergeDeep(this, props); + return this; + } + + toJSON(): any { + return Object.fromEntries( + this.metadata!.columns // @ts-ignore + .map((x) => [x.propertyName, this[x.propertyName]]) // @ts-ignore + .concat(this.metadata.relations.map((x) => [x.propertyName, this[x.propertyName]])) + ); + } + + static increment(conditions: FindOptionsWhere, propertyPath: string, value: number | string) { + const repository = this.getRepository(); + return repository.increment(conditions, propertyPath, value); + } + + static decrement(conditions: FindOptionsWhere, propertyPath: string, value: number | string) { + const repository = this.getRepository(); + return repository.decrement(conditions, propertyPath, value); + } +} + +export const PrimaryIdColumn = process.env.DATABASE?.startsWith("mongodb") ? ObjectIdColumn : PrimaryColumn; + +export class BaseClass extends BaseClassWithoutId { + @PrimaryIdColumn() + id: string; + + @BeforeUpdate() + @BeforeInsert() + do_validate() { + if (!this.id) this.id = Snowflake.generate(); + } +} diff --git a/src/util/entities/Categories.ts b/src/util/entities/Categories.ts new file mode 100644 index 00000000..81fbc303 --- /dev/null +++ b/src/util/entities/Categories.ts @@ -0,0 +1,33 @@ +import { PrimaryColumn, Column, Entity} from "typeorm"; +import { BaseClassWithoutId } from "./BaseClass"; + +// TODO: categories: +// [{ +// "id": 16, +// "default": "Anime & Manga", +// "localizations": { +// "de": "Anime & Manga", +// "fr": "Anim\u00e9s et mangas", +// "ru": "\u0410\u043d\u0438\u043c\u0435 \u0438 \u043c\u0430\u043d\u0433\u0430" +// } +// }, +// "is_primary": false/true +// }] +// Also populate discord default categories + +@Entity("categories") +export class Categories extends BaseClassWithoutId { // Not using snowflake + + @PrimaryColumn() + id: number; + + @Column({ nullable: true }) + name: string; + + @Column({ type: "simple-json" }) + localizations: string; + + @Column({ nullable: true }) + is_primary: boolean; + +} \ No newline at end of file diff --git a/src/util/entities/Channel.ts b/src/util/entities/Channel.ts new file mode 100644 index 00000000..577b627e --- /dev/null +++ b/src/util/entities/Channel.ts @@ -0,0 +1,390 @@ +import { Column, Entity, JoinColumn, ManyToOne, OneToMany, RelationId } from "typeorm"; +import { BaseClass } from "./BaseClass"; +import { Guild } from "./Guild"; +import { PublicUserProjection, User } from "./User"; +import { HTTPError } from "lambert-server"; +import { containsAll, emitEvent, getPermission, Snowflake, trimSpecial, InvisibleCharacters, ChannelTypes } from "../util"; +import { ChannelCreateEvent, ChannelRecipientRemoveEvent } from "../interfaces"; +import { Recipient } from "./Recipient"; +import { Message } from "./Message"; +import { ReadState } from "./ReadState"; +import { Invite } from "./Invite"; +import { VoiceState } from "./VoiceState"; +import { Webhook } from "./Webhook"; +import { DmChannelDTO } from "../dtos"; + +export enum ChannelType { + GUILD_TEXT = 0, // a text channel within a guild + DM = 1, // a direct message between users + GUILD_VOICE = 2, // a voice channel within a guild + GROUP_DM = 3, // a direct message between multiple users + GUILD_CATEGORY = 4, // an organizational category that contains zero or more channels + GUILD_NEWS = 5, // a channel that users can follow and crosspost into a guild or route + GUILD_STORE = 6, // a channel in which game developers can sell their things + ENCRYPTED = 7, // end-to-end encrypted channel + ENCRYPTED_THREAD = 8, // end-to-end encrypted thread channel + TRANSACTIONAL = 9, // event chain style transactional channel + GUILD_NEWS_THREAD = 10, // a temporary sub-channel within a GUILD_NEWS channel + GUILD_PUBLIC_THREAD = 11, // a temporary sub-channel within a GUILD_TEXT channel + GUILD_PRIVATE_THREAD = 12, // a temporary sub-channel within a GUILD_TEXT channel that is only viewable by those invited and those with the MANAGE_THREADS permission + GUILD_STAGE_VOICE = 13, // a voice channel for hosting events with an audience + DIRECTORY = 14, // guild directory listing channel + GUILD_FORUM = 15, // forum composed of IM threads + TICKET_TRACKER = 33, // ticket tracker, individual ticket items shall have type 12 + KANBAN = 34, // confluence like kanban board + VOICELESS_WHITEBOARD = 35, // whiteboard but without voice (whiteboard + voice is the same as stage) + CUSTOM_START = 64, // start custom channel types from here + UNHANDLED = 255 // unhandled unowned pass-through channel type +} + +@Entity("channels") +export class Channel extends BaseClass { + @Column() + created_at: Date; + + @Column({ nullable: true }) + name?: string; + + @Column({ type: "text", nullable: true }) + icon?: string | null; + + @Column({ type: "int" }) + type: ChannelType; + + @OneToMany(() => Recipient, (recipient: Recipient) => recipient.channel, { + cascade: true, + orphanedRowAction: "delete", + }) + recipients?: Recipient[]; + + @Column({ nullable: true }) + last_message_id?: string; + + @Column({ nullable: true }) + @RelationId((channel: Channel) => channel.guild) + guild_id?: string; + + @JoinColumn({ name: "guild_id" }) + @ManyToOne(() => Guild, { + onDelete: "CASCADE", + }) + guild: Guild; + + @Column({ nullable: true }) + @RelationId((channel: Channel) => channel.parent) + parent_id: string; + + @JoinColumn({ name: "parent_id" }) + @ManyToOne(() => Channel) + parent?: Channel; + + // for group DMs and owned custom channel types + @Column({ nullable: true }) + @RelationId((channel: Channel) => channel.owner) + owner_id?: string; + + @JoinColumn({ name: "owner_id" }) + @ManyToOne(() => User) + owner: User; + + @Column({ nullable: true }) + last_pin_timestamp?: number; + + @Column({ nullable: true }) + default_auto_archive_duration?: number; + + @Column({ nullable: true }) + position?: number; + + @Column({ type: "simple-json", nullable: true }) + permission_overwrites?: ChannelPermissionOverwrite[]; + + @Column({ nullable: true }) + video_quality_mode?: number; + + @Column({ nullable: true }) + bitrate?: number; + + @Column({ nullable: true }) + user_limit?: number; + + @Column() + nsfw: boolean = false; + + @Column({ nullable: true }) + rate_limit_per_user?: number; + + @Column({ nullable: true }) + topic?: string; + + @OneToMany(() => Invite, (invite: Invite) => invite.channel, { + cascade: true, + orphanedRowAction: "delete", + }) + invites?: Invite[]; + + @Column({ nullable: true }) + retention_policy_id?: string; + + @OneToMany(() => Message, (message: Message) => message.channel, { + cascade: true, + orphanedRowAction: "delete", + }) + messages?: Message[]; + + @OneToMany(() => VoiceState, (voice_state: VoiceState) => voice_state.channel, { + cascade: true, + orphanedRowAction: "delete", + }) + voice_states?: VoiceState[]; + + @OneToMany(() => ReadState, (read_state: ReadState) => read_state.channel, { + cascade: true, + orphanedRowAction: "delete", + }) + read_states?: ReadState[]; + + @OneToMany(() => Webhook, (webhook: Webhook) => webhook.channel, { + cascade: true, + orphanedRowAction: "delete", + }) + webhooks?: Webhook[]; + + // TODO: DM channel + static async createChannel( + channel: Partial, + user_id: string = "0", + opts?: { + keepId?: boolean; + skipExistsCheck?: boolean; + skipPermissionCheck?: boolean; + skipEventEmit?: boolean; + skipNameChecks?: boolean; + } + ) { + if (!opts?.skipPermissionCheck) { + // Always check if user has permission first + const permissions = await getPermission(user_id, channel.guild_id); + permissions.hasThrow("MANAGE_CHANNELS"); + } + + if (!opts?.skipNameChecks) { + const guild = await Guild.findOneOrFail({ where: { id: channel.guild_id } }); + if (!guild.features.includes("ALLOW_INVALID_CHANNEL_NAMES") && channel.name) { + for (var character of InvisibleCharacters) + if (channel.name.includes(character)) + throw new HTTPError("Channel name cannot include invalid characters", 403); + + // Categories skip these checks on discord.com + if (channel.type !== ChannelType.GUILD_CATEGORY) { + if (channel.name.includes(" ")) + throw new HTTPError("Channel name cannot include invalid characters", 403); + + if (channel.name.match(/\-\-+/g)) + throw new HTTPError("Channel name cannot include multiple adjacent dashes.", 403); + + if (channel.name.charAt(0) === "-" || + channel.name.charAt(channel.name.length - 1) === "-") + throw new HTTPError("Channel name cannot start/end with dash.", 403); + } + else + channel.name = channel.name.trim(); //category names are trimmed client side on discord.com + } + + if (!guild.features.includes("ALLOW_UNNAMED_CHANNELS")) { + if (!channel.name) + throw new HTTPError("Channel name cannot be empty.", 403); + } + } + + switch (channel.type) { + case ChannelType.GUILD_TEXT: + case ChannelType.GUILD_NEWS: + case ChannelType.GUILD_VOICE: + if (channel.parent_id && !opts?.skipExistsCheck) { + const exists = await Channel.findOneOrFail({ where: { id: channel.parent_id } }); + if (!exists) throw new HTTPError("Parent id channel doesn't exist", 400); + if (exists.guild_id !== channel.guild_id) + throw new HTTPError("The category channel needs to be in the guild"); + } + break; + case ChannelType.GUILD_CATEGORY: + case ChannelType.UNHANDLED: + break; + case ChannelType.DM: + case ChannelType.GROUP_DM: + throw new HTTPError("You can't create a dm channel in a guild"); + case ChannelType.GUILD_STORE: + default: + throw new HTTPError("Not yet supported"); + } + + if (!channel.permission_overwrites) channel.permission_overwrites = []; + // TODO: eagerly auto generate position of all guild channels + + channel = { + ...channel, + ...(!opts?.keepId && { id: Snowflake.generate() }), + created_at: new Date(), + position: (channel.type === ChannelType.UNHANDLED ? 0 : channel.position) || 0, + }; + + await Promise.all([ + Channel.create(channel).save(), + !opts?.skipEventEmit + ? emitEvent({ + event: "CHANNEL_CREATE", + data: channel, + guild_id: channel.guild_id, + } as ChannelCreateEvent) + : Promise.resolve(), + ]); + + return channel; + } + + static async createDMChannel(recipients: string[], creator_user_id: string, name?: string) { + recipients = recipients.unique().filter((x) => x !== creator_user_id); + //@ts-ignore some typeorm typescript issue + const otherRecipientsUsers = await User.find({ where: recipients.map((x) => ({ id: x })) }); + + // TODO: check config for max number of recipients + /** if you want to disallow note to self channels, uncomment the conditional below + if (otherRecipientsUsers.length !== recipients.length) { + throw new HTTPError("Recipient/s not found"); + } + **/ + + const type = recipients.length > 1 ? ChannelType.GROUP_DM : ChannelType.DM; + + let channel = null; + + const channelRecipients = [...recipients, creator_user_id]; + + const userRecipients = await Recipient.find({ + where: { user_id: creator_user_id }, + relations: ["channel", "channel.recipients"], + }); + + for (let ur of userRecipients) { + let re = ur.channel.recipients!.map((r) => r.user_id); + if (re.length === channelRecipients.length) { + if (containsAll(re, channelRecipients)) { + if (channel == null) { + channel = ur.channel; + await ur.assign({ closed: false }).save(); + } + } + } + } + + if (channel == null) { + name = trimSpecial(name); + + channel = await Channel.create({ + name, + type, + owner_id: undefined, + created_at: new Date(), + last_message_id: undefined, + recipients: channelRecipients.map( + (x) => + Recipient.create({ user_id: x, closed: !(type === ChannelType.GROUP_DM || x === creator_user_id) }) + ), + nsfw: false, + }).save(); + } + + const channel_dto = await DmChannelDTO.from(channel); + + if (type === ChannelType.GROUP_DM) { + for (let recipient of channel.recipients!) { + await emitEvent({ + event: "CHANNEL_CREATE", + data: channel_dto.excludedRecipients([recipient.user_id]), + user_id: recipient.user_id, + }); + } + } else { + await emitEvent({ event: "CHANNEL_CREATE", data: channel_dto, user_id: creator_user_id }); + } + + if (recipients.length === 1) return channel_dto; + else return channel_dto.excludedRecipients([creator_user_id]); + } + + static async removeRecipientFromChannel(channel: Channel, user_id: string) { + await Recipient.delete({ channel_id: channel.id, user_id: user_id }); + channel.recipients = channel.recipients?.filter((r) => r.user_id !== user_id); + + if (channel.recipients?.length === 0) { + await Channel.deleteChannel(channel); + await emitEvent({ + event: "CHANNEL_DELETE", + data: await DmChannelDTO.from(channel, [user_id]), + user_id: user_id, + }); + return; + } + + await emitEvent({ + event: "CHANNEL_DELETE", + data: await DmChannelDTO.from(channel, [user_id]), + user_id: user_id, + }); + + //If the owner leave the server user is the new owner + if (channel.owner_id === user_id) { + channel.owner_id = "1"; // The channel is now owned by the server user + await emitEvent({ + event: "CHANNEL_UPDATE", + data: await DmChannelDTO.from(channel, [user_id]), + channel_id: channel.id, + }); + } + + await channel.save(); + + await emitEvent({ + event: "CHANNEL_RECIPIENT_REMOVE", + data: { + channel_id: channel.id, + user: await User.findOneOrFail({ where: { id: user_id }, select: PublicUserProjection }), + }, + channel_id: channel.id, + } as ChannelRecipientRemoveEvent); + } + + static async deleteChannel(channel: Channel) { + await Message.delete({ channel_id: channel.id }); //TODO we should also delete the attachments from the cdn but to do that we need to move cdn.ts in util + //TODO before deleting the channel we should check and delete other relations + await Channel.delete({ id: channel.id }); + } + + isDm() { + return this.type === ChannelType.DM || this.type === ChannelType.GROUP_DM; + } + + // Does the channel support sending messages ( eg categories do not ) + isWritable() { + const disallowedChannelTypes = [ + ChannelType.GUILD_CATEGORY, + ChannelType.GUILD_STAGE_VOICE, + ChannelType.VOICELESS_WHITEBOARD, + ]; + return disallowedChannelTypes.indexOf(this.type) == -1; + } +} + +export interface ChannelPermissionOverwrite { + allow: string; + deny: string; + id: string; + type: ChannelPermissionOverwriteType; +} + +export enum ChannelPermissionOverwriteType { + role = 0, + member = 1, + group = 2, +} diff --git a/src/util/entities/ClientRelease.ts b/src/util/entities/ClientRelease.ts new file mode 100644 index 00000000..c5afd307 --- /dev/null +++ b/src/util/entities/ClientRelease.ts @@ -0,0 +1,26 @@ +import { Column, Entity} from "typeorm"; +import { BaseClass } from "./BaseClass"; + +@Entity("client_release") +export class Release extends BaseClass { + @Column() + name: string; + + @Column() + pub_date: string; + + @Column() + url: string; + + @Column() + deb_url: string; + + @Column() + osx_url: string; + + @Column() + win_url: string; + + @Column({ nullable: true }) + notes?: string; +} diff --git a/src/util/entities/Config.ts b/src/util/entities/Config.ts new file mode 100644 index 00000000..9aabc1a8 --- /dev/null +++ b/src/util/entities/Config.ts @@ -0,0 +1,416 @@ +import { Column, Entity } from "typeorm"; +import { BaseClassWithoutId, PrimaryIdColumn } from "./BaseClass"; +import crypto from "crypto"; +import { Snowflake } from "../util/Snowflake"; +import { SessionsReplace } from ".."; +import { hostname } from "os"; +import { Rights } from "../util/Rights"; + +@Entity("config") +export class ConfigEntity extends BaseClassWithoutId { + @PrimaryIdColumn() + key: string; + + @Column({ type: "simple-json", nullable: true }) + value: number | boolean | null | string | undefined; +} + +export interface RateLimitOptions { + bot?: number; + count: number; + window: number; + onyIp?: boolean; +} + +export interface Region { + id: string; + name: string; + endpoint: string; + location?: { + latitude: number; + longitude: number; + }; + vip: boolean; + custom: boolean; + deprecated: boolean; +} + +export interface KafkaBroker { + ip: string; + port: number; +} + +export interface ConfigValue { + gateway: { + endpointClient: string | null; + endpointPrivate: string | null; + endpointPublic: string | null; + }; + cdn: { + endpointClient: string | null; + endpointPublic: string | null; + endpointPrivate: string | null; + resizeHeightMax: number | null; + resizeWidthMax: number | null; + }; + api: { + defaultVersion: string; + activeVersions: string[]; + useFosscordEnhancements: boolean; + }; + general: { + instanceName: string; + instanceDescription: string | null; + frontPage: string | null; + tosPage: string | null; + correspondenceEmail: string | null; + correspondenceUserID: string | null; + image: string | null; + instanceId: string; + }; + limits: { + user: { + maxGuilds: number; + maxUsername: number; + maxFriends: number; + }; + guild: { + maxRoles: number; + maxEmojis: number; + maxMembers: number; + maxChannels: number; + maxChannelsInCategory: number; + hideOfflineMember: number; + }; + message: { + maxCharacters: number; + maxTTSCharacters: number; + maxReactions: number; + maxAttachmentSize: number; + maxBulkDelete: number; + maxEmbedDownloadSize: number; + }; + channel: { + maxPins: number; + maxTopic: number; + maxWebhooks: number; + }; + rate: { + disabled: boolean; + ip: Omit; + global: RateLimitOptions; + error: RateLimitOptions; + routes: { + guild: RateLimitOptions; + webhook: RateLimitOptions; + channel: RateLimitOptions; + auth: { + login: RateLimitOptions; + register: RateLimitOptions; + }; + // TODO: rate limit configuration for all routes + }; + }; + }; + security: { + autoUpdate: boolean | number; + requestSignature: string; + jwtSecret: string; + forwadedFor: string | null; // header to get the real user ip address + captcha: { + enabled: boolean; + service: "recaptcha" | "hcaptcha" | null; // TODO: hcaptcha, custom + sitekey: string | null; + secret: string | null; + }; + ipdataApiKey: string | null; + defaultRights: string; + }; + login: { + requireCaptcha: boolean; + }; + register: { + email: { + required: boolean; + allowlist: boolean; + blocklist: boolean; + domains: string[]; + }; + dateOfBirth: { + required: boolean; + minimum: number; // in years + }; + disabled: boolean; + requireCaptcha: boolean; + requireInvite: boolean; + guestsRequireInvite: boolean; + allowNewRegistration: boolean; + allowMultipleAccounts: boolean; + blockProxies: boolean; + password: { + required: boolean; + minLength: number; + minNumbers: number; + minUpperCase: number; + minSymbols: number; + }; + incrementingDiscriminators: boolean; // random otherwise + }; + regions: { + default: string; + useDefaultAsOptimal: boolean; + available: Region[]; + }; + guild: { + discovery: { + showAllGuilds: boolean; + useRecommendation: boolean; // TODO: Recommendation, privacy concern? + offset: number; + limit: number; + }; + autoJoin: { + enabled: boolean; + guilds: string[]; + canLeave: boolean; + }; + defaultFeatures: string[]; + }; + gif: { + enabled: boolean; + provider: "tenor"; // more coming soon + apiKey?: string; + }; + rabbitmq: { + host: string | null; + }; + kafka: { + brokers: KafkaBroker[] | null; + }; + templates: { + enabled: Boolean; + allowTemplateCreation: Boolean; + allowDiscordTemplates: Boolean; + allowRaws: Boolean; + }, + client: { + useTestClient: Boolean; + releases: { + useLocalRelease: Boolean; //TODO + upstreamVersion: string; + }; + }, + metrics: { + timeout: number; + }, + sentry: { + enabled: boolean; + endpoint: string; + traceSampleRate: number; + environment: string; + }; +} + +export const DefaultConfigOptions: ConfigValue = { + gateway: { + endpointClient: null, + endpointPrivate: null, + endpointPublic: null, + }, + cdn: { + endpointClient: null, + endpointPrivate: null, + endpointPublic: null, + resizeHeightMax: 1000, + resizeWidthMax: 1000, + }, + api: { + defaultVersion: "9", + activeVersions: ["6", "7", "8", "9"], + useFosscordEnhancements: true, + }, + general: { + instanceName: "Fosscord Instance", + instanceDescription: "This is a Fosscord instance made in pre-release days", + frontPage: null, + tosPage: null, + correspondenceEmail: "noreply@localhost.local", + correspondenceUserID: null, + image: null, + instanceId: Snowflake.generate(), + }, + limits: { + user: { + maxGuilds: 1048576, + maxUsername: 127, + maxFriends: 5000, + }, + guild: { + maxRoles: 1000, + maxEmojis: 2000, + maxMembers: 25000000, + maxChannels: 65535, + maxChannelsInCategory: 65535, + hideOfflineMember: 3, + }, + message: { + maxCharacters: 1048576, + maxTTSCharacters: 160, + maxReactions: 2048, + maxAttachmentSize: 1024 * 1024 * 1024, + maxEmbedDownloadSize: 1024 * 1024 * 5, + maxBulkDelete: 1000, + }, + channel: { + maxPins: 500, + maxTopic: 1024, + maxWebhooks: 100, + }, + rate: { + disabled: true, + ip: { + count: 500, + window: 5, + }, + global: { + count: 250, + window: 5, + }, + error: { + count: 10, + window: 5, + }, + routes: { + guild: { + count: 5, + window: 5, + }, + webhook: { + count: 10, + window: 5, + }, + channel: { + count: 10, + window: 5, + }, + auth: { + login: { + count: 5, + window: 60, + }, + register: { + count: 2, + window: 60 * 60 * 12, + }, + }, + }, + }, + }, + security: { + autoUpdate: true, + requestSignature: crypto.randomBytes(32).toString("base64"), + jwtSecret: crypto.randomBytes(256).toString("base64"), + forwadedFor: null, + // forwadedFor: "X-Forwarded-For" // nginx/reverse proxy + // forwadedFor: "CF-Connecting-IP" // cloudflare: + captcha: { + enabled: false, + service: null, + sitekey: null, + secret: null, + }, + ipdataApiKey: "eca677b284b3bac29eb72f5e496aa9047f26543605efe99ff2ce35c9", + defaultRights: "30644591655936", // See util/scripts/rights.js + }, + login: { + requireCaptcha: false, + }, + register: { + email: { + required: false, + allowlist: false, + blocklist: true, + domains: [], // TODO: efficiently save domain blocklist in database + // domains: fs.readFileSync(__dirname + "/blockedEmailDomains.txt", { encoding: "utf8" }).split("\n"), + }, + dateOfBirth: { + required: true, + minimum: 13, + }, + disabled: false, + requireInvite: false, + guestsRequireInvite: true, + requireCaptcha: true, + allowNewRegistration: true, + allowMultipleAccounts: true, + blockProxies: true, + password: { + required: false, + minLength: 8, + minNumbers: 2, + minUpperCase: 2, + minSymbols: 0, + }, + incrementingDiscriminators: false, + }, + regions: { + default: "fosscord", + useDefaultAsOptimal: true, + available: [ + { + id: "fosscord", + name: "Fosscord", + endpoint: "127.0.0.1:3004", + vip: false, + custom: false, + deprecated: false, + }, + ], + }, + guild: { + discovery: { + showAllGuilds: false, + useRecommendation: false, + offset: 0, + limit: 24, + }, + autoJoin: { + enabled: true, + canLeave: true, + guilds: [], + }, + defaultFeatures: [], + }, + gif: { + enabled: true, + provider: "tenor", + apiKey: "LIVDSRZULELA", + }, + rabbitmq: { + host: null, + }, + kafka: { + brokers: null, + }, + templates: { + enabled: true, + allowTemplateCreation: true, + allowDiscordTemplates: true, + allowRaws: false + }, + client: { + useTestClient: true, + releases: { + useLocalRelease: true, + upstreamVersion: "0.0.264" + } + }, + metrics: { + timeout: 30000 + }, + sentry: { + enabled: false, + endpoint: "https://05e8e3d005f34b7d97e920ae5870a5e5@sentry.thearcanebrony.net/6", + traceSampleRate: 1.0, + environment: hostname() + } +}; \ No newline at end of file diff --git a/src/util/entities/ConnectedAccount.ts b/src/util/entities/ConnectedAccount.ts new file mode 100644 index 00000000..09ae30ab --- /dev/null +++ b/src/util/entities/ConnectedAccount.ts @@ -0,0 +1,42 @@ +import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm"; +import { BaseClass } from "./BaseClass"; +import { User } from "./User"; + +export interface PublicConnectedAccount extends Pick {} + +@Entity("connected_accounts") +export class ConnectedAccount extends BaseClass { + @Column({ nullable: true }) + @RelationId((account: ConnectedAccount) => account.user) + user_id: string; + + @JoinColumn({ name: "user_id" }) + @ManyToOne(() => User, { + onDelete: "CASCADE", + }) + user: User; + + @Column({ select: false }) + access_token: string; + + @Column({ select: false }) + friend_sync: boolean; + + @Column() + name: string; + + @Column({ select: false }) + revoked: boolean; + + @Column({ select: false }) + show_activity: boolean; + + @Column() + type: string; + + @Column() + verified: boolean; + + @Column({ select: false }) + visibility: number; +} diff --git a/src/util/entities/Emoji.ts b/src/util/entities/Emoji.ts new file mode 100644 index 00000000..a3615b7d --- /dev/null +++ b/src/util/entities/Emoji.ts @@ -0,0 +1,46 @@ +import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm"; +import { User } from "."; +import { BaseClass } from "./BaseClass"; +import { Guild } from "./Guild"; +import { Role } from "./Role"; + +@Entity("emojis") +export class Emoji extends BaseClass { + @Column() + animated: boolean; + + @Column() + available: boolean; // whether this emoji can be used, may be false due to various reasons + + @Column() + guild_id: string; + + @JoinColumn({ name: "guild_id" }) + @ManyToOne(() => Guild, { + onDelete: "CASCADE", + }) + guild: Guild; + + @Column({ nullable: true }) + @RelationId((emoji: Emoji) => emoji.user) + user_id: string; + + @JoinColumn({ name: "user_id" }) + @ManyToOne(() => User) + user: User; + + @Column() + managed: boolean; + + @Column() + name: string; + + @Column() + require_colons: boolean; + + @Column({ type: "simple-array" }) + roles: string[]; // roles this emoji is whitelisted to (new discord feature?) + + @Column({ type: "simple-array", nullable: true }) + groups: string[]; // user groups this emoji is whitelisted to (Fosscord extension) +} diff --git a/src/util/entities/Encryption.ts b/src/util/entities/Encryption.ts new file mode 100644 index 00000000..b597b90a --- /dev/null +++ b/src/util/entities/Encryption.ts @@ -0,0 +1,35 @@ +import { Column, Entity, JoinColumn, ManyToOne, OneToMany, RelationId } from "typeorm"; +import { BaseClass } from "./BaseClass"; +import { Guild } from "./Guild"; +import { PublicUserProjection, User } from "./User"; +import { HTTPError } from "lambert-server"; +import { containsAll, emitEvent, getPermission, Snowflake, trimSpecial, InvisibleCharacters } from "../util"; +import { BitField, BitFieldResolvable, BitFlag } from "../util/BitField"; +import { Recipient } from "./Recipient"; +import { Message } from "./Message"; +import { ReadState } from "./ReadState"; +import { Invite } from "./Invite"; +import { DmChannelDTO } from "../dtos"; + +@Entity("security_settings") +export class SecuritySettings extends BaseClass { + + @Column({ nullable: true }) + guild_id: string; + + @Column({ nullable: true }) + channel_id: string; + + @Column() + encryption_permission_mask: number; + + @Column({ type: "simple-array" }) + allowed_algorithms: string[]; + + @Column() + current_algorithm: string; + + @Column({ nullable: true }) + used_since_message: string; + +} diff --git a/src/util/entities/Guild.ts b/src/util/entities/Guild.ts new file mode 100644 index 00000000..2ce7c213 --- /dev/null +++ b/src/util/entities/Guild.ts @@ -0,0 +1,363 @@ +import { Column, Entity, JoinColumn, ManyToMany, ManyToOne, OneToMany, OneToOne, RelationId } from "typeorm"; +import { Config, handleFile, Snowflake } from ".."; +import { Ban } from "./Ban"; +import { BaseClass } from "./BaseClass"; +import { Channel } from "./Channel"; +import { Emoji } from "./Emoji"; +import { Invite } from "./Invite"; +import { Member } from "./Member"; +import { Role } from "./Role"; +import { Sticker } from "./Sticker"; +import { Template } from "./Template"; +import { User } from "./User"; +import { VoiceState } from "./VoiceState"; +import { Webhook } from "./Webhook"; + +// TODO: application_command_count, application_command_counts: {1: 0, 2: 0, 3: 0} +// TODO: guild_scheduled_events +// TODO: stage_instances +// TODO: threads +// TODO: +// "keywords": [ +// "Genshin Impact", +// "Paimon", +// "Honkai Impact", +// "ARPG", +// "Open-World", +// "Waifu", +// "Anime", +// "Genshin", +// "miHoYo", +// "Gacha" +// ], + +export const PublicGuildRelations = [ + "channels", + "emojis", + "members", + "roles", + "stickers", + "voice_states", + "members.user", +]; + +@Entity("guilds") +export class Guild extends BaseClass { + @Column({ nullable: true }) + @RelationId((guild: Guild) => guild.afk_channel) + afk_channel_id?: string; + + @JoinColumn({ name: "afk_channel_id" }) + @ManyToOne(() => Channel) + afk_channel?: Channel; + + @Column({ nullable: true }) + afk_timeout?: number; + + // * commented out -> use owner instead + // application id of the guild creator if it is bot-created + // @Column({ nullable: true }) + // application?: string; + + @JoinColumn({ name: "ban_ids" }) + @OneToMany(() => Ban, (ban: Ban) => ban.guild, { + cascade: true, + orphanedRowAction: "delete", + }) + bans: Ban[]; + + @Column({ nullable: true }) + banner?: string; + + @Column({ nullable: true }) + default_message_notifications?: number; + + @Column({ nullable: true }) + description?: string; + + @Column({ nullable: true }) + discovery_splash?: string; + + @Column({ nullable: true }) + explicit_content_filter?: number; + + @Column({ type: "simple-array" }) + features: string[]; //TODO use enum + //TODO: https://discord.com/developers/docs/resources/guild#guild-object-guild-features + + @Column({ nullable: true }) + primary_category_id?: string; // TODO: this was number? + + @Column({ nullable: true }) + icon?: string; + + @Column({ nullable: true }) + large?: boolean; + + @Column({ nullable: true }) + max_members?: number; // e.g. default 100.000 + + @Column({ nullable: true }) + max_presences?: number; + + @Column({ nullable: true }) + max_video_channel_users?: number; // ? default: 25, is this max 25 streaming or watching + + @Column({ nullable: true }) + member_count?: number; + + @Column({ nullable: true }) + presence_count?: number; // users online + + @OneToMany(() => Member, (member: Member) => member.guild, { + cascade: true, + orphanedRowAction: "delete", + onDelete: "CASCADE", + }) + members: Member[]; + + @JoinColumn({ name: "role_ids" }) + @OneToMany(() => Role, (role: Role) => role.guild, { + cascade: true, + orphanedRowAction: "delete", + onDelete: "CASCADE", + }) + roles: Role[]; + + @JoinColumn({ name: "channel_ids" }) + @OneToMany(() => Channel, (channel: Channel) => channel.guild, { + cascade: true, + orphanedRowAction: "delete", + }) + channels: Channel[]; + + @Column({ nullable: true }) + @RelationId((guild: Guild) => guild.template) + template_id?: string; + + @JoinColumn({ name: "template_id", referencedColumnName: "id" }) + @ManyToOne(() => Template) + template: Template; + + @JoinColumn({ name: "emoji_ids" }) + @OneToMany(() => Emoji, (emoji: Emoji) => emoji.guild, { + cascade: true, + orphanedRowAction: "delete", + onDelete: "CASCADE", + }) + emojis: Emoji[]; + + @JoinColumn({ name: "sticker_ids" }) + @OneToMany(() => Sticker, (sticker: Sticker) => sticker.guild, { + cascade: true, + orphanedRowAction: "delete", + onDelete: "CASCADE", + }) + stickers: Sticker[]; + + @JoinColumn({ name: "invite_ids" }) + @OneToMany(() => Invite, (invite: Invite) => invite.guild, { + cascade: true, + orphanedRowAction: "delete", + onDelete: "CASCADE", + }) + invites: Invite[]; + + @JoinColumn({ name: "voice_state_ids" }) + @OneToMany(() => VoiceState, (voicestate: VoiceState) => voicestate.guild, { + cascade: true, + orphanedRowAction: "delete", + onDelete: "CASCADE", + }) + voice_states: VoiceState[]; + + @JoinColumn({ name: "webhook_ids" }) + @OneToMany(() => Webhook, (webhook: Webhook) => webhook.guild, { + cascade: true, + orphanedRowAction: "delete", + onDelete: "CASCADE", + }) + webhooks: Webhook[]; + + @Column({ nullable: true }) + mfa_level?: number; + + @Column() + name: string; + + @Column({ nullable: true }) + @RelationId((guild: Guild) => guild.owner) + owner_id?: string; // optional to allow for ownerless guilds + + @JoinColumn({ name: "owner_id", referencedColumnName: "id" }) + @ManyToOne(() => User) + owner?: User; // optional to allow for ownerless guilds + + @Column({ nullable: true }) + preferred_locale?: string; + + @Column({ nullable: true }) + premium_subscription_count?: number; + + @Column({ nullable: true }) + premium_tier?: number; // crowd premium level + + @Column({ nullable: true }) + @RelationId((guild: Guild) => guild.public_updates_channel) + public_updates_channel_id: string; + + @JoinColumn({ name: "public_updates_channel_id" }) + @ManyToOne(() => Channel) + public_updates_channel?: Channel; + + @Column({ nullable: true }) + @RelationId((guild: Guild) => guild.rules_channel) + rules_channel_id?: string; + + @JoinColumn({ name: "rules_channel_id" }) + @ManyToOne(() => Channel) + rules_channel?: string; + + @Column({ nullable: true }) + region?: string; + + @Column({ nullable: true }) + splash?: string; + + @Column({ nullable: true }) + @RelationId((guild: Guild) => guild.system_channel) + system_channel_id?: string; + + @JoinColumn({ name: "system_channel_id" }) + @ManyToOne(() => Channel) + system_channel?: Channel; + + @Column({ nullable: true }) + system_channel_flags?: number; + + @Column({ nullable: true }) + unavailable?: boolean; + + @Column({ nullable: true }) + verification_level?: number; + + @Column({ type: "simple-json" }) + welcome_screen: { + enabled: boolean; + description: string; + welcome_channels: { + description: string; + emoji_id?: string; + emoji_name?: string; + channel_id: string; + }[]; + }; + + @Column({ nullable: true }) + @RelationId((guild: Guild) => guild.widget_channel) + widget_channel_id?: string; + + @JoinColumn({ name: "widget_channel_id" }) + @ManyToOne(() => Channel) + widget_channel?: Channel; + + @Column({ nullable: true }) + widget_enabled?: boolean; + + @Column({ nullable: true }) + nsfw_level?: number; + + @Column() + nsfw: boolean; + + // TODO: nested guilds + @Column({ nullable: true }) + parent?: string; + + // only for developer portal + permissions?: number; + + static async createGuild(body: { + name?: string; + icon?: string | null; + owner_id?: string; + channels?: Partial[]; + }) { + const guild_id = Snowflake.generate(); + + const guild = await Guild.create({ + name: body.name || "Fosscord", + icon: await handleFile(`/icons/${guild_id}`, body.icon as string), + region: Config.get().regions.default, + owner_id: body.owner_id, // TODO: need to figure out a way for ownerless guilds and multiply-owned guilds + afk_timeout: 300, + default_message_notifications: 1, // defaults effect: setting the push default at mentions-only will save a lot + explicit_content_filter: 0, + features: Config.get().guild.defaultFeatures, + primary_category_id: undefined, + id: guild_id, + max_members: 250000, + max_presences: 250000, + max_video_channel_users: 200, + presence_count: 0, + member_count: 0, // will automatically be increased by addMember() + mfa_level: 0, + preferred_locale: "en-US", + premium_subscription_count: 0, + premium_tier: 0, + system_channel_flags: 4, // defaults effect: suppress the setup tips to save performance + unavailable: false, + nsfw: false, + nsfw_level: 0, + verification_level: 0, + welcome_screen: { + enabled: false, + description: "Fill in your description", + welcome_channels: [], + }, + widget_enabled: true, // NB: don't set it as false to prevent artificial restrictions + }).save(); + + // we have to create the role _after_ the guild because else we would get a "SQLITE_CONSTRAINT: FOREIGN KEY constraint failed" error + // TODO: make the @everyone a pseudorole that is dynamically generated at runtime so we can save storage + await Role.create({ + id: guild_id, + guild_id: guild_id, + color: 0, + hoist: false, + managed: false, + // NB: in Fosscord, every role will be non-managed, as we use user-groups instead of roles for managed groups + mentionable: false, + name: "@everyone", + permissions: String("2251804225"), + position: 0, + icon: undefined, + unicode_emoji: undefined + }).save(); + + if (!body.channels || !body.channels.length) body.channels = [{ id: "01", type: 0, name: "general", nsfw: false }]; + + const ids = new Map(); + + body.channels.forEach((x) => { + if (x.id) { + ids.set(x.id, Snowflake.generate()); + } + }); + + for (const channel of body.channels?.sort((a, b) => (a.parent_id ? 1 : -1))) { + var id = ids.get(channel.id) || Snowflake.generate(); + + var parent_id = ids.get(channel.parent_id); + + await Channel.createChannel({ ...channel, guild_id, id, parent_id }, body.owner_id, { + keepId: true, + skipExistsCheck: true, + skipPermissionCheck: true, + skipEventEmit: true, + }); + } + + return guild; + } +} diff --git a/src/util/entities/Invite.ts b/src/util/entities/Invite.ts new file mode 100644 index 00000000..4f36f247 --- /dev/null +++ b/src/util/entities/Invite.ts @@ -0,0 +1,85 @@ +import { Column, Entity, JoinColumn, ManyToOne, RelationId, PrimaryColumn } from "typeorm"; +import { Member } from "./Member"; +import { BaseClassWithoutId } from "./BaseClass"; +import { Channel } from "./Channel"; +import { Guild } from "./Guild"; +import { User } from "./User"; + +export const PublicInviteRelation = ["inviter", "guild", "channel"]; + +@Entity("invites") +export class Invite extends BaseClassWithoutId { + @PrimaryColumn() + code: string; + + @Column() + temporary: boolean; + + @Column() + uses: number; + + @Column() + max_uses: number; + + @Column() + max_age: number; + + @Column() + created_at: Date; + + @Column() + expires_at: Date; + + @Column({ nullable: true }) + @RelationId((invite: Invite) => invite.guild) + guild_id: string; + + @JoinColumn({ name: "guild_id" }) + @ManyToOne(() => Guild, { + onDelete: "CASCADE", + }) + guild: Guild; + + @Column({ nullable: true }) + @RelationId((invite: Invite) => invite.channel) + channel_id: string; + + @JoinColumn({ name: "channel_id" }) + @ManyToOne(() => Channel, { + onDelete: "CASCADE", + }) + channel: Channel; + + @Column({ nullable: true }) + @RelationId((invite: Invite) => invite.inviter) + inviter_id?: string; + + @JoinColumn({ name: "inviter_id" }) + @ManyToOne(() => User) + inviter: User; + + @Column({ nullable: true }) + @RelationId((invite: Invite) => invite.target_user) + target_user_id: string; + + @JoinColumn({ name: "target_user_id" }) + @ManyToOne(() => User, { + onDelete: "CASCADE", + }) + target_user?: string; // could be used for "User specific invites" https://github.com/fosscord/fosscord/issues/62 + + @Column({ nullable: true }) + target_user_type?: number; + + @Column({ nullable: true }) + vanity_url?: boolean; + + static async joinGuild(user_id: string, code: string) { + const invite = await Invite.findOneOrFail({ where: { code } }); + if (invite.uses++ >= invite.max_uses && invite.max_uses !== 0) await Invite.delete({ code }); + else await invite.save(); + + await Member.addToGuild(user_id, invite.guild_id); + return invite; + } +} diff --git a/src/util/entities/Member.ts b/src/util/entities/Member.ts new file mode 100644 index 00000000..d7bcefea --- /dev/null +++ b/src/util/entities/Member.ts @@ -0,0 +1,430 @@ +import { PublicUser, User } from "./User"; +import { Message } from "./Message"; +import { + BeforeInsert, + BeforeUpdate, + Column, + Entity, + Index, + JoinColumn, + JoinTable, + ManyToMany, + ManyToOne, + PrimaryGeneratedColumn, + RelationId, +} from "typeorm"; +import { Guild } from "./Guild"; +import { Config, emitEvent, BannedWords, FieldErrors } from "../util"; +import { + GuildCreateEvent, + GuildDeleteEvent, + GuildMemberAddEvent, + GuildMemberRemoveEvent, + GuildMemberUpdateEvent, + MessageCreateEvent, + +} from "../interfaces"; +import { HTTPError } from "lambert-server"; +import { Role } from "./Role"; +import { BaseClassWithoutId } from "./BaseClass"; +import { Ban, PublicGuildRelations } from "."; +import { DiscordApiErrors } from "../util/Constants"; + +export const MemberPrivateProjection: (keyof Member)[] = [ + "id", + "guild", + "guild_id", + "deaf", + "joined_at", + "last_message_id", + "mute", + "nick", + "pending", + "premium_since", + "roles", + "settings", + "user", +]; + +@Entity("members") +@Index(["id", "guild_id"], { unique: true }) +export class Member extends BaseClassWithoutId { + @PrimaryGeneratedColumn() + index: string; + + @Column() + @RelationId((member: Member) => member.user) + id: string; + + @JoinColumn({ name: "id" }) + @ManyToOne(() => User, { + onDelete: "CASCADE", + }) + user: User; + + @Column() + @RelationId((member: Member) => member.guild) + guild_id: string; + + @JoinColumn({ name: "guild_id" }) + @ManyToOne(() => Guild, { + onDelete: "CASCADE", + }) + guild: Guild; + + @Column({ nullable: true }) + nick?: string; + + @JoinTable({ + name: "member_roles", + joinColumn: { name: "index", referencedColumnName: "index" }, + inverseJoinColumn: { + name: "role_id", + referencedColumnName: "id", + }, + }) + @ManyToMany(() => Role, { cascade: true }) + roles: Role[]; + + @Column() + joined_at: Date; + + @Column({ type: "bigint", nullable: true }) + premium_since?: number; + + @Column() + deaf: boolean; + + @Column() + mute: boolean; + + @Column() + pending: boolean; + + @Column({ type: "simple-json", select: false }) + settings: UserGuildSettings; + + @Column({ nullable: true }) + last_message_id?: string; + + /** + @JoinColumn({ name: "id" }) + @ManyToOne(() => User, { + onDelete: "DO NOTHING", + // do not auto-kick force-joined members just because their joiners left the server + }) **/ + @Column({ nullable: true }) + joined_by?: string; + + // TODO: add this when we have proper read receipts + // @Column({ type: "simple-json" }) + // read_state: ReadState; + + @BeforeUpdate() + @BeforeInsert() + validate() { + if (this.nick) { + this.nick = this.nick.split("\n").join(""); + this.nick = this.nick.split("\t").join(""); + if (BannedWords.find(this.nick)) throw FieldErrors({ nick: { message: "Bad nickname", code: "INVALID_NICKNAME" } }); + } + } + + static async IsInGuildOrFail(user_id: string, guild_id: string) { + if (await Member.count({ where: { id: user_id, guild: { id: guild_id } } })) return true; + throw new HTTPError("You are not member of this guild", 403); + } + + static async removeFromGuild(user_id: string, guild_id: string) { + const guild = await Guild.findOneOrFail({ select: ["owner_id"], where: { id: guild_id } }); + if (guild.owner_id === user_id) throw new Error("The owner cannot be removed of the guild"); + const member = await Member.findOneOrFail({ where: { id: user_id, guild_id }, relations: ["user"] }); + + // use promise all to execute all promises at the same time -> save time + return Promise.all([ + Member.delete({ + id: user_id, + guild_id, + }), + Guild.decrement({ id: guild_id }, "member_count", -1), + + emitEvent({ + event: "GUILD_DELETE", + data: { + id: guild_id, + }, + user_id: user_id, + } as GuildDeleteEvent), + emitEvent({ + event: "GUILD_MEMBER_REMOVE", + data: { guild_id, user: member.user }, + guild_id, + } as GuildMemberRemoveEvent), + ]); + } + + static async addRole(user_id: string, guild_id: string, role_id: string) { + const [member, role] = await Promise.all([ + // @ts-ignore + Member.findOneOrFail({ + where: { id: user_id, guild_id }, + relations: ["user", "roles"], // we don't want to load the role objects just the ids + //@ts-ignore + select: ["index", "roles.id"], // TODO fix type + }), + Role.findOneOrFail({ where: { id: role_id, guild_id }, select: ["id"] }), + ]); + member.roles.push(Role.create({ id: role_id })); + + await Promise.all([ + member.save(), + emitEvent({ + event: "GUILD_MEMBER_UPDATE", + data: { + guild_id, + user: member.user, + roles: member.roles.map((x) => x.id), + }, + guild_id, + } as GuildMemberUpdateEvent), + ]); + } + + static async removeRole(user_id: string, guild_id: string, role_id: string) { + const [member] = await Promise.all([ + // @ts-ignore + Member.findOneOrFail({ + where: { id: user_id, guild_id }, + relations: ["user", "roles"], // we don't want to load the role objects just the ids + //@ts-ignore + select: ["roles.id", "index"], // TODO: fix type + }), + await Role.findOneOrFail({ where: { id: role_id, guild_id } }), + ]); + member.roles = member.roles.filter((x) => x.id == role_id); + + await Promise.all([ + member.save(), + emitEvent({ + event: "GUILD_MEMBER_UPDATE", + data: { + guild_id, + user: member.user, + roles: member.roles.map((x) => x.id), + }, + guild_id, + } as GuildMemberUpdateEvent), + ]); + } + + static async changeNickname(user_id: string, guild_id: string, nickname: string) { + const member = await Member.findOneOrFail({ + where: { + id: user_id, + guild_id, + }, + relations: ["user"], + }); + member.nick = nickname; + + await Promise.all([ + member.save(), + + emitEvent({ + event: "GUILD_MEMBER_UPDATE", + data: { + guild_id, + user: member.user, + nick: nickname, + }, + guild_id, + } as GuildMemberUpdateEvent), + ]); + } + + static async addToGuild(user_id: string, guild_id: string) { + const user = await User.getPublicUser(user_id); + const isBanned = await Ban.count({ where: { guild_id, user_id } }); + if (isBanned) { + throw DiscordApiErrors.USER_BANNED; + } + const { maxGuilds } = Config.get().limits.user; + const guild_count = await Member.count({ where: { id: user_id } }); + if (guild_count >= maxGuilds) { + throw new HTTPError(`You are at the ${maxGuilds} server limit.`, 403); + } + + const guild = await Guild.findOneOrFail({ + where: { + id: guild_id, + }, + relations: [...PublicGuildRelations, "system_channel"], + }); + + if (await Member.count({ where: { id: user.id, guild: { id: guild_id } } })) + throw new HTTPError("You are already a member of this guild", 400); + + const member = { + id: user_id, + guild_id, + nick: undefined, + roles: [guild_id], // @everyone role + joined_at: new Date(), + premium_since: (new Date()).getTime(), + deaf: false, + mute: false, + pending: false, + }; + + await Promise.all([ + Member.create({ + ...member, + roles: [Role.create({ id: guild_id })], + // read_state: {}, + settings: { + guild_id: null, + mute_config: null, + mute_scheduled_events: false, + flags: 0, + hide_muted_channels: false, + notify_highlights: 0, + channel_overrides: {}, + message_notifications: 0, + mobile_push: true, + muted: false, + suppress_everyone: false, + suppress_roles: false, + version: 0, + }, + // Member.save is needed because else the roles relations wouldn't be updated + }).save(), + Guild.increment({ id: guild_id }, "member_count", 1), + emitEvent({ + event: "GUILD_MEMBER_ADD", + data: { + ...member, + user, + guild_id, + }, + guild_id, + } as GuildMemberAddEvent), + emitEvent({ + event: "GUILD_CREATE", + data: { + ...guild, + members: [...guild.members, { ...member, user }], + member_count: (guild.member_count || 0) + 1, + guild_hashes: {}, + guild_scheduled_events: [], + joined_at: member.joined_at, + presences: [], + stage_instances: [], + threads: [], + }, + user_id, + } as GuildCreateEvent), + ]); + + if (guild.system_channel_id) { + // send welcome message + const message = Message.create({ + type: 7, + guild_id: guild.id, + channel_id: guild.system_channel_id, + author: user, + timestamp: new Date(), + reactions: [], + attachments: [], + embeds: [], + sticker_items: [], + edited_timestamp: undefined, + }); + await Promise.all([ + message.save(), + emitEvent({ event: "MESSAGE_CREATE", channel_id: message.channel_id, data: message } as MessageCreateEvent) + ]); + } + } +} + +export interface ChannelOverride { + message_notifications: number; + mute_config: MuteConfig; + muted: boolean; + channel_id: string | null; +} + +export interface UserGuildSettings { + // channel_overrides: { + // channel_id: string; + // message_notifications: number; + // mute_config: MuteConfig; + // muted: boolean; + // }[]; + + channel_overrides: { + [channel_id: string]: ChannelOverride; + } | null, + message_notifications: number; + mobile_push: boolean; + mute_config: MuteConfig | null; + muted: boolean; + suppress_everyone: boolean; + suppress_roles: boolean; + version: number; + guild_id: string | null; + flags: number; + mute_scheduled_events: boolean; + hide_muted_channels: boolean; + notify_highlights: 0; +} + +export const DefaultUserGuildSettings: UserGuildSettings = { + channel_overrides: null, + message_notifications: 1, + flags: 0, + hide_muted_channels: false, + mobile_push: true, + mute_config: null, + mute_scheduled_events: false, + muted: false, + notify_highlights: 0, + suppress_everyone: false, + suppress_roles: false, + version: 453, // ? + guild_id: null, +}; + +export interface MuteConfig { + end_time: number; + selected_time_window: number; +} + +export type PublicMemberKeys = + | "id" + | "guild_id" + | "nick" + | "roles" + | "joined_at" + | "pending" + | "deaf" + | "mute" + | "premium_since"; + +export const PublicMemberProjection: PublicMemberKeys[] = [ + "id", + "guild_id", + "nick", + "roles", + "joined_at", + "pending", + "deaf", + "mute", + "premium_since", +]; + +// @ts-ignore +export type PublicMember = Pick> & { + user: PublicUser; + roles: string[]; // only role ids not objects +}; diff --git a/src/util/entities/Message.ts b/src/util/entities/Message.ts new file mode 100644 index 00000000..3a3dd5e4 --- /dev/null +++ b/src/util/entities/Message.ts @@ -0,0 +1,296 @@ +import { User } from "./User"; +import { Member } from "./Member"; +import { Role } from "./Role"; +import { Channel } from "./Channel"; +import { InteractionType } from "../interfaces/Interaction"; +import { Application } from "./Application"; +import { + BeforeInsert, + BeforeUpdate, + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + JoinTable, + ManyToMany, + ManyToOne, + OneToMany, + RelationId, +} from "typeorm"; +import { BaseClass } from "./BaseClass"; +import { Guild } from "./Guild"; +import { Webhook } from "./Webhook"; +import { Sticker } from "./Sticker"; +import { Attachment } from "./Attachment"; +import { BannedWords } from "../util"; +import { HTTPError } from "lambert-server"; +import { ValidatorConstraint } from "class-validator"; + +export enum MessageType { + DEFAULT = 0, + RECIPIENT_ADD = 1, + RECIPIENT_REMOVE = 2, + CALL = 3, + CHANNEL_NAME_CHANGE = 4, + CHANNEL_ICON_CHANGE = 5, + CHANNEL_PINNED_MESSAGE = 6, + GUILD_MEMBER_JOIN = 7, + USER_PREMIUM_GUILD_SUBSCRIPTION = 8, + USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_1 = 9, + USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_2 = 10, + USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_3 = 11, + CHANNEL_FOLLOW_ADD = 12, + ACTION = 13, // /me messages + GUILD_DISCOVERY_DISQUALIFIED = 14, + GUILD_DISCOVERY_REQUALIFIED = 15, + ENCRYPTED = 16, + REPLY = 19, + APPLICATION_COMMAND = 20, // application command or self command invocation + ROUTE_ADDED = 41, // custom message routing: new route affecting that channel + ROUTE_DISABLED = 42, // custom message routing: given route no longer affecting that channel + SELF_COMMAND_SCRIPT = 43, // self command scripts + ENCRYPTION = 50, + CUSTOM_START = 63, + UNHANDLED = 255 +} + +@Entity("messages") +@Index(["channel_id", "id"], { unique: true }) +export class Message extends BaseClass { + @Column({ nullable: true }) + @RelationId((message: Message) => message.channel) + @Index() + channel_id?: string; + + @JoinColumn({ name: "channel_id" }) + @ManyToOne(() => Channel, { + onDelete: "CASCADE", + }) + channel: Channel; + + @Column({ nullable: true }) + @RelationId((message: Message) => message.guild) + guild_id?: string; + + @JoinColumn({ name: "guild_id" }) + @ManyToOne(() => Guild, { + onDelete: "CASCADE", + }) + guild?: Guild; + + @Column({ nullable: true }) + @RelationId((message: Message) => message.author) + @Index() + author_id?: string; + + @JoinColumn({ name: "author_id", referencedColumnName: "id" }) + @ManyToOne(() => User, { + onDelete: "CASCADE", + }) + author?: User; + + @Column({ nullable: true }) + @RelationId((message: Message) => message.member) + member_id?: string; + + @JoinColumn({ name: "member_id", referencedColumnName: "id" }) + @ManyToOne(() => User, { + onDelete: "CASCADE", + }) + member?: Member; + + @Column({ nullable: true }) + @RelationId((message: Message) => message.webhook) + webhook_id?: string; + + @JoinColumn({ name: "webhook_id" }) + @ManyToOne(() => Webhook) + webhook?: Webhook; + + @Column({ nullable: true }) + @RelationId((message: Message) => message.application) + application_id?: string; + + @JoinColumn({ name: "application_id" }) + @ManyToOne(() => Application) + application?: Application; + + @Column({ nullable: true, type: process.env.PRODUCTION ? "longtext" : undefined }) + content?: string; + + @Column() + @CreateDateColumn() + timestamp: Date; + + @Column({ nullable: true }) + edited_timestamp?: Date; + + @Column({ nullable: true }) + tts?: boolean; + + @Column({ nullable: true }) + mention_everyone?: boolean; + + @JoinTable({ name: "message_user_mentions" }) + @ManyToMany(() => User) + mentions: User[]; + + @JoinTable({ name: "message_role_mentions" }) + @ManyToMany(() => Role) + mention_roles: Role[]; + + @JoinTable({ name: "message_channel_mentions" }) + @ManyToMany(() => Channel) + mention_channels: Channel[]; + + @JoinTable({ name: "message_stickers" }) + @ManyToMany(() => Sticker, { cascade: true, onDelete: "CASCADE" }) + sticker_items?: Sticker[]; + + @OneToMany(() => Attachment, (attachment: Attachment) => attachment.message, { + cascade: true, + orphanedRowAction: "delete", + }) + attachments?: Attachment[]; + + @Column({ type: "simple-json" }) + embeds: Embed[]; + + @Column({ type: "simple-json" }) + reactions: Reaction[]; + + @Column({ type: "text", nullable: true }) + nonce?: string; + + @Column({ nullable: true }) + pinned?: boolean; + + @Column({ type: "int" }) + type: MessageType; + + @Column({ type: "simple-json", nullable: true }) + activity?: { + type: number; + party_id: string; + }; + + @Column({ nullable: true }) + flags?: string; + + @Column({ type: "simple-json", nullable: true }) + message_reference?: { + message_id: string; + channel_id?: string; + guild_id?: string; + }; + + @JoinColumn({ name: "message_reference_id" }) + @ManyToOne(() => Message) + referenced_message?: Message; + + @Column({ type: "simple-json", nullable: true }) + interaction?: { + id: string; + type: InteractionType; + name: string; + user_id: string; // the user who invoked the interaction + // user: User; // TODO: autopopulate user + }; + + @Column({ type: "simple-json", nullable: true }) + components?: MessageComponent[]; + + @BeforeUpdate() + @BeforeInsert() + validate() { + if (this.content) { + if (BannedWords.find(this.content)) throw new HTTPError("Message was blocked by automatic moderation", 200000); + } + } +} + +export interface MessageComponent { + type: number; + style?: number; + label?: string; + emoji?: PartialEmoji; + custom_id?: string; + url?: string; + disabled?: boolean; + components: MessageComponent[]; +} + +export enum MessageComponentType { + Script = 0, // self command script + ActionRow = 1, + Button = 2, +} + +export interface Embed { + title?: string; //title of embed + type?: EmbedType; // type of embed (always "rich" for webhook embeds) + description?: string; // description of embed + url?: string; // url of embed + timestamp?: Date; // timestamp of embed content + color?: number; // color code of the embed + footer?: { + text: string; + icon_url?: string; + proxy_icon_url?: string; + }; // footer object footer information + image?: EmbedImage; // image object image information + thumbnail?: EmbedImage; // thumbnail object thumbnail information + video?: EmbedImage; // video object video information + provider?: { + name?: string; + url?: string; + }; // provider object provider information + author?: { + name?: string; + url?: string; + icon_url?: string; + proxy_icon_url?: string; + }; // author object author information + fields?: { + name: string; + value: string; + inline?: boolean; + }[]; +} + +export enum EmbedType { + rich = "rich", + image = "image", + video = "video", + gifv = "gifv", + article = "article", + link = "link", +} + +export interface EmbedImage { + url?: string; + proxy_url?: string; + height?: number; + width?: number; +} + +export interface Reaction { + count: number; + //// not saved in the database // me: boolean; // whether the current user reacted using this emoji + emoji: PartialEmoji; + user_ids: string[]; +} + +export interface PartialEmoji { + id?: string; + name: string; + animated?: boolean; +} + +export interface AllowedMentions { + parse?: ("users" | "roles" | "everyone")[]; + roles?: string[]; + users?: string[]; + replied_user?: boolean; +} diff --git a/src/util/entities/Migration.ts b/src/util/entities/Migration.ts new file mode 100644 index 00000000..3f39ae72 --- /dev/null +++ b/src/util/entities/Migration.ts @@ -0,0 +1,18 @@ +import { Column, Entity, ObjectIdColumn, PrimaryGeneratedColumn } from "typeorm"; +import { BaseClassWithoutId } from "."; + +export const PrimaryIdAutoGenerated = process.env.DATABASE?.startsWith("mongodb") + ? ObjectIdColumn + : PrimaryGeneratedColumn; + +@Entity("migrations") +export class Migration extends BaseClassWithoutId { + @PrimaryIdAutoGenerated() + id: number; + + @Column({ type: "bigint" }) + timestamp: number; + + @Column() + name: string; +} diff --git a/src/util/entities/Note.ts b/src/util/entities/Note.ts new file mode 100644 index 00000000..36017c5e --- /dev/null +++ b/src/util/entities/Note.ts @@ -0,0 +1,18 @@ +import { Column, Entity, JoinColumn, ManyToOne, Unique } from "typeorm"; +import { BaseClass } from "./BaseClass"; +import { User } from "./User"; + +@Entity("notes") +@Unique(["owner", "target"]) +export class Note extends BaseClass { + @JoinColumn({ name: "owner_id" }) + @ManyToOne(() => User, { onDelete: "CASCADE" }) + owner: User; + + @JoinColumn({ name: "target_id" }) + @ManyToOne(() => User, { onDelete: "CASCADE" }) + target: User; + + @Column() + content: string; +} \ No newline at end of file diff --git a/src/util/entities/RateLimit.ts b/src/util/entities/RateLimit.ts new file mode 100644 index 00000000..f5916f6b --- /dev/null +++ b/src/util/entities/RateLimit.ts @@ -0,0 +1,17 @@ +import { Column, Entity } from "typeorm"; +import { BaseClass } from "./BaseClass"; + +@Entity("rate_limits") +export class RateLimit extends BaseClass { + @Column() // no relation as it also + executor_id: string; + + @Column() + hits: number; + + @Column() + blocked: boolean; + + @Column() + expires_at: Date; +} diff --git a/src/util/entities/ReadState.ts b/src/util/entities/ReadState.ts new file mode 100644 index 00000000..b915573b --- /dev/null +++ b/src/util/entities/ReadState.ts @@ -0,0 +1,55 @@ +import { Column, Entity, Index, JoinColumn, ManyToOne, RelationId } from "typeorm"; +import { BaseClass } from "./BaseClass"; +import { Channel } from "./Channel"; +import { Message } from "./Message"; +import { User } from "./User"; + +// for read receipts +// notification cursor and public read receipt need to be forwards-only (the former to prevent re-pinging when marked as unread, and the latter to be acceptable as a legal acknowledgement in criminal proceedings), and private read marker needs to be advance-rewind capable +// public read receipt ≥ notification cursor ≥ private fully read marker + +@Entity("read_states") +@Index(["channel_id", "user_id"], { unique: true }) +export class ReadState extends BaseClass { + @Column() + @RelationId((read_state: ReadState) => read_state.channel) + channel_id: string; + + @JoinColumn({ name: "channel_id" }) + @ManyToOne(() => Channel, { + onDelete: "CASCADE", + }) + channel: Channel; + + @Column() + @RelationId((read_state: ReadState) => read_state.user) + user_id: string; + + @JoinColumn({ name: "user_id" }) + @ManyToOne(() => User, { + onDelete: "CASCADE", + }) + user: User; + + // fully read marker + @Column({ nullable: true }) + last_message_id: string; + + // public read receipt + @Column({ nullable: true }) + public_ack: string; + + // notification cursor / private read receipt + @Column({ nullable: true }) + notifications_cursor: string; + + @Column({ nullable: true }) + last_pin_timestamp?: Date; + + @Column({ nullable: true }) + mention_count: number; + + // @Column({ nullable: true }) + // TODO: derive this from (last_message_id=notifications_cursor=public_ack)=true + manual: boolean; +} diff --git a/src/util/entities/Recipient.ts b/src/util/entities/Recipient.ts new file mode 100644 index 00000000..a945f938 --- /dev/null +++ b/src/util/entities/Recipient.ts @@ -0,0 +1,30 @@ +import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm"; +import { BaseClass } from "./BaseClass"; + +@Entity("recipients") +export class Recipient extends BaseClass { + @Column() + @RelationId((recipient: Recipient) => recipient.channel) + channel_id: string; + + @JoinColumn({ name: "channel_id" }) + @ManyToOne(() => require("./Channel").Channel, { + onDelete: "CASCADE", + }) + channel: import("./Channel").Channel; + + @Column() + @RelationId((recipient: Recipient) => recipient.user) + user_id: string; + + @JoinColumn({ name: "user_id" }) + @ManyToOne(() => require("./User").User, { + onDelete: "CASCADE", + }) + user: import("./User").User; + + @Column({ default: false }) + closed: boolean; + + // TODO: settings/mute/nick/added at/encryption keys/read_state +} diff --git a/src/util/entities/Relationship.ts b/src/util/entities/Relationship.ts new file mode 100644 index 00000000..c3592c76 --- /dev/null +++ b/src/util/entities/Relationship.ts @@ -0,0 +1,49 @@ +import { Column, Entity, Index, JoinColumn, ManyToOne, RelationId } from "typeorm"; +import { BaseClass } from "./BaseClass"; +import { User } from "./User"; + +export enum RelationshipType { + outgoing = 4, + incoming = 3, + blocked = 2, + friends = 1, +} + +@Entity("relationships") +@Index(["from_id", "to_id"], { unique: true }) +export class Relationship extends BaseClass { + @Column({}) + @RelationId((relationship: Relationship) => relationship.from) + from_id: string; + + @JoinColumn({ name: "from_id" }) + @ManyToOne(() => User, { + onDelete: "CASCADE", + }) + from: User; + + @Column({}) + @RelationId((relationship: Relationship) => relationship.to) + to_id: string; + + @JoinColumn({ name: "to_id" }) + @ManyToOne(() => User, { + onDelete: "CASCADE", + }) + to: User; + + @Column({ nullable: true }) + nickname?: string; + + @Column({ type: "int" }) + type: RelationshipType; + + toPublicRelationship() { + return { + id: this.to?.id || this.to_id, + type: this.type, + nickname: this.nickname, + user: this.to?.toPublicUser(), + }; + } +} diff --git a/src/util/entities/Role.ts b/src/util/entities/Role.ts new file mode 100644 index 00000000..d87b835f --- /dev/null +++ b/src/util/entities/Role.ts @@ -0,0 +1,51 @@ +import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm"; + +import { BaseClass } from "./BaseClass"; +import { Guild } from "./Guild"; + +@Entity("roles") +export class Role extends BaseClass { + @Column({ nullable: true }) + @RelationId((role: Role) => role.guild) + guild_id: string; + + @JoinColumn({ name: "guild_id" }) + @ManyToOne(() => Guild, { + onDelete: "CASCADE", + }) + guild: Guild; + + @Column() + color: number; + + @Column() + hoist: boolean; + + @Column() + managed: boolean; + + @Column() + mentionable: boolean; + + @Column() + name: string; + + @Column() + permissions: string; + + @Column() + position: number; + + @Column({ nullable: true }) + icon?: string; + + @Column({ nullable: true }) + unicode_emoji?: string; + + @Column({ type: "simple-json", nullable: true }) + tags?: { + bot_id?: string; + integration_id?: string; + premium_subscriber?: boolean; + }; +} diff --git a/src/util/entities/Session.ts b/src/util/entities/Session.ts new file mode 100644 index 00000000..969efa89 --- /dev/null +++ b/src/util/entities/Session.ts @@ -0,0 +1,46 @@ +import { User } from "./User"; +import { BaseClass } from "./BaseClass"; +import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm"; +import { Status } from "../interfaces/Status"; +import { Activity } from "../interfaces/Activity"; + +//TODO we need to remove all sessions on server start because if the server crashes without closing websockets it won't delete them + +@Entity("sessions") +export class Session extends BaseClass { + @Column({ nullable: true }) + @RelationId((session: Session) => session.user) + user_id: string; + + @JoinColumn({ name: "user_id" }) + @ManyToOne(() => User, { + onDelete: "CASCADE", + }) + user: User; + + //TODO check, should be 32 char long hex string + @Column({ nullable: false, select: false }) + session_id: string; + + @Column({ type: "simple-json", nullable: true }) + activities: Activity[]; + + // TODO client_status + @Column({ type: "simple-json", select: false }) + client_info: { + client: string; + os: string; + version: number; + }; + + @Column({ nullable: false, type: "varchar" }) + status: Status; //TODO enum +} + +export const PrivateSessionProjection: (keyof Session)[] = [ + "user_id", + "session_id", + "activities", + "client_info", + "status", +]; diff --git a/src/util/entities/Sticker.ts b/src/util/entities/Sticker.ts new file mode 100644 index 00000000..37bc6fbe --- /dev/null +++ b/src/util/entities/Sticker.ts @@ -0,0 +1,66 @@ +import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm"; +import { User } from "./User"; +import { BaseClass } from "./BaseClass"; +import { Guild } from "./Guild"; + +export enum StickerType { + STANDARD = 1, + GUILD = 2, +} + +export enum StickerFormatType { + GIF = 0, // gif is a custom format type and not in discord spec + PNG = 1, + APNG = 2, + LOTTIE = 3, +} + +@Entity("stickers") +export class Sticker extends BaseClass { + @Column() + name: string; + + @Column({ nullable: true }) + description?: string; + + @Column({ nullable: true }) + available?: boolean; + + @Column({ nullable: true }) + tags?: string; + + @Column({ nullable: true }) + @RelationId((sticker: Sticker) => sticker.pack) + pack_id?: string; + + @JoinColumn({ name: "pack_id" }) + @ManyToOne(() => require("./StickerPack").StickerPack, { + onDelete: "CASCADE", + nullable: true, + }) + pack: import("./StickerPack").StickerPack; + + @Column({ nullable: true }) + guild_id?: string; + + @JoinColumn({ name: "guild_id" }) + @ManyToOne(() => Guild, { + onDelete: "CASCADE", + }) + guild?: Guild; + + @Column({ nullable: true }) + user_id?: string; + + @JoinColumn({ name: "user_id" }) + @ManyToOne(() => User, { + onDelete: "CASCADE", + }) + user?: User; + + @Column({ type: "int" }) + type: StickerType; + + @Column({ type: "int" }) + format_type: StickerFormatType; +} diff --git a/src/util/entities/StickerPack.ts b/src/util/entities/StickerPack.ts new file mode 100644 index 00000000..ec8c69a2 --- /dev/null +++ b/src/util/entities/StickerPack.ts @@ -0,0 +1,31 @@ +import { Column, Entity, JoinColumn, ManyToOne, OneToMany, OneToOne, RelationId } from "typeorm"; +import { Sticker } from "."; +import { BaseClass } from "./BaseClass"; + +@Entity("sticker_packs") +export class StickerPack extends BaseClass { + @Column() + name: string; + + @Column({ nullable: true }) + description?: string; + + @Column({ nullable: true }) + banner_asset_id?: string; + + @OneToMany(() => Sticker, (sticker: Sticker) => sticker.pack, { + cascade: true, + orphanedRowAction: "delete", + }) + stickers: Sticker[]; + + // sku_id: string + + @Column({ nullable: true }) + @RelationId((pack: StickerPack) => pack.cover_sticker) + cover_sticker_id?: string; + + @ManyToOne(() => Sticker, { nullable: true }) + @JoinColumn() + cover_sticker?: Sticker; +} diff --git a/src/util/entities/Team.ts b/src/util/entities/Team.ts new file mode 100644 index 00000000..22140b7f --- /dev/null +++ b/src/util/entities/Team.ts @@ -0,0 +1,27 @@ +import { Column, Entity, JoinColumn, ManyToMany, ManyToOne, OneToMany, RelationId } from "typeorm"; +import { BaseClass } from "./BaseClass"; +import { TeamMember } from "./TeamMember"; +import { User } from "./User"; + +@Entity("teams") +export class Team extends BaseClass { + @Column({ nullable: true }) + icon?: string; + + @JoinColumn({ name: "member_ids" }) + @OneToMany(() => TeamMember, (member: TeamMember) => member.team, { + orphanedRowAction: "delete", + }) + members: TeamMember[]; + + @Column() + name: string; + + @Column({ nullable: true }) + @RelationId((team: Team) => team.owner_user) + owner_user_id: string; + + @JoinColumn({ name: "owner_user_id" }) + @ManyToOne(() => User) + owner_user: User; +} diff --git a/src/util/entities/TeamMember.ts b/src/util/entities/TeamMember.ts new file mode 100644 index 00000000..b726e1e8 --- /dev/null +++ b/src/util/entities/TeamMember.ts @@ -0,0 +1,37 @@ +import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm"; +import { BaseClass } from "./BaseClass"; +import { User } from "./User"; + +export enum TeamMemberState { + INVITED = 1, + ACCEPTED = 2, +} + +@Entity("team_members") +export class TeamMember extends BaseClass { + @Column({ type: "int" }) + membership_state: TeamMemberState; + + @Column({ type: "simple-array" }) + permissions: string[]; + + @Column({ nullable: true }) + @RelationId((member: TeamMember) => member.team) + team_id: string; + + @JoinColumn({ name: "team_id" }) + @ManyToOne(() => require("./Team").Team, (team: import("./Team").Team) => team.members, { + onDelete: "CASCADE", + }) + team: import("./Team").Team; + + @Column({ nullable: true }) + @RelationId((member: TeamMember) => member.user) + user_id: string; + + @JoinColumn({ name: "user_id" }) + @ManyToOne(() => User, { + onDelete: "CASCADE", + }) + user: User; +} diff --git a/src/util/entities/Template.ts b/src/util/entities/Template.ts new file mode 100644 index 00000000..1d952283 --- /dev/null +++ b/src/util/entities/Template.ts @@ -0,0 +1,44 @@ +import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm"; +import { BaseClass } from "./BaseClass"; +import { Guild } from "./Guild"; +import { User } from "./User"; + +@Entity("templates") +export class Template extends BaseClass { + @Column({ unique: true }) + code: string; + + @Column() + name: string; + + @Column({ nullable: true }) + description?: string; + + @Column({ nullable: true }) + usage_count?: number; + + @Column({ nullable: true }) + @RelationId((template: Template) => template.creator) + creator_id: string; + + @JoinColumn({ name: "creator_id" }) + @ManyToOne(() => User) + creator: User; + + @Column() + created_at: Date; + + @Column() + updated_at: Date; + + @Column({ nullable: true }) + @RelationId((template: Template) => template.source_guild) + source_guild_id: string; + + @JoinColumn({ name: "source_guild_id" }) + @ManyToOne(() => Guild) + source_guild: Guild; + + @Column({ type: "simple-json" }) + serialized_source_guild: Guild; +} diff --git a/src/util/entities/User.ts b/src/util/entities/User.ts new file mode 100644 index 00000000..84a8a674 --- /dev/null +++ b/src/util/entities/User.ts @@ -0,0 +1,430 @@ +import { BeforeInsert, BeforeUpdate, Column, Entity, FindOneOptions, JoinColumn, OneToMany } from "typeorm"; +import { BaseClass } from "./BaseClass"; +import { BitField } from "../util/BitField"; +import { Relationship } from "./Relationship"; +import { ConnectedAccount } from "./ConnectedAccount"; +import { Config, FieldErrors, Snowflake, trimSpecial, BannedWords, adjustEmail } from ".."; +import { Member, Session } from "."; + +export enum PublicUserEnum { + username, + discriminator, + id, + public_flags, + avatar, + accent_color, + banner, + bio, + bot, + premium_since, +} +export type PublicUserKeys = keyof typeof PublicUserEnum; + +export enum PrivateUserEnum { + flags, + mfa_enabled, + email, + phone, + verified, + nsfw_allowed, + premium, + premium_type, + purchased_flags, + premium_usage_flags, + disabled, + settings, + // locale +} +export type PrivateUserKeys = keyof typeof PrivateUserEnum | PublicUserKeys; + +export const PublicUserProjection = Object.values(PublicUserEnum).filter( + (x) => typeof x === "string" +) as PublicUserKeys[]; +export const PrivateUserProjection = [ + ...PublicUserProjection, + ...Object.values(PrivateUserEnum).filter((x) => typeof x === "string"), +] as PrivateUserKeys[]; + +// Private user data that should never get sent to the client +export type PublicUser = Pick; + +export interface UserPublic extends Pick { } + +export interface UserPrivate extends Pick { + locale: string; +} + +@Entity("users") +export class User extends BaseClass { + @Column() + username: string; // username max length 32, min 2 (should be configurable) + + @Column() + discriminator: string; // opaque string: 4 digits on discord.com + + @Column({ nullable: true }) + avatar?: string; // hash of the user avatar + + @Column({ nullable: true }) + accent_color?: number; // banner color of user + + @Column({ nullable: true }) + banner?: string; // hash of the user banner + + @Column({ nullable: true, select: false }) + phone?: string; // phone number of the user + + @Column({ select: false }) + desktop: boolean; // if the user has desktop app installed + + @Column({ select: false }) + mobile: boolean; // if the user has mobile app installed + + @Column() + premium: boolean; // if user bought individual premium + + @Column() + premium_type: number; // individual premium level + + @Column() + bot: boolean; // if user is bot + + @Column() + bio: string; // short description of the user (max 190 chars -> should be configurable) + + @Column() + system: boolean; // shouldn't be used, the api sends this field type true, if the generated message comes from a system generated author + + @Column({ select: false }) + nsfw_allowed: boolean; // if the user can do age-restricted actions (NSFW channels/guilds/commands) + + @Column({ select: false }) + mfa_enabled: boolean; // if multi factor authentication is enabled + + @Column({ select: false, nullable: true }) + totp_secret?: string; + + @Column({ nullable: true, select: false }) + totp_last_ticket?: string; + + @Column() + created_at: Date; // registration date + + @Column({ nullable: true }) + premium_since: Date; // premium date + + @Column({ select: false }) + verified: boolean; // if the user is offically verified + + @Column() + disabled: boolean; // if the account is disabled + + @Column() + deleted: boolean; // if the user was deleted + + @Column({ nullable: true, select: false }) + email?: string; // email of the user + + @Column() + flags: string; // UserFlags + + @Column() + public_flags: number; + + @Column() + purchased_flags: number; + + @Column() + premium_usage_flags: number; + + @Column({ type: "bigint" }) + rights: string; // Rights + + @OneToMany(() => Session, (session: Session) => session.user) + sessions: Session[]; + + @JoinColumn({ name: "relationship_ids" }) + @OneToMany(() => Relationship, (relationship: Relationship) => relationship.from, { + cascade: true, + orphanedRowAction: "delete", + }) + relationships: Relationship[]; + + @JoinColumn({ name: "connected_account_ids" }) + @OneToMany(() => ConnectedAccount, (account: ConnectedAccount) => account.user, { + cascade: true, + orphanedRowAction: "delete", + }) + connected_accounts: ConnectedAccount[]; + + @Column({ type: "simple-json", select: false }) + data: { + valid_tokens_since: Date; // all tokens with a previous issue date are invalid + hash?: string; // hash of the password, salt is saved in password (bcrypt) + }; + + @Column({ type: "simple-array", select: false }) + fingerprints: string[]; // array of fingerprints -> used to prevent multiple accounts + + @Column({ type: "simple-json", select: false }) + settings: UserSettings; + + // workaround to prevent fossord-unaware clients from deleting settings not used by them + @Column({ type: "simple-json", select: false }) + extended_settings: string; + + @BeforeUpdate() + @BeforeInsert() + validate() { + this.email = adjustEmail(this.email); + if (!this.email) throw FieldErrors({ email: { message: "Invalid email", code: "EMAIL_INVALID" } }); + if (!this.email.match(/([a-z\d.-]{3,})@([a-z\d.-]+).([a-z]{2,})/g)) throw FieldErrors({ email: { message: "Invalid email", code: "EMAIL_INVALID" } }); + + const discrim = Number(this.discriminator); + if (this.discriminator.length > 4) throw FieldErrors({ email: { message: "Discriminator cannot be more than 4 digits.", code: "DISCRIMINATOR_INVALID" } }); + if (isNaN(discrim)) throw FieldErrors({ email: { message: "Discriminator must be a number.", code: "DISCRIMINATOR_INVALID" } }); + if (discrim <= 0 || discrim >= 10000) throw FieldErrors({ email: { message: "Discriminator must be a number.", code: "DISCRIMINATOR_INVALID" } }); + this.discriminator = discrim.toString().padStart(4, "0"); + + if (BannedWords.find(this.username)) throw FieldErrors({ username: { message: "Bad username", code: "INVALID_USERNAME" } }); + } + + toPublicUser() { + const user: any = {}; + PublicUserProjection.forEach((x) => { + user[x] = this[x]; + }); + return user as PublicUser; + } + + static async getPublicUser(user_id: string, opts?: FindOneOptions) { + return await User.findOneOrFail({ + where: { id: user_id }, + ...opts, + //@ts-ignore + select: [...PublicUserProjection, ...(opts?.select || [])], // TODO: fix + }); + } + + private static async generateDiscriminator(username: string): Promise { + if (Config.get().register.incrementingDiscriminators) { + // discriminator will be incrementally generated + + // First we need to figure out the currently highest discrimnator for the given username and then increment it + const users = await User.find({ where: { username }, select: ["discriminator"] }); + const highestDiscriminator = Math.max(0, ...users.map((u) => Number(u.discriminator))); + + const discriminator = highestDiscriminator + 1; + if (discriminator >= 10000) { + return undefined; + } + + return discriminator.toString().padStart(4, "0"); + } else { + // discriminator will be randomly generated + + // randomly generates a discriminator between 1 and 9999 and checks max five times if it already exists + // TODO: is there any better way to generate a random discriminator only once, without checking if it already exists in the database? + for (let tries = 0; tries < 5; tries++) { + const discriminator = Math.randomIntBetween(1, 9999).toString().padStart(4, "0"); + const exists = await User.findOne({ where: { discriminator, username: username }, select: ["id"] }); + if (!exists) return discriminator; + } + + return undefined; + } + } + + static async register({ + email, + username, + password, + date_of_birth, + req, + }: { + username: string; + password?: string; + email?: string; + date_of_birth?: Date; // "2000-04-03" + req?: any; + }) { + // trim special uf8 control characters -> Backspace, Newline, ... + username = trimSpecial(username); + + const discriminator = await User.generateDiscriminator(username); + if (!discriminator) { + // We've failed to generate a valid and unused discriminator + throw FieldErrors({ + username: { + code: "USERNAME_TOO_MANY_USERS", + message: req.t("auth:register.USERNAME_TOO_MANY_USERS"), + }, + }); + } + + // TODO: save date_of_birth + // appearently discord doesn't save the date of birth and just calculate if nsfw is allowed + // if nsfw_allowed is null/undefined it'll require date_of_birth to set it to true/false + const language = req.language === "en" ? "en-US" : req.language || "en-US"; + + const user = User.create({ + created_at: new Date(), + username: username, + discriminator, + id: Snowflake.generate(), + bot: false, + system: false, + premium_since: new Date(), + desktop: false, + mobile: false, + premium: true, + premium_type: 2, + bio: "", + mfa_enabled: false, + verified: true, + disabled: false, + deleted: false, + email: email, + rights: Config.get().security.defaultRights, + nsfw_allowed: true, // TODO: depending on age + public_flags: 0, + flags: "0", // TODO: generate + data: { + hash: password, + valid_tokens_since: new Date(), + }, + settings: { ...defaultSettings, locale: language }, + purchased_flags: 5, // TODO: idk what the values for this are + premium_usage_flags: 2, // TODO: idk what the values for this are + extended_settings: "", // TODO: was {} + fingerprints: [], + }); + + await user.save(); + + setImmediate(async () => { + if (Config.get().guild.autoJoin.enabled) { + for (const guild of Config.get().guild.autoJoin.guilds || []) { + await Member.addToGuild(user.id, guild).catch((e) => { }); + } + } + }); + + return user; + } +} + +export const defaultSettings: UserSettings = { + afk_timeout: 3600, + allow_accessibility_detection: true, + animate_emoji: true, + animate_stickers: 0, + contact_sync_enabled: false, + convert_emoticons: false, + custom_status: null, + default_guilds_restricted: false, + detect_platform_accounts: false, + developer_mode: true, + disable_games_tab: true, + enable_tts_command: false, + explicit_content_filter: 0, + friend_source_flags: { all: true }, + gateway_connected: false, + gif_auto_play: true, + guild_folders: [], + guild_positions: [], + inline_attachment_media: true, + inline_embed_media: true, + locale: "en-US", + message_display_compact: false, + native_phone_integration_enabled: true, + render_embeds: true, + render_reactions: true, + restricted_guilds: [], + show_current_game: true, + status: "online", + stream_notifications_enabled: false, + theme: "dark", + timezone_offset: 0, // TODO: timezone from request + + banner_color: null, + friend_discovery_flags: 0, + view_nsfw_guilds: true, + passwordless: false, +}; + +export interface UserSettings { + afk_timeout: number; + allow_accessibility_detection: boolean; + animate_emoji: boolean; + animate_stickers: number; + contact_sync_enabled: boolean; + convert_emoticons: boolean; + custom_status: { + emoji_id?: string; + emoji_name?: string; + expires_at?: number; + text?: string; + } | null; + default_guilds_restricted: boolean; + detect_platform_accounts: boolean; + developer_mode: boolean; + disable_games_tab: boolean; + enable_tts_command: boolean; + explicit_content_filter: number; + friend_source_flags: { all: boolean; }; + gateway_connected: boolean; + gif_auto_play: boolean; + // every top guild is displayed as a "folder" + guild_folders: { + color: number; + guild_ids: string[]; + id: number; + name: string; + }[]; + guild_positions: string[]; // guild ids ordered by position + inline_attachment_media: boolean; + inline_embed_media: boolean; + locale: string; // en_US + message_display_compact: boolean; + native_phone_integration_enabled: boolean; + render_embeds: boolean; + render_reactions: boolean; + restricted_guilds: string[]; + show_current_game: boolean; + status: "online" | "offline" | "dnd" | "idle" | "invisible"; + stream_notifications_enabled: boolean; + theme: "dark" | "white"; // dark + timezone_offset: number; // e.g -60 + banner_color: string | null; + friend_discovery_flags: number; + view_nsfw_guilds: boolean; + passwordless: boolean; +} + +export const CUSTOM_USER_FLAG_OFFSET = BigInt(1) << BigInt(32); + +export class UserFlags extends BitField { + static FLAGS = { + DISCORD_EMPLOYEE: BigInt(1) << BigInt(0), + PARTNERED_SERVER_OWNER: BigInt(1) << BigInt(1), + HYPESQUAD_EVENTS: BigInt(1) << BigInt(2), + BUGHUNTER_LEVEL_1: BigInt(1) << BigInt(3), + MFA_SMS: BigInt(1) << BigInt(4), + PREMIUM_PROMO_DISMISSED: BigInt(1) << BigInt(5), + HOUSE_BRAVERY: BigInt(1) << BigInt(6), + HOUSE_BRILLIANCE: BigInt(1) << BigInt(7), + HOUSE_BALANCE: BigInt(1) << BigInt(8), + EARLY_SUPPORTER: BigInt(1) << BigInt(9), + TEAM_USER: BigInt(1) << BigInt(10), + TRUST_AND_SAFETY: BigInt(1) << BigInt(11), + SYSTEM: BigInt(1) << BigInt(12), + HAS_UNREAD_URGENT_MESSAGES: BigInt(1) << BigInt(13), + BUGHUNTER_LEVEL_2: BigInt(1) << BigInt(14), + UNDERAGE_DELETED: BigInt(1) << BigInt(15), + VERIFIED_BOT: BigInt(1) << BigInt(16), + EARLY_VERIFIED_BOT_DEVELOPER: BigInt(1) << BigInt(17), + CERTIFIED_MODERATOR: BigInt(1) << BigInt(18), + BOT_HTTP_INTERACTIONS: BigInt(1) << BigInt(19), + }; +} diff --git a/src/util/entities/VoiceState.ts b/src/util/entities/VoiceState.ts new file mode 100644 index 00000000..75748a01 --- /dev/null +++ b/src/util/entities/VoiceState.ts @@ -0,0 +1,77 @@ +import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm"; +import { BaseClass } from "./BaseClass"; +import { Channel } from "./Channel"; +import { Guild } from "./Guild"; +import { User } from "./User"; +import { Member } from "./Member"; + +//https://gist.github.com/vassjozsef/e482c65df6ee1facaace8b3c9ff66145#file-voice_state-ex +@Entity("voice_states") +export class VoiceState extends BaseClass { + @Column({ nullable: true }) + @RelationId((voice_state: VoiceState) => voice_state.guild) + guild_id: string; + + @JoinColumn({ name: "guild_id" }) + @ManyToOne(() => Guild, { + onDelete: "CASCADE", + }) + guild?: Guild; + + @Column({ nullable: true }) + @RelationId((voice_state: VoiceState) => voice_state.channel) + channel_id: string; + + @JoinColumn({ name: "channel_id" }) + @ManyToOne(() => Channel, { + onDelete: "CASCADE", + }) + channel: Channel; + + @Column({ nullable: true }) + @RelationId((voice_state: VoiceState) => voice_state.user) + user_id: string; + + @JoinColumn({ name: "user_id" }) + @ManyToOne(() => User, { + onDelete: "CASCADE", + }) + user: User; + + // @JoinColumn([{ name: "user_id", referencedColumnName: "id" },{ name: "guild_id", referencedColumnName: "guild_id" }]) + // @ManyToOne(() => Member, { + // onDelete: "CASCADE", + // }) + //TODO find a way to make it work without breaking Guild.voice_states + member: Member; + + @Column() + session_id: string; + + @Column({ nullable: true }) + token: string; + + @Column() + deaf: boolean; + + @Column() + mute: boolean; + + @Column() + self_deaf: boolean; + + @Column() + self_mute: boolean; + + @Column({ nullable: true }) + self_stream?: boolean; + + @Column() + self_video: boolean; + + @Column() + suppress: boolean; // whether this user is muted by the current user + + @Column({ nullable: true, default: null }) + request_to_speak_timestamp?: Date; +} diff --git a/src/util/entities/Webhook.ts b/src/util/entities/Webhook.ts new file mode 100644 index 00000000..89538417 --- /dev/null +++ b/src/util/entities/Webhook.ts @@ -0,0 +1,76 @@ +import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm"; +import { Application } from "./Application"; +import { BaseClass } from "./BaseClass"; +import { Channel } from "./Channel"; +import { Guild } from "./Guild"; +import { User } from "./User"; + +export enum WebhookType { + Incoming = 1, + ChannelFollower = 2, +} + +@Entity("webhooks") +export class Webhook extends BaseClass { + @Column({ type: "int" }) + type: WebhookType; + + @Column({ nullable: true }) + name?: string; + + @Column({ nullable: true }) + avatar?: string; + + @Column({ nullable: true }) + token?: string; + + @Column({ nullable: true }) + @RelationId((webhook: Webhook) => webhook.guild) + guild_id: string; + + @JoinColumn({ name: "guild_id" }) + @ManyToOne(() => Guild, { + onDelete: "CASCADE", + }) + guild: Guild; + + @Column({ nullable: true }) + @RelationId((webhook: Webhook) => webhook.channel) + channel_id: string; + + @JoinColumn({ name: "channel_id" }) + @ManyToOne(() => Channel, { + onDelete: "CASCADE", + }) + channel: Channel; + + @Column({ nullable: true }) + @RelationId((webhook: Webhook) => webhook.application) + application_id: string; + + @JoinColumn({ name: "application_id" }) + @ManyToOne(() => Application, { + onDelete: "CASCADE", + }) + application: Application; + + @Column({ nullable: true }) + @RelationId((webhook: Webhook) => webhook.user) + user_id: string; + + @JoinColumn({ name: "user_id" }) + @ManyToOne(() => User, { + onDelete: "CASCADE", + }) + user: User; + + @Column({ nullable: true }) + @RelationId((webhook: Webhook) => webhook.guild) + source_guild_id: string; + + @JoinColumn({ name: "source_guild_id" }) + @ManyToOne(() => Guild, { + onDelete: "CASCADE", + }) + source_guild: Guild; +} diff --git a/src/util/entities/index.ts b/src/util/entities/index.ts new file mode 100644 index 00000000..49793810 --- /dev/null +++ b/src/util/entities/index.ts @@ -0,0 +1,32 @@ +export * from "./Application"; +export * from "./Attachment"; +export * from "./AuditLog"; +export * from "./Ban"; +export * from "./BaseClass"; +export * from "./Categories"; +export * from "./Channel"; +export * from "./Config"; +export * from "./ConnectedAccount"; +export * from "./Emoji"; +export * from "./Guild"; +export * from "./Invite"; +export * from "./Member"; +export * from "./Message"; +export * from "./Migration"; +export * from "./RateLimit"; +export * from "./ReadState"; +export * from "./Recipient"; +export * from "./Relationship"; +export * from "./Role"; +export * from "./Session"; +export * from "./Sticker"; +export * from "./StickerPack"; +export * from "./Team"; +export * from "./TeamMember"; +export * from "./Template"; +export * from "./User"; +export * from "./VoiceState"; +export * from "./Webhook"; +export * from "./ClientRelease"; +export * from "./BackupCodes"; +export * from "./Note"; \ No newline at end of file -- cgit 1.4.1