diff options
author | Madeline <46743919+MaddyUnderStars@users.noreply.github.com> | 2022-09-25 18:24:21 +1000 |
---|---|---|
committer | Madeline <46743919+MaddyUnderStars@users.noreply.github.com> | 2022-09-25 23:35:18 +1000 |
commit | 0d23eaba09a4878520bf346af4cead90d76829fc (patch) | |
tree | d930eacceff0b407b44abe55f01d8e3c5dfbfa34 /src/util/entities/User.ts | |
parent | Allow edited_timestamp to passthrough in handleMessage (diff) | |
download | server-0d23eaba09a4878520bf346af4cead90d76829fc.tar.xz |
Refactor to mono-repo + upgrade packages
Diffstat (limited to 'src/util/entities/User.ts')
-rw-r--r-- | src/util/entities/User.ts | 430 |
1 files changed, 430 insertions, 0 deletions
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<User, PublicUserKeys>; + +export interface UserPublic extends Pick<User, PublicUserKeys> { } + +export interface UserPrivate extends Pick<User, PrivateUserKeys> { + 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<User>) { + return await User.findOneOrFail({ + where: { id: user_id }, + ...opts, + //@ts-ignore + select: [...PublicUserProjection, ...(opts?.select || [])], // TODO: fix + }); + } + + private static async generateDiscriminator(username: string): Promise<string | undefined> { + 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), + }; +} |