diff --git a/src/controllers/404Controller.js b/src/controllers/404Controller.js
index 5136b02..471ea16 100644
--- a/src/controllers/404Controller.js
+++ b/src/controllers/404Controller.js
@@ -21,7 +21,7 @@ const config = require('../../config.json');
const csrfUtils = require('../utils/csrfUtils');
//register page functions
-module.exports = async function(req, res, next){
+module.exports = async function(req, res){
//set status
res.status(404);
diff --git a/src/controllers/api/account/emailChangeRequestController.js b/src/controllers/api/account/emailChangeRequestController.js
new file mode 100644
index 0000000..32c853c
--- /dev/null
+++ b/src/controllers/api/account/emailChangeRequestController.js
@@ -0,0 +1,96 @@
+
+/*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 .*/
+
+//Config
+const config = require('../../../../config.json');
+
+//NPM Imports
+const {validationResult, matchedData} = require('express-validator');
+
+//local imports
+const {userModel} = require('../../../schemas/user/userSchema');
+const emailChangeModel = require('../../../schemas/user/emailChangeSchema');
+const mailUtils = require('../../../utils/mailUtils');
+const {exceptionHandler, errorHandler} = require('../../../utils/loggerUtils');
+
+//Gateway for generating request token and having it emailed to the user
+module.exports.post = async function(req, res){
+ try{
+ //Check for validation errors
+ const validResult = validationResult(req);
+
+ //If there are none
+ if(validResult.isEmpty()){
+ //Get sanatized/validated data
+ const {email, pass} = matchedData(req);
+
+ //Check to make sure the user is logged in
+ if(req.session.user == null){
+ errorHandler(res, "Invalid user!");
+ }
+
+ //Authenticate and find user model from DB
+ const userDB = await userModel.authenticate(req.session.user.user, pass, "Bad password.");
+
+ //If we have an invalid user
+ if(userDB == null){
+ errorHandler(res, "Invalid user!");
+ }
+
+ if(userDB.email == email){
+ errorHandler(res, "Cannot set current email!");
+ }
+
+ //Generate the password reset link
+ const requestDB = await emailChangeModel.create({user: userDB._id, newEmail: email, ipHash: req.ip});
+
+ //Don't wait on mailer to get back to the browser
+ res.sendStatus(200);
+
+ //Send the reset url via email
+ await mailUtils.mailem(
+ email,
+ `Email Change Request - ${userDB.user}`,
+ `
Email Change Request
+
A request to change the email associated with the ${config.instanceName} account '${userDB.user}' to this address has been requested.
+ Click here to confirm this change.
+ If you received this email without request, feel free to ignore and delete it! -Tokebot`,
+ true
+ );
+
+ //If the user has a pre-existing email address
+ if(userDB.email != null && userDB.email != ""){
+ await mailUtils.mailem(
+ userDB.email,
+ `Email Change Request - ${userDB.user}`,
+ `
Email Change Request Notification
+
A request to change the email associated with the ${config.instanceName} account '${userDB.user}' to another address has been requested.
+ If you received this email without request, you should immediately change your password and contact the server adminsitrator! -Tokebot`,
+ true
+ );
+ }
+
+ //Clean our hands of the operation
+ return;
+ }else{
+ res.status(400);
+ return res.send({errors: validResult.array()});
+ }
+ }catch(err){
+ return exceptionHandler(res, err);
+ }
+}
\ No newline at end of file
diff --git a/src/controllers/emailChangeController.js b/src/controllers/emailChangeController.js
new file mode 100644
index 0000000..fb9cabd
--- /dev/null
+++ b/src/controllers/emailChangeController.js
@@ -0,0 +1,57 @@
+/*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 .*/
+
+//Config
+const config = require('../../config.json');
+
+//NPM Imports
+const {validationResult, matchedData} = require('express-validator');
+
+//local imports
+const emailChangeModel = require('../schemas/user/emailChangeSchema');
+const csrfUtils = require('../utils/csrfUtils');
+
+//gateway for resetting password
+module.exports.get = async function(req, res){
+ try{
+ //Check for validation errors
+ const validResult = validationResult(req);
+
+ //If there are none
+ if(validResult.isEmpty()){
+ //Get sanatized/validated data
+ const {token} = matchedData(req);
+
+ //Consume the password reset token using given input
+ const requestDB = await emailChangeModel.findOne({token});
+
+ //If we have an invalid request
+ if(requestDB == null){
+ return res.render('emailChange', {instance: config.instanceName, user: req.session.user, csrfToken: csrfUtils.generateToken(req), valid: false});
+ }
+
+ //Speak of our success (don't wait for the emails to be sent)
+ res.render('emailChange', {instance: config.instanceName, user: req.session.user, csrfToken: csrfUtils.generateToken(req), valid: true});
+
+ //Consume the request
+ await requestDB.consume();
+ }else{
+ return res.render('emailChange', {instance: config.instanceName, user: req.session.user, csrfToken: csrfUtils.generateToken(req), valid: false});
+ }
+ }catch(err){
+ return res.render('emailChange', {instance: config.instanceName, user: req.session.user, csrfToken: csrfUtils.generateToken(req), valid: false});
+ }
+}
\ No newline at end of file
diff --git a/src/routers/api/accountRouter.js b/src/routers/api/accountRouter.js
index f066546..b21fe48 100644
--- a/src/routers/api/accountRouter.js
+++ b/src/routers/api/accountRouter.js
@@ -26,6 +26,7 @@ const updateController = require("../../controllers/api/account/updateController
const rankEnumController = require("../../controllers/api/account/rankEnumController");
const passwordResetRequestController = require("../../controllers/api/account/passwordResetRequestController");
const passwordResetController = require("../../controllers/api/account/passwordResetController");
+const emailChangeRequestController = require('../../controllers/api/account/emailChangeRequestController');
const deleteController = require("../../controllers/api/account/deleteController");
//globals
@@ -55,6 +56,8 @@ router.get('/rankEnum', rankEnumController.get);
router.post('/passwordResetRequest', accountValidator.user(), passwordResetRequestController.post);
//password reset
router.post('/passwordReset', accountValidator.securityToken(), accountValidator.securePass(), accountValidator.pass('confirmPass'), passwordResetController.post);
+//email change request
+router.post('/emailChangeRequest', accountValidator.email(), accountValidator.pass(), emailChangeRequestController.post);
//account deletion
router.post('/delete', accountValidator.pass(), deleteController.post);
diff --git a/src/routers/emailChangeController.js b/src/routers/emailChangeController.js
new file mode 100644
index 0000000..7177bfd
--- /dev/null
+++ b/src/routers/emailChangeController.js
@@ -0,0 +1,31 @@
+/*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 .*/
+
+//npm imports
+const { Router } = require('express');
+
+
+//local imports
+const accountValidator = require('../validators/accountValidator');
+const emailChangeController = require("../controllers/emailChangeController");
+
+//globals
+const router = Router();
+
+//routing functions
+router.get('/', accountValidator.securityToken(), emailChangeController.get);
+
+module.exports = router;
diff --git a/src/schemas/user/emailChangeSchema.js b/src/schemas/user/emailChangeSchema.js
new file mode 100644
index 0000000..96bf5d2
--- /dev/null
+++ b/src/schemas/user/emailChangeSchema.js
@@ -0,0 +1,155 @@
+/*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 .*/
+
+//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');
+
+const daysToExpire = 7;
+
+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()
+ }
+});
+
+
+//Presave function
+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();
+});
+
+//statics
+emailChangeSchema.statics.processExpiredRequests = async function(){
+ //Pull all requests from the DB
+ 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
+ requestDB.forEach(async (request) => {
+ //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});
+ }
+ });
+}
+
+//methods
+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}`,
+ `
Email Change Notification
+
The ${config.instanceName} account '${this.user.user}' is no longer associated with this email address.
+ If you received this email without request, you should immediately change your password and contact the server adminsitrator! -Tokebot`,
+ true
+ );
+ }
+
+ //Notify the new inbox of the change
+ await mailUtils.mailem(
+ this.newEmail,
+ `Email Change Notification - ${this.user.user}`,
+ `
Email Change Notification
+
The ${config.instanceName} account '${this.user.user}' is now associated with this email address.
+ If you received this email without request, you should immediately check who's been inside your inbox! -Tokebot`,
+ true
+ );
+
+}
+
+emailChangeSchema.methods.getChangeURL = function(){
+ //Check for default port based on protocol
+ if((config.protocol == 'http' && config.port == 80) || (config.protocol == 'https' && config.port == 443)){
+ //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}`;
+ }
+}
+
+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);
diff --git a/src/schemas/user/userSchema.js b/src/schemas/user/userSchema.js
index e170704..f88e3f5 100644
--- a/src/schemas/user/userSchema.js
+++ b/src/schemas/user/userSchema.js
@@ -240,7 +240,7 @@ userSchema.statics.register = async function(userObj, ip){
}
}
-userSchema.statics.authenticate = async function(user, pass){
+userSchema.statics.authenticate = async function(user, pass, failLine = "Bad Username or Password."){
//check for missing pass
if(!user || !pass){
throw new Error("Missing user/pass.");
@@ -264,7 +264,7 @@ userSchema.statics.authenticate = async function(user, pass){
//standardize bad login response so it's unknowin which is bad for security reasons.
function badLogin(){
- throw new Error("Bad Username or Password.");
+ throw new Error(failLine);
}
}
diff --git a/src/server.js b/src/server.js
index 5a53293..a6fb18e 100644
--- a/src/server.js
+++ b/src/server.js
@@ -50,6 +50,7 @@ const adminPanelRouter = require('./routers/adminPanelRouter');
const channelRouter = require('./routers/channelRouter');
const newChannelRouter = require('./routers/newChannelRouter');
const passwordResetRouter = require('./routers/passwordResetRouter');
+const emailChangeRouter = require('./routers/emailChangeController');
//Panel
const panelRouter = require('./routers/panelRouter');
//Popup
@@ -119,6 +120,7 @@ app.use('/adminPanel', adminPanelRouter);
app.use('/c', channelRouter);
app.use('/newChannel', newChannelRouter);
app.use('/passwordReset', passwordResetRouter);
+app.use('/emailChange', emailChangeRouter);
//Panel
app.use('/panel', panelRouter);
//Popup
diff --git a/src/utils/loggerUtils.js b/src/utils/loggerUtils.js
index a62b196..4ed4fa6 100644
--- a/src/utils/loggerUtils.js
+++ b/src/utils/loggerUtils.js
@@ -16,8 +16,11 @@ along with this program. If not, see .*/
//At some point this will be a bit more advanced, right now it's just a placeholder :P
module.exports.errorHandler = function(res, msg, type = "Generic", status = 400){
- res.status(status);
- return res.send({errors: [{type, msg, date: new Date()}]});
+ //Some controllers do things after sending headers, for those, we should remain silent
+ if(!res.headersSent){
+ res.status(status);
+ return res.send({errors: [{type, msg, date: new Date()}]});
+ }
}
module.exports.exceptionHandler = function(res, err){
diff --git a/src/utils/scheduler.js b/src/utils/scheduler.js
index d783766..71aa672 100644
--- a/src/utils/scheduler.js
+++ b/src/utils/scheduler.js
@@ -21,8 +21,10 @@ const cron = require('node-cron');
const {userModel} = require('../schemas/user/userSchema');
const userBanModel = require('../schemas/user/userBanSchema');
const passwordResetModel = require('../schemas/user/passwordResetSchema');
+const emailChangeModel = require('../schemas/user/emailChangeSchema');
const channelModel = require('../schemas/channel/channelSchema');
const sessionUtils = require('./sessionUtils');
+const { email } = require('../validators/accountValidator');
module.exports.schedule = function(){
//Process hashed IP Records that haven't been recorded in a week or more
@@ -35,6 +37,8 @@ module.exports.schedule = function(){
cron.schedule('0 0 * * *', ()=>{sessionUtils.processExpiredAttempts()},{scheduled: true, timezone: "UTC"});
//Process expired password reset requests every night at midnight
cron.schedule('0 0 * * *', ()=>{passwordResetModel.processExpiredRequests()},{scheduled: true, timezone: "UTC"});
+ //Process expired email change requests every night at midnight
+ cron.schedule('0 0 * * *', ()=>{emailChangeModel.processExpiredRequests()},{scheduled: true, timezone: "UTC"});
}
module.exports.kickoff = function(){
@@ -46,7 +50,8 @@ module.exports.kickoff = function(){
channelModel.processExpiredBans();
//Process expired password reset requests that may have expired since last restart
passwordResetModel.processExpiredRequests();
-
+ //Process expired email change requests that may have expired since last restart
+ emailChangeModel.processExpiredRequests();
//Schedule jobs
module.exports.schedule();
diff --git a/src/views/404.ejs b/src/views/404.ejs
index 227885d..e067747 100644
--- a/src/views/404.ejs
+++ b/src/views/404.ejs
@@ -23,9 +23,12 @@ along with this program. If not, see . %>