Source: schemas/user/userSchema.js

/*Canopy - The next generation of stoner streaming software
Copyright (C) 2024-2025 Rainbownapkin and the TTN Community

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/>.*/

//NPM Imports
const {mongoose} = require('mongoose');

//local imports
//server
const server = require('../../server');
//DB Models
const statModel = require('../statSchema');
const flairModel = require('../flairSchema');
const permissionModel = require('../permissionSchema');
const emoteModel = require('../emoteSchema');
const emailChangeModel = require('./emailChangeSchema');
const playlistSchema = require('../channel/media/playlistSchema');
//Utils
const hashUtil = require('../../utils/hashUtils');
const mailUtil = require('../../utils/mailUtils');
const loggerUtils = require('../../utils/loggerUtils')


/**
 * Mongoose Schema for a document representing a single canopy user
 */
const userSchema = new mongoose.Schema({
    id: {
        type: mongoose.SchemaTypes.Number,
        required: true
    },
    user: {
        type: mongoose.SchemaTypes.String,
        maxLength: 22,
        required: true,
    },
    pass: {
        type: mongoose.SchemaTypes.String,
        required: true
    },
    email: {
        type: mongoose.SchemaTypes.String,
        optional: true,
        default: ""
    },
    date: {
        type: mongoose.SchemaTypes.Date,
        required: true,
        default: new Date()
    },
    rank: {
        type: mongoose.SchemaTypes.String,
        required: true,
        enum: permissionModel.rankEnum,
        default: "user"
    },
    tokes: {
        type: mongoose.SchemaTypes.Map,
        required: true,
        default: new Map()
    },
    img: {
        type: mongoose.SchemaTypes.String,
        required: true,
        default: "/img/johnny.png"
    },
    bio: {
        type: mongoose.SchemaTypes.String,
        required: true,
        //Calculate max length by the validator max length and the size of an escaped character
        maxLength: 1000 * 6,
        default: "Bio not set!"
    },
    pronouns:{
        type: mongoose.SchemaTypes.String,
        optional: true,
        //Calculate max length by the validator max length and the size of an escaped character
        maxLength: 15 * 6,
        default: ""
    },
    signature: {
        type: mongoose.SchemaTypes.String,
        required: true,
        //Calculate max length by the validator max length and the size of an escaped character
        maxLength: 25 * 6,
        default: "Signature not set!"
    },
    highLevel: {
        type: mongoose.SchemaTypes.Number,
        required: true,
        min: 0,
        max: 10,
        default: 0
    },
    flair: {
        type: mongoose.SchemaTypes.ObjectID,
        default: null,
        ref: "flair"
    },
    //Not re-using the site-wide schema because post/pre save should call different functions
    emotes: [{
        name:{
            type: mongoose.SchemaTypes.String,
            required: true
        },
        link:{
            type: mongoose.SchemaTypes.String,
            required: true
        },
        type:{
            type: mongoose.SchemaTypes.String,
            required: true,
            enum: emoteModel.typeEnum,
            default: emoteModel.typeEnum[0]
        }
    }],
    recentIPs: [{
        ipHash: {
            type: mongoose.SchemaTypes.String,
            required: true
        },
        firstLog: {
            type: mongoose.SchemaTypes.Date,
            required: true,
            default: new Date()
        },
        lastLog: {
            type: mongoose.SchemaTypes.Date,
            required: true,
            default: new Date()
        }
    }],
    alts:[{
        type: mongoose.SchemaTypes.ObjectID,
        ref: "user"
    }],
    playlists: [playlistSchema]
});

//This is one of those places where you really DON'T want to use an arrow function over an anonymous one!
/**
 * Pre-Save function for user document, handles password hashing, flair updates, emote refreshes, and kills sessions upon rank change
 */
userSchema.pre('save', async function (next){

    //If the password was changed
    if(this.isModified("pass")){
        //Hash that sunnovabitch, no questions asked.
        this.pass = hashUtil.hashPassword(this.pass);
    }

    //If the flair was changed
    if(this.isModified("flair")){
        //Get flair properties 
        await this.populate('flair');

        if(permissionModel.rankToNum(this.rank) < permissionModel.rankToNum(this.flair.rank)){
            throw loggerUtils.exceptionSmith(`User '${this.user}' does not have a high enough rank for flair '${this.flair.displayName}'!`, "unauthorized");
        }
    }

    //Ensure we don't have empty flair
    if(this.flair == null){
        const flairDB = await flairModel.findOne({});
        this.flair = flairDB._id;
    }

    //If rank was changed
    if(this.isModified("rank")){
        //force a full log-out
        await this.killAllSessions("Your site-wide rank has changed. Sign-in required.");
    }

    //if emotes where modified
    if(this.isModified('emotes')){
        //Get the active Channel object from the application side of the house
        server.channelManager.crawlConnections(this.user, (conn)=>{
            //Send out emotes to each one
            conn.sendPersonalEmotes(this);
        });
    }

    //All is good, continue on saving.
    next();
});

/**
 * Pre-Delete function for user accounts, drops connections and rips it self out from alt accounts
 */
userSchema.post('deleteOne', {document: true}, async function (){
    //Kill any active sessions
    await this.killAllSessions("If you're seeing this, your account has been deleted. So long, and thanks for all the fish! <3");

    //Populate alts
    await this.populate('alts');

    //iterate through alts
    for(alt in this.alts){
        //Find the index of the alt entry for this user inside of the alt users array of alt users
        const altIndex = this.alts[alt].alts.indexOf(this._id);

        //splice the entry for this user out of the alt users array of alt users
        this.alts[alt].alts.splice(altIndex,1);

        //Save the alt user
        await this.alts[alt].save();
    }
});

//statics
/**
 * Registers a new user account with given information
 * @param {Object} userObj - Object representing user to register, generated by the client
 * @param {String} ip - IP Address of connection registering the account
 */
userSchema.statics.register = async function(userObj, ip){
    //Pull values from user object
    const {user, pass, passConfirm, email} = userObj;

    //Check password confirmation matches
    if(pass == passConfirm){
        //Look for a user (case insensitive)
        var userDB = await this.findOne({user: new RegExp(user, 'i')}); 

        //If the user is found or someones trying to impersonate tokeboi
        if(userDB || user.toLowerCase() == "tokebot"){
            throw loggerUtils.exceptionSmith("User name/email already taken!", "validation");
        }else{
            //Increment the user count, pulling the id to tattoo to the user
            const id = await statModel.incrementUserCount();

            //Create user document in the database
            const newUser = await this.create({id, user, pass});

            //Tattoo the hashed IP used to register to the new user
            await newUser.tattooIPRecord(ip);

            //if we submitted an email
            if(email != null){
                const requestDB = await emailChangeModel.create({user: newUser._id, newEmail: email, ipHash: ip});

                await mailUtil.sendAddressVerification(requestDB, newUser, email)
            }
        }
    }else{
        throw loggerUtils.exceptionSmith("Confirmation password doesn't match!", "validation");
    }
}

/**
 * Authenticates a username and password pair
 * @param {String} user - Username of account to login as
 * @param {String} pass - Password to authenticat account
 * @param {String} failLine - Line to paste into custom error upon login failure
 * @returns {Mongoose.Document} - User DB Document upon success
 */
userSchema.statics.authenticate = async function(user, pass, failLine = "Bad Username or Password."){
    //check for missing pass
    if(!user || !pass){
        throw loggerUtils.exceptionSmith("Missing user/pass.", "validation");
    }

    //get the user if it exists
    const userDB = await this.findOne({ user:  new RegExp(user, 'i')});

    //if not scream and shout
    if(!userDB){
        badLogin();
    }

    //Check our password is correct
    if(userDB.checkPass(pass)){
        return userDB;
    }else{
        //if not scream and shout
        badLogin();
    }

    //standardize bad login response so it's unknown which is bad for security reasons.
    function badLogin(){
        throw loggerUtils.exceptionSmith(failLine, "unauthorized");
    }
}

/**
 * Gets profile by username
 * @param {String} user - name of user to find
 * @param {Boolean} includeEmail - Whether or not to include email in the final result
 * @returns {Object} A network-friendly browser-digestable Object representing a single user profile
 */
userSchema.statics.findProfile = async function(user, includeEmail = false){
    //Catch null users
    if(user == null || user.user == null){
        return null;
    //If someone's looking for tokebot
    }else if(user.user.toLowerCase() == "tokebot"){
        //fake a profile hashtable for tokebot
        const profile = {
            id: -420,
            user: "Tokebot",
            date: (await statModel.getStats()).firstLaunch,
            tokes: await statModel.getTokeCommandCounts(),
            tokeCount: await statModel.getTokeCount(),
            img: "/img/johnny.png",
            signature: "!TOKE",
            bio: "!TOKE OR DIE!"
        };

        //return the faked profile
        return profile;
    }else{
        //find user
        const userDB = await this.findOne({user: user.user});
        
        //If we don't find a user just return a null profile
        if(userDB == null){
            return null
        }

        //return the profile
        return userDB.getProfile(includeEmail);
    }

}

/**
 * Tattoos a single toke callout to all the users within it
 * @param {Map} tokemap - Map containing list of users and the toke command they used to join the toke
 */
userSchema.statics.tattooToke = function(tokemap){
    //For each toke, asynchronously:
    tokemap.forEach(async (toke, user) => {
        //get user
        const userDB = await this.findOne({user});

        //Check that the user exists (might come in handy for future treez.one integration?)
        if(userDB != null){
            var tokeCount = userDB.tokes.get(toke);

            //if this is the first time using this toke command
            if(tokeCount == null){
                //set toke count to one
                tokeCount = 1;
            //otherwise
            }else{
                //increment tokecount
                tokeCount++;
            }

            //Set the toke count for the specific command
            userDB.tokes.set(toke, tokeCount);

            //Save the user doc to the database
            await userDB.save();

            //Would rather do this inside of tokebot but then our boi would have to wait for DB or pass a nasty-looking callback function
            //Crawl through active connections
            server.channelManager.crawlConnections(userDB.user, (conn)=>{
                    //Update used toke list
                    conn.sendUsedTokes(userDB);
            });
        }
    });
}

/**
 * Acquires a list of the entire userbase
 * @param {Boolean} fullList - Determines whether or not we're listing rank and email
 * @returns {Array} Array of objects containing user metadata
 */
userSchema.statics.getUserList = async function(fullList = false){
    var userList = [];
    //Get all of our users
    const users = await this.find({});

    //Return empty if we don't find nuthin'
    if(users == null){
        return [];
    }

    //For each user
    users.forEach((user)=>{
        //create a user object with limited properties (safe for public consumption)
        var userObj = {
            id: user.id,
            user: user.user,
            img: user.img,
            date: user.date
        }

        //Put together a spicier version for admins when told so (permission checks should happen before this is called)
        if(fullList){
            userObj.rank = user.rank,
            userObj.email = user.email
        }

        //Add user object to list
        userList.push(userObj);
    });
    
    //return the userlist
    return userList;
}

/**
 * Process and Deletes Aged IP Records
 */
userSchema.statics.processAgedIPRecords = async function(){
    //Pull full userlist
    const users = await this.find({});

    //Not a fan of iterating through the DB but there doesn't seem to be a way to search for a doc based on the properties of it's subdoc
    for(let userIndex in users){
        //Pull user record from users by index
        const userDB = users[userIndex];
        //For every recent ip within the user
        for(let recordIndex in userDB.recentIPs){
            //Pull record from recent IPs by index
            const record = userDB.recentIPs[recordIndex];
            //Check how long it's been since we've last seen the IP
            const daysSinceLastUse = ((new Date() - record.lastLog) / (1000 * 60 * 60 * 24)).toFixed(1);

            //If it's been more than a week
            if(daysSinceLastUse >= 7){
                //Splice out the IP record
                userDB.recentIPs.splice(recordIndex, 1);
                //No reason to wait on this since we're done with this user
                await userDB.save();
            }
        }
    }
}



//methods
/**
 * Checks password against a user document
 * @param {String} pass - Password to authenticate
 * @returns {Boolean} True if authenticated
 */
userSchema.methods.checkPass = function(pass){
    return hashUtil.comparePassword(pass, this.pass)
}

/**
 * Lists authenticated sessions for a given user document
 * @returns {Promise} Promise containing an array of active user sessions for a given user
 */
userSchema.methods.getAuthenticatedSessions = async function(){
    var returnArr = [];

    //retrieve active sessions (they really need to implement this shit async already)
    return new Promise((resolve) => {
        server.store.all((err, sessions) => {
            //You guys ever hear of a 'not my' problem? Fucking y33tskies lmao, better use a try/catch
            if(err){
                throw err;

            }

            //crawl through active sessions
            sessions.forEach((session) => {
                //Skip un-authed sessions
                if(session.user != null){
                    //if a session matches the current user
                    if(session.user.id == this.id){
                        //we return it
                        returnArr.push(session);
                    }
                }
            });

            resolve(returnArr);
        });
    });

}

/**
 * Generates a network-friendly browser-digestable profile object for a sepcific user document
 * @param {Boolean} includeEmail - Whether or not to include the email address in the complete profile object
 * @returns {Object} Network-Friendly Browser-Digestable object containing profile metadata
 */
userSchema.methods.getProfile = function(includeEmail = false){
    //Create profile hashtable
    const profile = {
        id: this.id,
        user: this.user,
        date: this.date,
        tokes: this.tokes,
        tokeCount: this.getTokeCount(),
        img: this.img,
        signature: this.signature,
        pronouns: this.pronouns,
        bio: this.bio
    };

    //Include the email if we need to
    if(includeEmail){
        profile.email = this.email;
    }

    //return profile hashtable
    return profile;
}

/**
 * Gets list of profiles of alt accounts related to the given user document
 * @returns {Array} List of alternate profiles
 */
userSchema.methods.getAltProfiles = async function(){
    //Create an empty list to hold alt profiles
    const profileList = [];

    //populate the users alt list
    await this.populate('alts');

    //For every alt for the current user
    for(let alt of this.alts){
        //get the alts profile and push it to the profile list
        profileList.push(alt.getProfile());
    }

    //return our generated profile list
    return profileList;
}

/**
 * Sets flair for current user documetn
 * @param {String} flair - flair to set
 * @returns {Mongoose.Document} returns found flair document from DB
 */
userSchema.methods.setFlair = async function(flair){
    //Find flair by name
    const flairDB = await flairModel.findOne({name: flair});
    //Set the users flair ref to the found flairs _id
    this.flair = flairDB._id;
    //Save the user
    await this.save();
    //return the found flair
    return flairDB;
}

/**
 * Gets number of tokes for a given user document
 * @returns {Number} Number of tokes recorded for the given user document
 */
userSchema.methods.getTokeCount = function(){
    //Set tokeCount to 0
    var tokeCount = 0;

    //For each toke command the user has used
    this.tokes.forEach((commandCount) => {
        //Add the count for that specific command to the total
        tokeCount += commandCount;
    });

    //Return the amount of tokes recorded
    return tokeCount;
}

/**
 * Gets number of emotes for a given user document
 * @returns {Array} Array of objects representing emotes for the given user document
 */
userSchema.methods.getEmotes = function(){
    //Create an empty array to hold our emote list
    const emoteList = [];

    //For each channel emote
    this.emotes.forEach((emote) => { 
        //Push an object with select information from the emote to the emote list
        emoteList.push({
            name: emote.name,
            link: emote.link,
            type: emote.type
        });
    });

    //return the emote list
    return emoteList;
}

/**
 * Deletes an emote from the user Document
 * @param {String} name - Name of emote to delete
 */
userSchema.methods.deleteEmote = async function(name){
    //Get index by emote name
    const emoteIndex = this.emotes.findIndex(checkName);

    //Splice out found emote
    this.emotes.splice(emoteIndex, 1);
    
    //Save the user doc
    await this.save();

    function checkName(emote){
        //return emotes
        return emote.name == name;
    }
}

/**
 * Gets list of user playlists
 * @returns {Array} Array of objects represnting a playlist containing media objects
 */
userSchema.methods.getPlaylists = function(){
    //Create an empty array to hold our emote list
    const playlists = [];

    //For each channel emote
    for(let playlist of this.playlists){
        //Push an object with select information from the emote to the emote list
        playlists.push(playlist.dehydrate());
    }

    //return the emote list
    return playlists;
}

/**
 * Iterates through user playlists and calls a given callback function against them
 * @param {Function} cb - Callback function to call against user playlists
 */
userSchema.methods.playlistCrawl = function(cb){
    for(let listIndex in this.playlists){
        //Grab the associated playlist
        playlist = this.playlists[listIndex];

        //Call the callback with the playlist and list index as arguments
        cb(playlist, listIndex);
    }
}

/**
 * Returns playlist by name
 * @param {String} name -  Playlist to get by name
 * @returns {Object} Object representing the requested playlist
 */
userSchema.methods.getPlaylistByName = function(name){
    //Create null value to hold our found playlist
    let foundPlaylist = null;

    //Crawl through active playlists
    this.playlistCrawl((playlist, listIndex) => {
        //If we found a match based on name
        if(playlist.name == name){
            //Keep it
            foundPlaylist = playlist;
            //Pass down the list index
            foundPlaylist.listIndex = listIndex;
        }
    });

    //return the given playlist
    return foundPlaylist;
}

/**
 * Deletes a playlist from the user document by name
 * @param {String} name - Name of playlist to delete
 */
userSchema.methods.deletePlaylistByName = async function(name){
    //Find the playlist
    const playlist = this.getPlaylistByName(name);

    //splice out the given playlist
    this.playlists.splice(playlist.listIndex, 1);

    //save the channel document
    await this.save();
}

/**
 * Salts, Hashes and Tattoo's IP address to user document in a privacy respecting manner
 * @param {String} ip - Plaintext IP Address to Salt, Hash and Tattoo
 */
userSchema.methods.tattooIPRecord = async function(ip){
    //Hash the users ip
    const ipHash = hashUtil.hashIP(ip);
   
    //Look for a pre-existing entry for this ipHash
    const foundIndex = this.recentIPs.findIndex(checkHash);

    //If there is no entry
    if(foundIndex == -1){
        //Pull the entire userlist
        //TODO: update query to only pull users with recentIPs, so we aren't looping through inactive users
        const users = await this.model().find({});

        //create record object
        const record = {
            ipHash: ipHash,
            firstLog: new Date(),
            lastLog: new Date()
        };

        //We should really start using for loops and stop acting like its 2008
        //Though to be quite honest this bit would be particularly brutal without them
        //For every user in the userlist
        for(let curUser of users){
            //Ensure we're not checking the user against itself
            if(curUser._id != this._id){
                //For every IP record in the current user
                for(let curRecord of curUser.recentIPs){
                    //If it matches the current ipHash
                    if(curRecord.ipHash == ipHash){
                        //Check if we've already marked the user as an alt
                        const foundAlt = this.alts.indexOf(curUser._id);

                        //If these accounts aren't already marked as alts
                        if(foundAlt == -1){
                            //Add found user to this users alt list
                            this.alts.push(curUser._id);

                            //add this user to found users alt list
                            curUser.alts.push(this._id);

                            //Save changes to the found user, this user will save at the end of the function
                            await curUser.save();
                        }
                    }
                }
            }
        }

        //Pop it into place
        this.recentIPs.push(record);

        //Save the user doc
        await this.save();
    //Otherwise, if we already have a record for this IP
    }else{
        //Update the last logged date for the found record
        this.recentIPs[foundIndex].lastLog = new Date();
        
        //Save the user doc
        await this.save();
    }

    //Look for matching ip record
    function checkHash(ipRecord){
        //return matching records
        return ipRecord.ipHash == ipHash;
    }
}

//note: if you gotta call this from a request authenticated by it's user, make sure to kill that session first!
/**
 * Kills all sessions for a given user
 * @param {String} reason - Reason to kill user sessions
 */
userSchema.methods.killAllSessions = async function(reason = "A full log-out from all devices was requested for your account."){
    //get authenticated sessions
    var sessions = await this.getAuthenticatedSessions();

    //crawl through and kill all sessions
    sessions.forEach((session) => {
        server.store.destroy(session.seshid);
    });

    //Tell the application side of the house to kick the user out as well
    server.channelManager.kickConnections(this.user, reason);
}

/**
 * Changes password for a given user document 
 * @param {Object} passChange - passChange object handed down from Browser
 */
userSchema.methods.changePassword = async function(passChange){
    if(this.checkPass(passChange.oldPass)){
        if(passChange.newPass == passChange.confirmPass){
            //Note: We don't have to worry about hashing here because the schema is written to do it auto-magically
            this.pass = passChange.newPass;
            
            //Save our password
            await this.save();

            //Kill all authed sessions for security purposes
            await this.killAllSessions("Your password has been reset.");
        }else{
            //confirmation pass doesn't match
            throw loggerUtils.exceptionSmith("Mismatched confirmation password!", "validation");
        }
    }else{
        //Old password wrong
        throw loggerUtils.exceptionSmith("Incorrect Password!", "validation");
    }

}

/**
 * Checks another user document against this one to see if they're alts
 * @param {Mongoose.Document} userDB - User document to check for alts against
 * @returns {Boolean} True if alt
 */
userSchema.methods.altCheck = async function(userDB){
    //Found alt flag
    let foundAlt = false;

    for(alt in this.alts){
        //If we found a match
        if(this.alts[alt]._id.toString() == userDB._id.toString()){
            foundAlt = true;
        }
    }

    //return the results
    return foundAlt;
}

/**
 * Nukes user account from the face of the planet upon said user's request
 * @param {String} pass - Password to authenticate against before deleting
 */
userSchema.methods.nuke = async function(pass){
    //Check we have a confirmation password
    if(pass == "" || pass == null){
        //scream and shout
        throw loggerUtils.exceptionSmith("No confirmation password!", "validation");
    }

    //Check that the password is correct
    if(this.checkPass(pass)){
        //delete the user
        var oldUser = await this.deleteOne();
    }else{
        //complain about a bad pass
        throw loggerUtils.exceptionSmith("Bad pass.", "unauthorized");
    }
}

module.exports.userModel = mongoose.model("user", userSchema);