/*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 {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() }, lastActive: { type: mongoose.SchemaTypes.Date, required: true, default: new Date(0) }, 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: "/nonfree/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: "/nonfree/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, lastActive: this.lastActive, 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);