diff --git a/src/controllers/api/account/registerController.js b/src/controllers/api/account/registerController.js index 1ea0d2c..b7d1bf7 100644 --- a/src/controllers/api/account/registerController.js +++ b/src/controllers/api/account/registerController.js @@ -35,10 +35,10 @@ module.exports.post = async function(req, res){ //if we found any related nuked bans if(nukedBans != null){ //Shit our pants! - return errorHandler(res, 'Cannon re-create banned account!', 'unauthorized'); + return errorHandler(res, 'Cannot get alts for non-existant user!'); } - await userModel.register(user) + await userModel.register(user, req.ip); return res.sendStatus(200); }else{ res.status(400); diff --git a/src/controllers/tooltip/altListController.js b/src/controllers/tooltip/altListController.js new file mode 100644 index 0000000..d192d48 --- /dev/null +++ b/src/controllers/tooltip/altListController.js @@ -0,0 +1,46 @@ +/*Canopy - The next generation of stoner streaming software +Copyright (C) 2024 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 {validationResult, matchedData} = require('express-validator'); + +//local imports +const {userModel} = require('../../schemas/userSchema'); +const {exceptionHandler, errorHandler} = require('../../utils/loggerUtils'); + +//root index functions +module.exports.get = async function(req, res){ + try{ + const validResult = validationResult(req); + + if(validResult.isEmpty()){ + const data = matchedData(req); + const userDB = await userModel.findOne({user: data.user}); + + if(userDB == null){ + return errorHandler(res, 'Cannot re-create banned account!', 'unauthorized'); + } + + return res.render('partial/tooltip/altList', {alts: await userDB.getAltProfiles()}); + }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/routers/tooltipRouter.js b/src/routers/tooltipRouter.js new file mode 100644 index 0000000..45080e2 --- /dev/null +++ b/src/routers/tooltipRouter.js @@ -0,0 +1,32 @@ +/*Canopy - The next generation of stoner streaming software +Copyright (C) 2024 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 altListController = require("../controllers/tooltip/altListController"); +const permissionSchema = require("../schemas/permissionSchema"); +const accountValidator = require("../validators/accountValidator"); + +//globals +const router = Router(); + +//routing functions +router.get('/altList', accountValidator.user(), permissionSchema.reqPermCheck("adminPanel"), altListController.get); + +module.exports = router; diff --git a/src/schemas/userSchema.js b/src/schemas/userSchema.js index 6878414..3d2b8d3 100644 --- a/src/schemas/userSchema.js +++ b/src/schemas/userSchema.js @@ -31,6 +31,7 @@ const permissionModel = require('./permissionSchema'); const emoteModel = require('./emoteSchema'); //Utils const hashUtil = require('../utils/hashUtils'); +const { profile } = require('console'); const userSchema = new mongoose.Schema({ @@ -135,6 +136,10 @@ const userSchema = new mongoose.Schema({ required: true, default: new Date() } + }], + alts:[{ + type: mongoose.SchemaTypes.ObjectID, + ref: "user" }] }); @@ -183,18 +188,33 @@ userSchema.pre('save', async function (next){ }); //statics -userSchema.statics.register = async function(userObj){ +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){ - const userDB = await this.findOne({$or: email ? [{user}, {email}] : [{user}]}); + //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}); + } //If the user is found or someones trying to impersonate tokeboi if(userDB || user.toLowerCase() == "tokebot"){ throw new Error("User name/email already taken!"); }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, email}); + + //Tattoo the hashed IP used to register to the new user + await newUser.tattooIPRecord(ip); } }else{ throw new Error("Confirmation password doesn't match!"); @@ -397,6 +417,23 @@ userSchema.methods.getProfile = function(){ return profile; } +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; +} + userSchema.methods.setFlair = async function(flair){ //Find flair by name const flairDB = await flairModel.findOne({name: flair}); @@ -471,6 +508,10 @@ userSchema.methods.tattooIPRecord = async function(ip){ //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, @@ -478,6 +519,35 @@ userSchema.methods.tattooIPRecord = async function(ip){ 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); diff --git a/src/server.js b/src/server.js index 1525b31..6e0ad85 100644 --- a/src/server.js +++ b/src/server.js @@ -38,6 +38,7 @@ const channelRouter = require('./routers/channelRouter'); const newChannelRouter = require('./routers/newChannelRouter'); const panelRouter = require('./routers/panelRouter'); const popupRouter = require('./routers/popupRouter'); +const tooltipRouter = require('./routers/tooltipRouter'); const apiRouter = require('./routers/apiRouter'); //Define Config @@ -98,6 +99,8 @@ app.use('/newChannel', newChannelRouter); app.use('/panel', panelRouter); //Popup app.use('/popup', popupRouter); +//tooltip +app.use('/tooltip', tooltipRouter); //Bot-Ready app.use('/api', apiRouter); diff --git a/src/validators/accountValidator.js b/src/validators/accountValidator.js index d875abd..82bcdf7 100644 --- a/src/validators/accountValidator.js +++ b/src/validators/accountValidator.js @@ -21,7 +21,7 @@ const { check, body, checkSchema, checkExact} = require('express-validator'); const {isRank} = require('./permissionsValidator'); module.exports = { - user: (field = 'user') => body(field).escape().trim().isLength({min: 1, max: 22}), + user: (field = 'user') => check(field).escape().trim().isLength({min: 1, max: 22}), //Password security requirements may change over time, therefore we should only validate against strongPassword() when creating new accounts //that way we don't break old ones upon change diff --git a/src/views/partial/adminPanel/userList.ejs b/src/views/partial/adminPanel/userList.ejs index 6119dcd..fffe830 100644 --- a/src/views/partial/adminPanel/userList.ejs +++ b/src/views/partial/adminPanel/userList.ejs @@ -53,7 +53,7 @@ along with this program. If not, see .--> - + <%- curUser.user %> diff --git a/src/views/partial/tooltip/altList.ejs b/src/views/partial/tooltip/altList.ejs new file mode 100644 index 0000000..3ad3358 --- /dev/null +++ b/src/views/partial/tooltip/altList.ejs @@ -0,0 +1,28 @@ + +<% if(alts.length > 0){ %> +

Known Alts:

+ <% for(let alt in alts){%> +
+

User: <%- alts[alt].user %>

+

ID: <%- alts[alt].id %>

+

Signature: <%- alts[alt].signature %>

+

Toke Count: <%- alts[alt].tokeCount %>

+

Joined: <%- alts[alt].date.toLocaleString() %>

+ <% } %> +<% }else{ %> +

No alts detected...

+<% } %> \ No newline at end of file diff --git a/www/css/global.css b/www/css/global.css index 3f1a715..8d14b21 100644 --- a/www/css/global.css +++ b/www/css/global.css @@ -74,6 +74,13 @@ div.dynamic-container{ user-select: none; } +.seperator{ + display: block; + width: 100%; + height: 1px; + margin: 0.5em 0; +} + /* Navbar */ #navbar{ display: flex; @@ -141,4 +148,17 @@ p.navbar-item, input.navbar-item{ .popup-title{ margin-top: 0; +} + +/* tooltip */ +div.tooltip{ + position: fixed; + pointer-events: all; + min-width: 1em; + min-height: 1em; + padding: 0.5em; +} + +p.tooltip, h3.tooltip{ + margin: 0 auto; } \ No newline at end of file diff --git a/www/css/panel/emote.css b/www/css/panel/emote.css index 65cafe0..d809fca 100644 --- a/www/css/panel/emote.css +++ b/www/css/panel/emote.css @@ -1,3 +1,18 @@ +/*Canopy - The next generation of stoner streaming software +Copyright (C) 2024 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 .*/ .title-span{ display: flex; flex-direction: row; diff --git a/www/css/theme/movie-night.css b/www/css/theme/movie-night.css index b94b057..35967bf 100644 --- a/www/css/theme/movie-night.css +++ b/www/css/theme/movie-night.css @@ -188,6 +188,11 @@ input.control-prompt, input.control-prompt:focus{ outline: none; } + +.seperator{ + background-color: var(--accent0); +} + /* navbar */ #navbar{ background-color: var(--bg1); @@ -344,6 +349,15 @@ select.panel-head-element{ color: var(--danger0); } +/* tooltip */ +div.tooltip{ + background-color: var(--bg1); + color: var(--accent1); + border: 1px solid var(--accent1); + box-shadow: 4px 4px 1px var(--bg1-alt0) inset; + border-radius: 1em; +} + /* panel */ .title-filler-span{ background-color: var(--accent0); diff --git a/www/js/adminPanel.js b/www/js/adminPanel.js index 3df0d25..94afd67 100644 --- a/www/js/adminPanel.js +++ b/www/js/adminPanel.js @@ -265,6 +265,7 @@ class canopyAdminUtils{ class adminUserList{ constructor(){ + this.userNames = document.querySelectorAll(".admin-user-list-name"); this.rankSelectors = document.querySelectorAll(".admin-user-list-rank-select"); this.banIcons = document.querySelectorAll(".admin-user-list-ban-icon"); @@ -272,13 +273,33 @@ class adminUserList{ } setupInput(){ - this.rankSelectors.forEach((rankSelector)=>{ - rankSelector.addEventListener("change", this.setRank.bind(this)) - }); + for(let userName of this.userNames){ + //Splice username out of class name + const name = userName.id.replace('admin-user-list-name-',''); - this.banIcons.forEach((banIcon) => { + //When the mouse starts to hover + userName.addEventListener('mouseenter',(event)=>{ + //Create the tooltip + const tooltip = new canopyUXUtils.tooltip(`altList?user=${name}`, true); + + //Do intial mouse move + tooltip.moveToMouse(event); + + //Move the tooltip with the mouse + userName.addEventListener('mousemove', tooltip.moveToMouse.bind(tooltip)); + + //remove the tooltip on mouseleave + userName.addEventListener('mouseleave', tooltip.remove.bind(tooltip)); + }); + } + + for(let rankSelector of this.rankSelectors){ + rankSelector.addEventListener("change", this.setRank.bind(this)) + } + + for(let banIcon of this.banIcons){ banIcon.addEventListener("click", this.banPopup.bind(this)); - }) + } } async setRank(event){ diff --git a/www/js/profile.js b/www/js/profile.js index ad128ca..2dcda67 100644 --- a/www/js/profile.js +++ b/www/js/profile.js @@ -160,7 +160,6 @@ class tokeList{ this.tokeList = document.querySelector('#profile-tokes'); this.tokeListLabel = document.querySelector('.profile-toke-count'); this.tokeListToggleIcon = document.querySelector('#toggle-toke-list'); - console.log(this.tokeList) this.setupInput(); } diff --git a/www/js/utils.js b/www/js/utils.js index 4a64c27..a33920a 100644 --- a/www/js/utils.js +++ b/www/js/utils.js @@ -98,6 +98,80 @@ class canopyUXUtils{ return entryRow; } + static tooltip = class{ + constructor(content, ajaxTooltip = false, cb){ + //Define non-tooltip node values + this.content = content; + this.ajaxPopup = ajaxTooltip; + this.cb = cb; + this.id = Math.random(); + + //create and append tooltip + this.tooltip = document.createElement('div'); + this.tooltip.classList.add('tooltip'); + + //Display tooltip even if it's not loaded to prevent removal of unloaded tooltips + this.displayTooltip(); + + //Fill the tooltip + this.fillTooltipContent(); + } + + async fillTooltipContent(){ + if(this.ajaxPopup){ + this.tooltip.textContent = "Loading tooltip..." + this.tooltip.innerHTML = await utils.ajax.getTooltip(this.content); + }else{ + this.tooltip.innerHTML = this.content; + } + + if(this.cb){ + //Callbacks are kinda out of vogue, but there really isn't a good way to handle asynchronously constructed objects/classes + this.cb(); + } + } + + displayTooltip(){ + document.body.appendChild(this.tooltip); + } + + moveToPos(x,y){ + //If the distance between the left edge of the window - the window width is more than the width of our tooltip + if((window.innerWidth - (window.innerWidth - x)) > this.tooltip.getBoundingClientRect().width){ + //Push it to the right edge of the cursor, where the hard edge typically is + this.tooltip.style.right = `${window.innerWidth - x}px`; + this.tooltip.style.left = ''; + //otherwise, if we're close to the edge + }else{ + //push it away from the edge of the screen + this.tooltip.style.right = '' + this.tooltip.style.left = `${x}px` + } + + + //If the distance between the top edge of the window - the window height is more than the heigt of our tooltip + if((window.innerHeight - (window.innerHeight - y)) > this.tooltip.getBoundingClientRect().height){ + //Push it above the mouse + this.tooltip.style.bottom = `${window.innerHeight - y}px`; + this.tooltip.style.top = ''; + //otherwise if we're close to the edge + }else{ + //Push it below the mouse to avoid the top edge of the screen + //-50px to account for a normal sized cursor + this.tooltip.style.bottom = ''; + this.tooltip.style.top = `${y+15}px`; + } + + } + + moveToMouse(event){ + this.moveToPos(event.clientX, event.clientY) + } + + remove(){ + this.tooltip.remove(); + } + } static popup = class{ constructor(content, ajaxPopup = false, cb){ @@ -468,6 +542,18 @@ class canopyAjaxUtils{ } } + async getTooltip(tooltip){ + var response = await fetch(`/tooltip/${tooltip}`,{ + method: "GET" + }); + + if(response.status == 200){ + return (await response.text()) + }else{ + utils.ux.displayResponseError(await response.json()); + } + } + async getChanBans(chanName){ var response = await fetch(`/api/channel/ban?chanName=${chanName}`,{ method: "GET",