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, 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; // TODO: I don't like this method? validate() { if (this.email) { 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" }, }); } if (this.discriminator) { const discrim = Number(this.discriminator); if (this.discriminator.length > 4) throw FieldErrors({ discriminator: { message: "Discriminator cannot be more than 4 digits.", code: "DISCRIMINATOR_INVALID", }, }); if (isNaN(discrim)) throw FieldErrors({ discriminator: { message: "Discriminator must be a number.", code: "DISCRIMINATOR_INVALID", }, }); if (discrim <= 0 || discrim >= 10000) throw FieldErrors({ discriminator: { message: "Discriminator must be a number.", code: "DISCRIMINATOR_INVALID", }, }); this.discriminator = discrim.toString().padStart(4, "0"); } } 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: [], }); user.validate(); 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" | "light"; // 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), }; }