From 977e8e1e2e69d0eda43e1d7aee28d4b49d585fe5 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Wed, 1 Jan 2025 17:36:43 -0500 Subject: [PATCH] Finished up with IP-Ban functionality on the back-end. Just need to finish up with UI. --- config.example.json | 1 + src/app/channel/channelManager.js | 62 ++++-- .../account/emailChangeRequestController.js | 1 - .../api/account/registerController.js | 17 +- src/controllers/api/admin/banController.js | 4 +- src/routers/api/adminRouter.js | 2 +- src/schemas/channel/channelSchema.js | 40 ++-- src/schemas/emoteSchema.js | 1 - src/schemas/flairSchema.js | 1 - src/schemas/tokebot/tokeCommandSchema.js | 1 - src/schemas/user/userBanSchema.js | 178 ++++++++++++++++-- src/utils/loggerUtils.js | 15 ++ src/utils/sessionUtils.js | 19 +- www/css/global.css | 4 + www/js/adminPanel.js | 4 +- www/js/utils.js | 1 + 16 files changed, 284 insertions(+), 67 deletions(-) diff --git a/config.example.json b/config.example.json index 5cdac66..9ca23e9 100644 --- a/config.example.json +++ b/config.example.json @@ -1,5 +1,6 @@ { "instanceName": "Canopy", + "verbose": false, "port": 8080, "protocol": "http", "domain": "localhost", diff --git a/src/app/channel/channelManager.js b/src/app/channel/channelManager.js index edd7e2d..9c3e6ab 100644 --- a/src/app/channel/channelManager.js +++ b/src/app/channel/channelManager.js @@ -18,6 +18,7 @@ along with this program. If not, see .*/ const channelModel = require('../../schemas/channel/channelSchema'); const emoteModel = require('../../schemas/emoteSchema'); const {userModel} = require('../../schemas/user/userSchema'); +const userBanModel = require('../../schemas/user/userBanSchema'); const loggerUtils = require('../../utils/loggerUtils'); const csrfUtils = require('../../utils/csrfUtils'); const activeChannel = require('./activeChannel'); @@ -39,23 +40,29 @@ module.exports = class{ } async handleConnection(socket){ - //Prevent logged out connections and authenticate socket - if(socket.request.session.user != null){ - try{ + try{ + //ensure unbanned ip and valid CSRF token + if(!(await this.validateSocket(socket))){ + socket.disconnect(); + return; + } + + //Prevent logged out connections and authenticate socket + if(socket.request.session.user != null){ //Authenticate socket const userDB = await this.authSocket(socket); //Get the active channel based on the socket var {activeChan, chanDB} = await this.getActiveChan(socket); - //Check for ban + //Check for chan ban const ban = await chanDB.checkBanByUserDoc(userDB); if(ban != null){ //Toss out banned user's if(ban.expirationDays < 0){ - socket.emit("kick", {type: "Banned", reason: "You have been permanently banned from this channel!"}); + socket.emit("kick", {type: "kicked", reason: "You have been permanently banned from this channel!"}); }else{ - socket.emit("kick", {type: "Banned", reason: `You have been temporarily banned from this channel, and will be unbanned in ${ban.getDaysUntilExpiration()} day(s)!`}); + socket.emit("kick", {type: "kicked", reason: `You have been temporarily banned from this channel, and will be unbanned in ${ban.getDaysUntilExpiration()} day(s)!`}); } socket.disconnect(); return; @@ -68,24 +75,41 @@ module.exports = class{ //Connect the socket to it's given channel //Lil' hacky to pass chanDB like that, but why double up on DB calls? activeChan.handleConnection(userDB, chanDB, socket); - }catch(err){ - //Flip a table if something fucks up - return loggerUtils.socketCriticalExceptionHandler(socket, err); + }else{ + //Toss out anon's + socket.emit("kick", {type: "disconnected", reason: "You must log-in to join this channel!"}); + socket.disconnect(); + return; } - }else{ - //Toss out anon's - socket.emit("kick", {type: "Disconnected", reason: "You must log-in to join this channel!"}); - socket.disconnect(); - return; + }catch(err){ + //Flip a table if something fucks up + return loggerUtils.socketCriticalExceptionHandler(socket, err); + } + } + + async validateSocket(socket){ + //Look for ban by IP + const ipBanDB = await userBanModel.checkBanByIP(socket.handshake.address); + + //If this ip is randy bobandy + if(ipBanDB != null){ + //tell it to fuck off + socket.emit("kick", {type: "kicked", reason: "The IP address you are trying to connect from has been banned!"}); + return false; } + + + //Check for Cross-Site Request Forgery + if(!csrfUtils.isRequestValid(socket.request)){ + socket.emit("kick", {type: "disconnected", reason: "Invalid CSRF Token!"}); + return false; + } + + + return true; } async authSocket(socket){ - //Check for Cross-Site Request Forgery - if(!csrfUtils.isRequestValid(socket.request)){ - throw new Error("Invalid CSRF Token!"); - } - //Find the user in the Database since the session won't store enough data to fulfill our needs :P const userDB = await userModel.findOne({user: socket.request.session.user.user}); diff --git a/src/controllers/api/account/emailChangeRequestController.js b/src/controllers/api/account/emailChangeRequestController.js index f1f7181..83fc654 100644 --- a/src/controllers/api/account/emailChangeRequestController.js +++ b/src/controllers/api/account/emailChangeRequestController.js @@ -71,7 +71,6 @@ 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 f2b294e..3fe962b 100644 --- a/src/controllers/api/account/registerController.js +++ b/src/controllers/api/account/registerController.js @@ -52,6 +52,22 @@ module.exports.post = async function(req, res){ return errorHandler(res, 'Cannot re-create banned account!', 'unauthorized'); } + //Look for ban by IP + const ipBanDB = await userBanModel.checkBanByIP(req.ip); + + //If this ip is randy bobandy + if(ipBanDB != null){ + //Make the code and message look pretty (kinda) at the same time + const banMsg = [ + 'The IP address you are trying to register an account from has been banned.', + 'If you beleive this to be an error feel free to reach out to your server administrator.', + 'Otherwise, fuck off :)' + ]; + + //tell it to fuck off + return errorHandler(res, banMsg.join('
'), 'unauthorized'); + } + await userModel.register(user, req.ip); return res.sendStatus(200); }else{ @@ -59,7 +75,6 @@ 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/admin/banController.js b/src/controllers/api/admin/banController.js index fc7a549..15a1ff0 100644 --- a/src/controllers/api/admin/banController.js +++ b/src/controllers/api/admin/banController.js @@ -40,7 +40,7 @@ module.exports.post = async function(req, res){ try{ const validResult = validationResult(req); if(validResult.isEmpty()){ - const {user, permanent, expirationDays} = matchedData(req); + const {user, permanent, ipBan, expirationDays} = matchedData(req); const userDB = await userModel.findOne({user}); if(userDB == null){ @@ -54,7 +54,7 @@ module.exports.post = async function(req, res){ return errorHandler(res, 'You cannot ban peer/outranking users', 'Unauthorized', 401); } - await banModel.banByUserDoc(userDB, permanent, expirationDays); + await banModel.banByUserDoc(userDB, permanent, expirationDays, ipBan); res.status(200); return res.send(await banModel.getBans()); diff --git a/src/routers/api/adminRouter.js b/src/routers/api/adminRouter.js index 242e74f..71a7aa4 100644 --- a/src/routers/api/adminRouter.js +++ b/src/routers/api/adminRouter.js @@ -50,7 +50,7 @@ router.post('/changeRank', permissionSchema.reqPermCheck("changeRank"), account //Ban router.get('/ban', permissionSchema.reqPermCheck("adminPanel"), banController.get); //Sometimes they're so simple you don't need to put your validators in their own special place :P -router.post('/ban', permissionSchema.reqPermCheck("banUser"), accountValidator.user(), body("permanent").isBoolean(), body("expirationDays").isInt(), banController.post); +router.post('/ban', permissionSchema.reqPermCheck("banUser"), accountValidator.user(), body("permanent").isBoolean(), body("ipBan").isBoolean(), body("expirationDays").isInt(), banController.post); router.delete('/ban', permissionSchema.reqPermCheck("banUser"), accountValidator.user(), banController.delete); //TokeCommands router.get('/tokeCommands', permissionSchema.reqPermCheck("adminPanel"), tokeCommandController.get); diff --git a/src/schemas/channel/channelSchema.js b/src/schemas/channel/channelSchema.js index 32eb67a..b147fe6 100644 --- a/src/schemas/channel/channelSchema.js +++ b/src/schemas/channel/channelSchema.js @@ -328,17 +328,9 @@ channelSchema.methods.rankCrawl = async function(userDB,cb){ //TODO: replace this with rank check function shared with setRank this.rankList.forEach(async (rankObj, rankIndex) => { //check against user ID to speed things up - if(rankObj.user.user == null){ - if(rankObj.user.toString() == userDB._id.toString()){ - //If we found a match, call back - cb(rankObj, rankIndex); - } - }else{ - //in case someone populated the users - if(rankObj.user.user == userDB.user){ - //If we found a match, call back - cb(rankObj, rankIndex); - } + if(rankObj.user != null && rankObj.user._id.toString() == userDB._id.toString()){ + //If we found a match, call back + cb(rankObj, rankIndex); } }); } @@ -379,14 +371,22 @@ channelSchema.methods.setRank = async function(userDB,rank){ channelSchema.methods.getRankList = async function(){ //Create an empty array to hold the user list const rankList = new Map() + //Create temp rank list to replace the current one in the advant we have busted users + let tempRankList = []; + //Flag that lets us know we gotta save + let reqSave = false; //Populate the user objects in our ranklist based off of their DB ID's await this.populate('rankList.user'); //For each rank object in the rank list - this.rankList.forEach(async (rankObj, rankObjIndex) => { + for(rankObjIndex in this.rankList){ + const rankObj = this.rankList[rankObjIndex]; //If the use still exists if(rankObj.user != null){ + //Push current rank object to the temp rank list in the advant that it doesn't get saved + tempRankList.push(rankObj); + //Create a new user object from rank object data const userObj = { id: rankObj.user.id, @@ -397,12 +397,20 @@ channelSchema.methods.getRankList = async function(){ //Add our user object to the list rankList.set(rankObj.user.user, userObj); + //Otherwise if it's an invalid rank for a deleted user }else{ - //Otherwise clean deleted users out of list - this.rankList.splice(rankObjIndex,1); - await this.save(); + //Ignore the rank object and throw the save flag to save the temporary rank list + reqSave = true; } - }); + } + + //if we need to save the temp rank list + if(reqSave){ + //set rank list + this.rankList = tempRankList; + //save + await this.save(); + } //return userList return rankList; diff --git a/src/schemas/emoteSchema.js b/src/schemas/emoteSchema.js index f556045..dafda2a 100644 --- a/src/schemas/emoteSchema.js +++ b/src/schemas/emoteSchema.js @@ -75,7 +75,6 @@ emoteSchema.statics.loadDefaults = async function(){ }catch(err){ if(emote != null){ - console.log(err); console.log(`Error loading emote [${emote.name}]:`); }else{ console.log("Error, null emote:"); diff --git a/src/schemas/flairSchema.js b/src/schemas/flairSchema.js index c458447..a10589e 100644 --- a/src/schemas/flairSchema.js +++ b/src/schemas/flairSchema.js @@ -60,7 +60,6 @@ flairSchema.statics.loadDefaults = async function(){ }catch(err){ if(flair != null){ - console.log(err); console.log(`Error loading flair '${flair.name}':`); }else{ console.log("Error, null flair:"); diff --git a/src/schemas/tokebot/tokeCommandSchema.js b/src/schemas/tokebot/tokeCommandSchema.js index d79111e..fffbe9a 100644 --- a/src/schemas/tokebot/tokeCommandSchema.js +++ b/src/schemas/tokebot/tokeCommandSchema.js @@ -93,7 +93,6 @@ tokeCommandSchema.statics.loadDefaults = async function(){ }catch(err){ if(toke != null){ - console.log(err); console.log(`Error loading toke command: '!${toke}'`); }else{ console.log("Error, null toke!"); diff --git a/src/schemas/user/userBanSchema.js b/src/schemas/user/userBanSchema.js index 03bad3c..947730d 100644 --- a/src/schemas/user/userBanSchema.js +++ b/src/schemas/user/userBanSchema.js @@ -18,6 +18,7 @@ along with this program. If not, see .*/ const {mongoose} = require('mongoose'); //Local Imports +const hashUtil = require('../../utils/hashUtils'); const {userModel} = require('./userSchema'); const userBanSchema = new mongoose.Schema({ @@ -25,16 +26,20 @@ const userBanSchema = new mongoose.Schema({ type: mongoose.SchemaTypes.ObjectID, ref: "user" }, - //To be used in future when ip-hashing/better session tracking is implemented ips: { - type: [mongoose.SchemaTypes.String], - required: false + plaintext: { + type: [mongoose.SchemaTypes.String], + required: false + }, + hashed: { + type: [mongoose.SchemaTypes.String], + required: false + } }, - //To be used in future when alt-detection has been implemented - alts: { + alts: [{ type: mongoose.SchemaTypes.ObjectID, ref: "user" - }, + }], deletedNames: { type: [mongoose.SchemaTypes.String], required: false @@ -58,15 +63,92 @@ const userBanSchema = new mongoose.Schema({ } }); +userBanSchema.statics.checkBanByIP = async function(ip){ + //Get hash of ip + const ipHash = hashUtil.hashIP(ip); + //Get all bans + const banDB = await this.find({}); + //Create null variable to hold any found ban + let foundBan = null; + + //For every ban + for(ban of banDB){ + //Create empty list to hold unmatched hashes in the advent that we match one + let tempHashes = []; + //Create flag to throw to save tempHashes in the advent that we have matches we dont want to save as hashes + let saveBan = false; + + + //For every plaintext IP in the ban + for(ipIndex in ban.ips.plaintext){ + //Get the current ip + const curIP = ban.ips.plaintext[ipIndex]; + + //Check the current IP against the given ip + if(ip == curIP){ + //If it matches we found the ban + foundBan = ban; + } + } + + //For every hashed IP in the ban + for(ipIndex in ban.ips.hashed){ + //Get the current ip hash + const curHash = ban.ips.hashed[ipIndex]; + + //Check the current hash against the given hash + if(ipHash == curHash){ + //If it matches we found the ban + foundBan = ban; + + //Push the match to plaintext IPs so we know who the fucker is + ban.ips.plaintext.push(ip); + + //Throw the save ban flag to save the ban + saveBan = true; + //Otherwise + }else{ + //Keep the hash since it hasn't been matched yet + tempHashes.push(curHash); + } + } + + //If we matched a hashed ip and we need to save it as plaintext + if(saveBan){ + //Keep unmatched hashes + ban.ips.hashed = tempHashes; + + //Save the current ban + await ban.save(); + } + } + + return foundBan; +} + userBanSchema.statics.checkBanByUserDoc = async function(userDB){ const banDB = await this.find({}); var foundBan = null; banDB.forEach((ban) => { if(ban.user != null){ + //if we found a match if(ban.user.toString() == userDB._id.toString()){ + //Set found ban foundBan = ban; } + + //For each banned alt + for(altIndex in ban.alts){ + //get current alt + const alt = ban.alts[altIndex]; + + //if the alt matches our user + if(alt._id.toString() == userDB._id.toString()){ + //Set found ban + foundBan = ban; + } + } } }); @@ -99,7 +181,7 @@ userBanSchema.statics.checkProcessedBans = async function(user){ return foundBan; } -userBanSchema.statics.banByUserDoc = async function(userDB, permanent, expirationDays){ +userBanSchema.statics.banByUserDoc = async function(userDB, permanent, expirationDays, ipBan = false){ //Prevent missing users if(userDB == null){ throw new Error("User not found") @@ -110,6 +192,7 @@ userBanSchema.statics.banByUserDoc = async function(userDB, permanent, expiratio throw new Error("User already banned"); } + //Verify time to expire/delete depending on action if(expirationDays < 0){ throw new Error("Expiration Days must be a positive integer!"); }else if(expirationDays < 30 && permanent){ @@ -118,20 +201,65 @@ userBanSchema.statics.banByUserDoc = async function(userDB, permanent, expiratio throw new Error("Expiration/Deletion date cannot be longer than half a year out from the original ban date."); } - //Log the user out - if(permanent){ - await userDB.killAllSessions(`Your account has been permanently banned, and will be nuked from the database in ${expirationDays} day(s).`); - }else{ - await userDB.killAllSessions(`Your account has been temporarily banned, and will be reinstated in: ${expirationDays} day(s).`); - } + await banSessions(userDB); //Add the ban to the database - return await this.create({user: userDB._id, permanent, expirationDays}); + const banDB = await this.create({user: userDB._id, permanent, expirationDays}); + + //If we're banning the users IP + if(ipBan){ + //Scrape IP's from current user into the ban record + await scrapeUserIPs(userDB); + + //Populate the users alts + await userDB.populate('alts'); + + //For each of the users alts + for(altIndex in userDB.alts){ + //Add the current alt to the ban record + banDB.alts.push(userDB.alts[altIndex]._id); + + //Scrape out the IPs from the current alt into the ban record + await scrapeUserIPs(userDB.alts[altIndex]); + + //Kill all of alts sessions + await banSessions(userDB.alts[altIndex]); + } + + //Save commited IP information to the ban record + await banDB.save(); + + async function scrapeUserIPs(curRecord){ + //For each hashed ip on record for this user + for(hashIndex in curRecord.recentIPs){ + //Look for any occurance of the current hash + const foundHash = banDB.ips.hashed.indexOf(curRecord.recentIPs[hashIndex].ipHash); + + //If its not listed in the ban record + if(foundHash == -1){ + //Add it to the list of hashed IPs for this ban + banDB.ips.hashed.push(curRecord.recentIPs[hashIndex].ipHash); + } + } + } + } + + //return the ban record + return banDB; + + async function banSessions(user){ + //Log the user out + if(permanent){ + await user.killAllSessions(`Your account has been permanently banned, and will be nuked from the database in ${expirationDays} day(s).`); + }else{ + await user.killAllSessions(`Your account has been temporarily banned, and will be reinstated in: ${expirationDays} day(s).`); + } + } } -userBanSchema.statics.ban = async function(user, permanent, expirationDays){ +userBanSchema.statics.ban = async function(user, permanent, expirationDays, ipBan){ const userDB = await userModel.findOne({user: user.user}); - return this.banByUserDoc(userDB, permanent, expirationDays); + return this.banByUserDoc(userDB, permanent, expirationDays, ipBan); } userBanSchema.statics.unbanByUserDoc = async function(userDB){ @@ -217,6 +345,7 @@ userBanSchema.statics.getBans = async function(){ userBanSchema.statics.processExpiredBans = async function(){ const banDB = await this.find({}); + //Firem all off all at once seperately without waiting for one another banDB.forEach(async (ban) => { //This ban was already processed, and it's user has been deleted. There is no more to be done... if(ban.user == null){ @@ -227,14 +356,27 @@ userBanSchema.statics.processExpiredBans = async function(){ if(ban.getDaysUntilExpiration() <= 0){ //If the ban is permanent if(ban.permanent){ - //Populate the user field + //Populate the user and alt fields await ban.populate('user'); + await ban.populate('alts'); //Add the name to our deleted names list ban.deletedNames.push(ban.user.user); //Hey hey hey, goodbye! await userModel.deleteOne({_id: ban.user._id}); //Empty out the reference ban.user = null; + + //For every alt + for(alt of ban.alts){ + //Add the alts name to the deleted names list + ban.deletedNames.push(alt.user); + //Motherfuckin' Kablewie! + await userModel.deleteOne({_id: alt._id}); + } + + //Clear out the alts array + ban.alts = []; + //Save the ban await ban.save(); }else{ @@ -242,7 +384,7 @@ userBanSchema.statics.processExpiredBans = async function(){ await this.deleteOne({_id: ban._id}); } } - }) + }); } //methods diff --git a/src/utils/loggerUtils.js b/src/utils/loggerUtils.js index 4ed4fa6..efa3239 100644 --- a/src/utils/loggerUtils.js +++ b/src/utils/loggerUtils.js @@ -14,6 +14,9 @@ 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'); + //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){ //Some controllers do things after sending headers, for those, we should remain silent @@ -24,11 +27,23 @@ module.exports.errorHandler = function(res, msg, type = "Generic", status = 400) } module.exports.exceptionHandler = function(res, err){ + //If we're being verbose + if(config.verbose){ + //Log the error + console.log(err) + } + //if not yell at the browser for fucking up, and tell it what it did wrong. module.exports.errorHandler(res, err.message, "Caught Exception"); } module.exports.socketExceptionHandler = function(socket, err){ + //If we're being verbose + if(config.verbose){ + //Log the error + console.log(err) + } + //if not yell at the browser for fucking up, and tell it what it did wrong. return socket.emit("error", {errors: [{type: "Caught Exception", msg: err.message, date: new Date()}]}); } diff --git a/src/utils/sessionUtils.js b/src/utils/sessionUtils.js index 39e35a2..7395f5a 100644 --- a/src/utils/sessionUtils.js +++ b/src/utils/sessionUtils.js @@ -32,6 +32,15 @@ module.exports.authenticateSession = async function(user, pass, req){ //Grab previous attempts const attempt = failedAttempts.get(user); + //Look for ban by IP + const ipBanDB = await userBanModel.checkBanByIP(req.ip); + + //If this ip is randy bobandy + if(ipBanDB != null){ + //tell it to fuck off + throw new Error(`The IP address you are trying to login from has been banned.`); + } + //If we have failed attempts if(attempt != null){ //If we have more failed attempts than allowed @@ -53,13 +62,15 @@ module.exports.authenticateSession = async function(user, pass, req){ //Authenticate the session const userDB = await userModel.authenticate(user, pass); - const banDB = await userBanModel.checkBanByUserDoc(userDB); + + //Check for user ban + const userBanDB = await userBanModel.checkBanByUserDoc(userDB); //If the user is banned - if(banDB){ + if(userBanDB){ //Make the number a little prettier despite the lack of precision since we're not doing calculations here :P - const expiration = banDB.getDaysUntilExpiration() < 1 ? 0 : banDB.getDaysUntilExpiration(); - if(banDB.permanent){ + const expiration = userBanDB.getDaysUntilExpiration() < 1 ? 0 : userBanDB.getDaysUntilExpiration(); + if(userBanDB.permanent){ throw new Error(`Your account has been permanently banned, and will be nuked from the database in: ${expiration} day(s)`); }else{ throw new Error(`Your account has been temporarily banned, and will be reinstated in: ${expiration} day(s)`); diff --git a/www/css/global.css b/www/css/global.css index 41771d6..b1d666c 100644 --- a/www/css/global.css +++ b/www/css/global.css @@ -150,6 +150,10 @@ p.navbar-item, input.navbar-item{ margin-top: 0; } +.popup-plaintext-content{ + text-align: center; +} + /* tooltip */ div.tooltip{ position: fixed; diff --git a/www/js/adminPanel.js b/www/js/adminPanel.js index e035538..819f563 100644 --- a/www/js/adminPanel.js +++ b/www/js/adminPanel.js @@ -139,7 +139,7 @@ class canopyAdminUtils{ } } - async banUser(user, permanent, expirationDays){ + async banUser(user, permanent, ipBan, expirationDays){ var response = await fetch(`/api/admin/ban`,{ method: "POST", headers: { @@ -147,7 +147,7 @@ class canopyAdminUtils{ "x-csrf-token": utils.ajax.getCSRFToken() }, //Unfortunately JSON doesn't natively handle ES6 maps, and god forbid someone update the standard in a way that's backwards compatible... - body: JSON.stringify({user, permanent, expirationDays}) + body: JSON.stringify({user, permanent, ipBan, expirationDays}) }); if(response.status == 200){ diff --git a/www/js/utils.js b/www/js/utils.js index f8bd2cc..b723215 100644 --- a/www/js/utils.js +++ b/www/js/utils.js @@ -234,6 +234,7 @@ class canopyUXUtils{ this.contentDiv.innerHTML = await utils.ajax.getPopup(this.content); }else{ this.contentDiv.innerHTML = this.content; + this.contentDiv.classList.add('popup-plaintext-content'); } //display popup nodes