summary refs log tree commit diff
path: root/src/database/entities/User.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/database/entities/User.ts')
-rw-r--r--src/database/entities/User.ts409
1 files changed, 409 insertions, 0 deletions
diff --git a/src/database/entities/User.ts b/src/database/entities/User.ts
new file mode 100644

index 00000000..5f40adb3 --- /dev/null +++ b/src/database/entities/User.ts
@@ -0,0 +1,409 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2023 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. +*/ + +import { Request } from "express"; +import { Column, Entity, JoinColumn, OneToMany, OneToOne } from "typeorm"; +import { Config, Email, FieldErrors, Snowflake, Stopwatch, trimSpecial } from "@spacebar/util"; +import { Random } from "@spacebar/util/util/Random"; +import { BaseClass } from "./BaseClass"; +import { Channel } from "./Channel"; +import { ConnectedAccount } from "./ConnectedAccount"; +import { Member } from "./Member"; +import { Relationship } from "./Relationship"; +import { SecurityKey } from "./SecurityKey"; +import { Session } from "./Session"; +import { UserSettings } from "./UserSettings"; +import { + AvatarDecorationData, + ChannelType, + Collectibles, + DisplayNameStyle, + PrimaryGuild, + PrivateUserProjection, + PublicUser, + PublicUserProjection, + UserPrivate, +} from "@spacebar/schemas"; +import { JsonNumber } from "../../util/util/Decorators"; + +@Entity({ + name: "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 + + // TODO: Separate `User` and `UserProfile` models + // puyo: changed from [number, number] because it breaks openapi + @Column({ nullable: true, type: "int4", array: true }) + theme_colors?: number[]; + + @Column({ nullable: true }) + pronouns?: string; + + @Column({ nullable: true, select: false }) + phone?: string; // phone number of the user + + @Column({ select: false }) + desktop: boolean = false; // if the user has desktop app installed + + @Column({ select: false }) + mobile: boolean = false; // 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 = false; // if user is bot + + @Column() + bio: string = ""; // short description of the user + + @Column() + system: boolean = false; // 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 = true; // if the user can do age-restricted actions (NSFW channels/guilds/commands) // TODO: depending on age + + @Column({ select: false }) + mfa_enabled: boolean = false; // if multi factor authentication is enabled + + @Column({ select: false, default: false }) + webauthn_enabled: boolean = false; // if webauthn 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; // email is verified + + @Column() + disabled: boolean = false; // if the account is disabled + + @Column() + deleted: boolean = false; // if the user was deleted + + @Column({ nullable: true, select: false }) + email?: string; // email of the user + + @Column({ type: "bigint" }) + @JsonNumber + flags: number = 0; // UserFlags // TODO: generate + + @Column({ type: "bigint" }) + @JsonNumber + public_flags: number = 0; + + @Column({ type: "bigint" }) + @JsonNumber + purchased_flags: number = 0; + + @Column() + premium_usage_flags: number = 0; + + @Column({ type: "bigint" }) + @JsonNumber + rights: string; + + @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: "jsonb", 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: "varchar", array: true, select: false }) + fingerprints: string[] = []; // array of fingerprints -> used to prevent multiple accounts + + @OneToOne(() => UserSettings, { + cascade: true, + orphanedRowAction: "delete", + nullable: true, + }) + @JoinColumn() + settings?: UserSettings; + + @OneToMany(() => SecurityKey, (key: SecurityKey) => key.user) + security_keys: SecurityKey[]; + + @Column({ type: "int8", array: true, nullable: true }) + badge_ids?: string[]; + + @Column({ type: "jsonb", nullable: true }) + avatar_decoration_data?: AvatarDecorationData; + + @Column({ type: "jsonb", nullable: true }) + display_name_styles?: DisplayNameStyle; + + @Column({ type: "jsonb", nullable: true }) + collectibles?: Collectibles; + + @Column({ type: "jsonb", nullable: true }) + primary_guild?: PrimaryGuild; + + // TODO: I don't like this method? + validate() { + if (this.discriminator) { + const discrim = Number(this.discriminator); + if (isNaN(discrim) || !Number.isInteger(discrim) || discrim <= 0 || discrim >= 10000) + throw FieldErrors({ + discriminator: { + message: "Discriminator must be a number.", + code: "DISCRIMINATOR_INVALID", + }, + }); + + this.discriminator = discrim.toString().padStart(4, "0"); + } + } + + toPublicUser() { + this.clean_data(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const user: any = {}; + PublicUserProjection.forEach((x) => { + user[x] = this[x]; + }); + return user as PublicUser; + } + + toPrivateUser(extraFields: (keyof User)[] = []) { + this.clean_data(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const user: any = {}; + [...PrivateUserProjection, ...extraFields].forEach((x) => { + user[x] = this[x]; + }); + return user as UserPrivate; + } + + static async getPublicUser(user_id: string): Promise<PublicUser> { + const user = await User.findOneOrFail({ + where: { id: user_id }, + select: PublicUserProjection, + }); + return user.toPublicUser(); + } + + public 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: true }, + }); + 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? + const takenDiscriminators = (await User.find({ where: { username }, select: { discriminator: true } })).map((x) => x.discriminator); + if (takenDiscriminators.length >= 9999) return undefined; + + for (let tries = 0; tries < 15; tries++) { + const discriminator = Random.nextInt(1, 9999).toString().padStart(4, "0"); + if (!takenDiscriminators.includes(discriminator)) return discriminator; + } + + return undefined; + } + } + + public get tag(): string { + //const { uniqueUsernames } = Config.get().general; + const uniqueUsernames = false; + + return uniqueUsernames ? this.username : `${this.username}#${this.discriminator}`; + } + + static async register({ + email, + username, + password, + id, + req, + bot, + }: { + username: string; + password?: string; + email?: string; + date_of_birth?: Date; // "2000-04-03" + id?: string; + req?: Request; + bot?: boolean; + }) { + const totalSw = Stopwatch.startNew(); + const incSw = Stopwatch.startNew(); + const logTrace = (...data: unknown[]) => { + if (process.env.LOG_VERBOSE_TRACES !== "true") return; + console.log("[User.register]", ...data, `[${totalSw.elapsed().toString()} (+${incSw.getElapsedAndReset().totalMilliseconds}ms)]`); + }; + + // trim special utf8 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") || "", + }, + }); + } + logTrace("Generate discriminator"); + + // TODO: save date_of_birth + // apparently 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 settings = UserSettings.create({ + locale: language, + }); + + const user = User.create({ + username: username, + discriminator, + id: id || Snowflake.generate(), + email: email, + data: { + hash: password, + valid_tokens_since: new Date(), + }, + settings: settings, + + premium_since: Config.get().defaults.user.premium ? new Date() : undefined, + rights: Config.get().register.defaultRights, + premium: Config.get().defaults.user.premium ?? false, + premium_type: Config.get().defaults.user.premiumType ?? 0, + verified: Config.get().defaults.user.verified ?? true, + created_at: new Date(), + bot: !!bot, + }); + + user.validate(); + logTrace("Generate/validate user"); + + await Promise.all([user.save(), settings.save()]); + logTrace("Save user"); + + // send verification email if users aren't verified by default and we have an email + if (!Config.get().defaults.user.verified && email) { + await Email.sendVerifyEmail(user, email).catch((e) => { + console.error(`Failed to send verification email to ${user.tag}: ${e}`); + }); + logTrace("Send verify email"); + } + + const { autoJoin } = Config.get().guild; + if (autoJoin.enabled && autoJoin.guilds.length > 0 && !(bot && !autoJoin.bots)) { + await Promise.all(autoJoin.guilds.map((guild) => Member.addToGuild(user.id, guild, true).catch((e) => console.error("[Autojoin]", e)))); + logTrace("Autojoin", autoJoin.guilds.length, "guilds"); + } + + return user; + } + + async getDmChannelWith(user_id: string) { + const qry = await Channel.getRepository() + .createQueryBuilder() + .leftJoinAndSelect("Channel.recipients", "rcp") + .where("Channel.type = :type", { type: ChannelType.DM }) + .andWhere("rcp.user_id IN (:...user_ids)", { user_ids: [this.id, user_id] }) + .groupBy("Channel.id") + .having("COUNT(rcp.user_id) = 2") + .getMany(); + + // Emma [it/its]@Rory&: is this technically a bug, or am I being too over-cautious? + if (qry.length > 1) { + console.warn(`[WARN] User(${this.id})#getDmChannel(${user_id}) returned multiple channels:`); + for (const channel of qry) { + console.warn(JSON.stringify(channel)); + } + throw new Error("Array contains more than one matching element"); + } + + return qry[0]; + } + + async getDmChannels() { + const qry = await Channel.getRepository() + .createQueryBuilder("channel") + .leftJoinAndSelect("channel.recipients", "rcp") + .where("channel.type = :type", { type: ChannelType.DM }) + .andWhere("rcp.user_id = :user_id", { user_id: this.id }) + .groupBy("channel.id") + .addGroupBy("rcp.id") + .having("COUNT(rcp.id) = 2") + .getMany(); + + return qry; + } +}