Source: schemas/user/emailChangeSchema.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/>.*/

//You could make an argument for making this part of the userModel
//However, this is so rarely used the preformance benefits aren't worth the extra clutter

//Config
const config = require('../../../config.json');

//Node Imports
const crypto = require("node:crypto");

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

//Local Imports
const hashUtil = require('../../utils/hashUtils');
const mailUtils = require('../../utils/mailUtils');

/**
 * Email change token retention time 
 */
const daysToExpire = 7;

/**
 * DB Schema for Document representing a single email change request
 */
const emailChangeSchema = new mongoose.Schema({
    user: {
        type: mongoose.SchemaTypes.ObjectID,
        ref: "user",
        required: true
    },
    newEmail: {
        type: mongoose.SchemaTypes.String,
        required: true
    },
    token: {
        type: mongoose.SchemaTypes.String,
        required: true,
        //Use a cryptographically secure algorythm to create a random hex string from 16 bytes as our change/cancel token
        default: ()=>{return crypto.randomBytes(16).toString('hex')}
    },
    ipHash: {
        type: mongoose.SchemaTypes.String,
        required: true
    },
    date: {
        type: mongoose.SchemaTypes.Date,
        required: true,
        default: new Date()
    }
});


/**
 * Pre-Save function, ensures IP's are hashed and previous requests are deleted upon request creation
 */
emailChangeSchema.pre('save', async function (next){
    //If we're saving an ip
    if(this.isModified('ipHash')){
        //Hash that sunnuvabitch
        this.ipHash = hashUtil.hashIP(this.ipHash);
    }

    if(this.isModified('user')){
        //Delete previous requests for the given user
        const requests = await this.model().deleteMany({user: this.user._id});
    }

    next();
});

/**
 * Schedulable function for processing expired email change requests
 */
emailChangeSchema.statics.processExpiredRequests = async function(){
    //Pull all requests from the DB
    //Tested finding request by date, but mongoose kept throwing casting errors.
    //This seems to be an intermittent issue online. Maybe it will work in a future version?
    const requestDB = await this.find({});

    //Fire em all off at once without waiting for the last one to complete since we don't fuckin' need to
    for(let requestIndex in requestDB){
        //Pull request from requestDB by index
        const request = requestDB[requestIndex];

        //If the request hasn't been processed and it's been expired
        if(request.getDaysUntilExpiration() <= 0){
            //Delete the request
            await this.deleteOne({_id: request._id});
        }
    }
}

/**
 * Consumes email change token, changing email address on a given user
 */
emailChangeSchema.methods.consume = async function(){
    //Populate the user reference
    await this.populate('user');

    const  oldMail = this.user.email;

    //Set the new email
    this.user.email = this.newEmail;
    
    //Save the user
    await this.user.save();

    //Delete the request token now that it has been consumed
    await this.deleteOne();

    //If we had a previous email address
    if(oldMail != null && oldMail != ''){
        //Notify it of the change
        await mailUtils.mailem(
            oldMail,
            `Email Change Notification - ${this.user.user}`,
            `<h1>Email Change Notification</h1>
            <p>The ${config.instanceName} account '${this.user.user}' is no longer associated with this email address.<br>
            <sup>If you received this email without request, you should <strong>immediately</strong> change your password and contact the server adminsitrator! -Tokebot</sup>`,
            true
        );
    }

    //Notify the new inbox of the change
    await mailUtils.mailem(
        this.newEmail,
        `Email Change Notification - ${this.user.user}`,
        `<h1>Email Change Notification</h1>
        <p>The ${config.instanceName} account '${this.user.user}' is now associated with this email address.<br>
        <sup>If you received this email without request, you should <strong>immediately</strong> check who's been inside your inbox! -Tokebot</sup>`,
        true
    );

}

/**
 * Generates email change URL from a given token
 * @returns {String} Email change URL generated from token
 */
emailChangeSchema.methods.getChangeURL = function(){
    //Check for default port based on protocol
    if((config.protocol == 'http' && config.port == 80) || (config.protocol == 'https' && config.port == 443  || config.proxied)){
        //Return path
        return `${config.protocol}://${config.domain}/emailChange?token=${this.token}`;
    }else{
        //Return path
        return `${config.protocol}://${config.domain}:${config.port}/emailChange?token=${this.token}`;
    }
}

/**
 * Calculates days until token expiration
 * @returns {Number} Days until token expiration
 */
emailChangeSchema.methods.getDaysUntilExpiration = function(){
        //Get request date
        const expirationDate = new Date(this.date);
        //Get expiration days and calculate expiration date
        expirationDate.setDate(expirationDate.getDate() + daysToExpire);
        //Calculate and return days until request expiration
        return ((expirationDate - new Date()) / (1000 * 60 * 60 * 24)).toFixed(1);
}

module.exports = mongoose.model("emailChange", emailChangeSchema);