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
- );
- }
+ await mailUtils.sendAddressVerification(requestDB, userDB, email);
//Clean our hands of the operation
return;
@@ -91,6 +71,7 @@ module.exports.post = async function(req, res){
return res.send({errors: validResult.array()});
}
}catch(err){
+ console.log(err)
return exceptionHandler(res, err);
}
}
\ No newline at end of file
diff --git a/src/controllers/api/account/registerController.js b/src/controllers/api/account/registerController.js
index 4755ab5..f2b294e 100644
--- a/src/controllers/api/account/registerController.js
+++ b/src/controllers/api/account/registerController.js
@@ -59,6 +59,7 @@ module.exports.post = async function(req, res){
return res.send({errors: validResult.array()});
}
}catch(err){
+ console.log(err);
return exceptionHandler(res, err);
}
}
\ No newline at end of file
diff --git a/src/controllers/profileController.js b/src/controllers/profileController.js
index 99146c4..c37fdf9 100644
--- a/src/controllers/profileController.js
+++ b/src/controllers/profileController.js
@@ -28,13 +28,17 @@ module.exports.get = async function(req, res){
try{
var profileName = req.url.slice(1) == '' ? (req.session.user ? req.session.user.user : null) : req.url.slice(1);
- const profile = await userModel.findProfile({user: profileName});
+ const profile = await userModel.findProfile({user: profileName}, true);
if(profile){
+ //If we have a user, check if the is looking at their own profile
+ const selfProfile = req.session.user ? profile.user == req.session.user.user : false;
+
res.render('profile', {
instance: config.instanceName,
user: req.session.user,
profile,
+ selfProfile,
csrfToken: csrfUtils.generateToken(req)
});
}else{
@@ -42,6 +46,7 @@ module.exports.get = async function(req, res){
instance: config.instanceName,
user: req.session.user,
profile: null,
+ selfProfile: false,
csrfToken: csrfUtils.generateToken(req)
});
}
diff --git a/src/schemas/user/userSchema.js b/src/schemas/user/userSchema.js
index fc5cd44..e6517f0 100644
--- a/src/schemas/user/userSchema.js
+++ b/src/schemas/user/userSchema.js
@@ -28,8 +28,10 @@ const statModel = require('../statSchema');
const flairModel = require('../flairSchema');
const permissionModel = require('../permissionSchema');
const emoteModel = require('../emoteSchema');
+const emailChangeModel = require('./emailChangeSchema');
//Utils
const hashUtil = require('../../utils/hashUtils');
+const mailUtil = require('../../utils/mailUtils');
const userSchema = new mongoose.Schema({
@@ -214,13 +216,7 @@ userSchema.statics.register = async function(userObj, ip){
//Check password confirmation matches
if(pass == passConfirm){
//Look for a user (case insensitive)
- var userDB = await this.findOne({user: new RegExp(user, 'i')});
-
- //If we didn't find a user and we submitted an email
- if(userDB == null && email != null){
- //Check to make sure no one's used this email address
- userDB = await this.findOne({email});
- }
+ 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"){
@@ -230,10 +226,17 @@ userSchema.statics.register = async function(userObj, ip){
const id = await statModel.incrementUserCount();
//Create user document in the database
- const newUser = await this.create({id, user, pass, email});
+ 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 new Error("Confirmation password doesn't match!");
@@ -268,7 +271,7 @@ userSchema.statics.authenticate = async function(user, pass, failLine = "Bad Use
}
}
-userSchema.statics.findProfile = async function(user){
+userSchema.statics.findProfile = async function(user, includeEmail = false){
//Catch null users
if(user == null || user.user == null){
return null;
@@ -298,7 +301,7 @@ userSchema.statics.findProfile = async function(user){
}
//return the profile
- return userDB.getProfile();
+ return userDB.getProfile(includeEmail);
}
}
@@ -425,7 +428,7 @@ userSchema.methods.getAuthenticatedSessions = async function(){
}
-userSchema.methods.getProfile = function(){
+userSchema.methods.getProfile = function(includeEmail = false){
//Create profile hashtable
const profile = {
id: this.id,
@@ -439,6 +442,11 @@ userSchema.methods.getProfile = function(){
bio: this.bio
};
+ //Include the email if we need to
+ if(includeEmail){
+ profile.email = this.email;
+ }
+
//return profile hashtable
return profile;
}
diff --git a/src/utils/mailUtils.js b/src/utils/mailUtils.js
index 4636d71..29d744a 100644
--- a/src/utils/mailUtils.js
+++ b/src/utils/mailUtils.js
@@ -54,4 +54,29 @@ module.exports.mailem = async function(to, subject, body, htmlBody = false){
//return the mail info
return sentMail;
+}
+
+module.exports.sendAddressVerification = async function(requestDB, userDB, newEmail){
+ //Send the reset url via email
+ await module.exports.mailem(
+ newEmail,
+ `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 module.exports.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
+ );
+ }
}
\ No newline at end of file
diff --git a/src/validators/accountValidator.js b/src/validators/accountValidator.js
index 777a9b5..377ecbf 100644
--- a/src/validators/accountValidator.js
+++ b/src/validators/accountValidator.js
@@ -34,7 +34,7 @@ module.exports = {
pronouns: (field = 'pronouns') => body(field).optional().escape().trim().isLength({min: 0, max: 15}),
- signature: (field = 'signature') => body(field).optional().escape().trim().isLength({min: 1, max: 150}),
+ signature: (field = 'signature') => body(field).optional().escape().trim().isLength({min: 1, max: 25}),
bio: (field = 'bio') => body(field).optional().escape().trim().isLength({min: 1, max: 1000}),
diff --git a/src/views/partial/popup/changeEmail.ejs b/src/views/partial/popup/changeEmail.ejs
new file mode 100644
index 0000000..6aaa253
--- /dev/null
+++ b/src/views/partial/popup/changeEmail.ejs
@@ -0,0 +1,22 @@
+<%# 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 . %>
+
+
Update Email
+
+
Enter new email and current password below:
+
+
+
\ No newline at end of file
diff --git a/src/views/partial/popup/changePassword.ejs b/src/views/partial/popup/changePassword.ejs
new file mode 100644
index 0000000..76abca8
--- /dev/null
+++ b/src/views/partial/popup/changePassword.ejs
@@ -0,0 +1,23 @@
+<%# 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 . %>
+
+
Update Password
+
+
Enter new email and current password below:
+
+
+
+
\ No newline at end of file
diff --git a/src/views/partial/profile/bio.ejs b/src/views/partial/profile/bio.ejs
index e15910c..0cc620b 100644
--- a/src/views/partial/profile/bio.ejs
+++ b/src/views/partial/profile/bio.ejs
@@ -16,9 +16,10 @@ along with this program. If not, see . %>
Bio:
<% if(selfProfile){ %>
-
<%- profile.bio %>
+ <%# Make sure to convert newlines to br so they display proepr %>
+
<%- profile.bio.replaceAll('\n',' ') %>
<% }else{ %>
-
<%- profile.bio %>
+
<%- profile.bio.replaceAll('\n',' ') %>
<% } %>
\ No newline at end of file
diff --git a/src/views/partial/profile/settings.ejs b/src/views/partial/profile/settings.ejs
index 4faea19..84a0ac8 100644
--- a/src/views/partial/profile/settings.ejs
+++ b/src/views/partial/profile/settings.ejs
@@ -15,6 +15,10 @@ You should have received a copy of the GNU Affero General Public License
along with this program. If not, see . %>
Account Settings
+ <% if(profile.email){ %>
+
Email Address:
+
<%= profile.email %>
+ <% } %>
diff --git a/src/views/profile.ejs b/src/views/profile.ejs
index 09cdbeb..ffb964b 100644
--- a/src/views/profile.ejs
+++ b/src/views/profile.ejs
@@ -18,7 +18,7 @@ along with this program. If not, see . %>
<%- include('partial/styles', {instance, user}); %>
<%- include('partial/csrfToken', {csrfToken}); %>
- <% var selfProfile = user ? profile ? profile.user == user.user : false : false %>
+ <% %>
<% if(profile){ %>
<%= instance %> - Profile: <%= profile.user %>
diff --git a/www/css/popup/changeEmail.css b/www/css/popup/changeEmail.css
new file mode 100644
index 0000000..af6fa23
--- /dev/null
+++ b/www/css/popup/changeEmail.css
@@ -0,0 +1,32 @@
+/*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 .*/
+
+#email-change-popup-content{
+ display: flex;
+ flex-direction: column;
+}
+
+#email-change-popup-title{
+ margin-bottom: 0;
+}
+
+#email-change-popup-caption{
+ margin-top: 0;
+}
+
+#email-change-popup-content input{
+ margin: 0.5em 0;
+}
\ No newline at end of file
diff --git a/www/css/popup/changePassword.css b/www/css/popup/changePassword.css
new file mode 100644
index 0000000..1c4de8f
--- /dev/null
+++ b/www/css/popup/changePassword.css
@@ -0,0 +1,32 @@
+/*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 .*/
+
+#password-change-popup-content{
+ display: flex;
+ flex-direction: column;
+}
+
+#password-change-popup-title{
+ margin-bottom: 0;
+}
+
+#password-change-popup-caption{
+ margin-top: 0;
+}
+
+#password-change-popup-content input{
+ margin: 0.5em 0;
+}
\ No newline at end of file
diff --git a/www/css/profile.css b/www/css/profile.css
index 0976aeb..55c00fb 100644
--- a/www/css/profile.css
+++ b/www/css/profile.css
@@ -41,6 +41,7 @@ along with this program. If not, see .*/
#account-settings-div{
display: flex;
flex-direction: column;
+ align-items: center;
padding: 1em;
}
@@ -57,16 +58,24 @@ along with this program. If not, see .*/
#profile-img{
position: relative;
+ display: flex;
+ justify-content: center;
+ height: 14em;
+ width: 14em;
+ margin: 1em 0;
}
#profile-img-content{
- margin: 1em 0;
+ cursor: pointer;
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
}
#profile-img-prompt{
position: absolute;
- left: -2em;
- right: -2em;
+ left: -1.5em;
+ right: -1.5em;
top: calc(50% - 1.3em);
}
@@ -85,7 +94,7 @@ p.profile-item{
margin: 0;
}
-span.profile-item-oneliner{
+.profile-item-oneliner{
text-wrap: nowrap
}
@@ -99,7 +108,7 @@ span.profile-item-oneliner{
height: fit-content;
min-height: 1.5em;
max-height: 5.8em;
- padding: 0.5em 0;
+ padding: 0.5em 0.2em;
border-bottom-right-radius: 0;
text-wrap: nowrap;
visibility: collapse;
@@ -124,11 +133,14 @@ span.profile-item-oneliner{
#profile-bio-prompt{
width: 30VW;
- height: 11em;
+ height: 19.4em;
resize: vertical;
}
-/* temp */
-input:not([type="checkbox"]):not(.navbar-item):not(.profile-item-prompt){
- display: block;
+#account-email-label{
+ margin: 0.2em 0;
+}
+
+#account-email-address{
+ margin-top: 0.2em
}
\ No newline at end of file
diff --git a/www/js/profile.js b/www/js/profile.js
index 050053b..a2aae0e 100644
--- a/www/js/profile.js
+++ b/www/js/profile.js
@@ -37,7 +37,7 @@ class profileUpdatePrompt{
closePrompt(event){
//Check if we're finished
- const fin = event.key == "Escape" || event.key == "Enter";
+ const fin = event.key == "Escape" || (event.key == "Enter" && !event.shiftKey);
//IF we are
if(fin){
@@ -63,6 +63,12 @@ class profileUpdateTextPrompt extends profileUpdatePrompt{
//Call derived method
super.popPrompt();
+ //For each line break
+ this.contentNode.querySelectorAll('br').forEach((br)=>{
+ //Replace the linenreaks with newlines to get the text area to play nice
+ br.outerHTML = "\n"
+ });
+
//If we're filling the prompt
if(this.fillPrompt){
//Fill the prompt content and placeholder
@@ -85,7 +91,7 @@ class profileUpdateTextPrompt extends profileUpdatePrompt{
this.contentNode.style.display = 'inline-block';
//If we're not cancelling
- if(event.key == "Enter"){
+ if(event.key == "Enter" && !event.shiftKey){
//Create empty update object
let updateObj = {};
@@ -95,8 +101,8 @@ class profileUpdateTextPrompt extends profileUpdatePrompt{
//Send er' off
const update = await utils.ajax.updateProfile(updateObj);
- //Fill content from update
- this.contentNode.innerHTML = update[this.field];
+ //Fill content from update, make sure to add line breaks for the bio
+ this.contentNode.innerHTML = update[this.field].replaceAll('\n',' ');
}
}
}
@@ -200,9 +206,10 @@ class tokeList{
}
}
-class deleteAccountButton{
- constructor(){
- this.deleteLink = document.querySelector('#account-settings-delete-button');
+class accountSettingsButton{
+ constructor(field, popupClass){
+ this.deleteLink = document.querySelector(`#account-settings-${field}-button`);
+ this.popupClass = popupClass;
if(this.deleteLink != null){
this.setupInput();
@@ -210,11 +217,74 @@ class deleteAccountButton{
}
setupInput(){
- this.deleteLink.addEventListener("click",this.deletePrompt.bind(this));
+ this.deleteLink.addEventListener("click",this.showPopup.bind(this));
}
- async deletePrompt(event){
- this.popup = new deleteAccountPopup();
+ async showPopup(event){
+ this.popup = new this.popupClass();
+ }
+}
+
+class changeEmailPopup{
+ constructor(){
+ this.popup = new canopyUXUtils.popup("changeEmail", true, this.asyncConstructor.bind(this), this.asyncConstructor);
+ }
+
+ asyncConstructor(){
+ this.emailPrompt = document.querySelector('#email-change-popup-email');
+ this.passPrompt = document.querySelector('#email-change-popup-password');
+
+ this.setupInput();
+ }
+
+ setupInput(){
+ this.emailPrompt.addEventListener('keydown', this.emailRequest.bind(this));
+ this.passPrompt.addEventListener('keydown', this.emailRequest.bind(this));
+ }
+
+ async emailRequest(event){
+ if(event.key == "Enter"){
+ await utils.ajax.requestEmailChange(this.emailPrompt.value, this.passPrompt.value);
+ }
+ }
+}
+
+class changePasswordPopup{
+ constructor(){
+ this.popup = new canopyUXUtils.popup("changePassword", true, this.asyncConstructor.bind(this), this.asyncConstructor);
+ }
+
+ asyncConstructor(){
+ this.oldPassPrompt = document.querySelector('#password-change-popup-old-password');
+ this.newPassPrompt = document.querySelector('#password-change-popup-new-password');
+ this.confirmPassPrompt = document.querySelector('#password-change-popup-confirm-new-password');
+
+ this.setupInput();
+ }
+
+ setupInput(){
+ this.oldPassPrompt.addEventListener('keydown', this.emailRequest.bind(this));
+ this.newPassPrompt.addEventListener('keydown', this.emailRequest.bind(this));
+ this.confirmPassPrompt.addEventListener('keydown', this.emailRequest.bind(this));
+ }
+
+ async emailRequest(event){
+ if(event.key == "Enter"){
+ const updateObj = {};
+
+ updateObj.passChange = {
+ oldPass: this.oldPassPrompt.value,
+ newPass: this.newPassPrompt.value,
+ confirmPass: this.confirmPassPrompt.value
+ };
+
+ const response = await utils.ajax.updateProfile(updateObj);
+
+ if(response != null){
+ //Return user homepage after good pass change, as we've probably been logged out by the server for security.
+ window.location.pathname = '/';
+ }
+ }
}
}
@@ -246,5 +316,6 @@ const pronounsPrompt = new profileUpdateTextPrompt('pronouns');
const signaturePrompt = new profileUpdateTextPrompt('signature');
const bioPrompt = new profileUpdateTextPrompt('bio', true);
const profileTokeList = new tokeList();
-const accountPassResetPrompt = new passwordResetPrompt();
-const accountDeleteButton = new deleteAccountButton();
\ No newline at end of file
+const accountEmailChange = new accountSettingsButton('update-email', changeEmailPopup);
+const accountPasswordChange = new accountSettingsButton('change-password', changePasswordPopup);
+const accountDeleteButton = new accountSettingsButton('delete', deleteAccountPopup);
\ No newline at end of file