Newer
Older
percord / src / util / entities / User.ts
@Rory& Rory& on 11 Jun 13 KB Trace registration
/*
	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 { Channel, Config, Email, FieldErrors, Snowflake, Stopwatch, trimSpecial } from "..";
import { Random } from "../util";
import { BaseClass } from "./BaseClass";
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/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).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;
    }
}