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;
+ }
+}
|