diff --git a/src/controllers/api/admin/banController.js b/src/controllers/api/admin/banController.js index 63fc1a2..a54a79d 100644 --- a/src/controllers/api/admin/banController.js +++ b/src/controllers/api/admin/banController.js @@ -36,6 +36,31 @@ module.exports.get = async function(req, res){ } module.exports.post = async function(req, res){ + try{ + const validResult = validationResult(req); + if(validResult.isEmpty()){ + const {user, permanent, expirationDays} = matchedData(req); + const userDB = await userModel.findOne({user}); + + if(userDB == null){ + res.status(400); + return res.send({errors:[{type: "Bad Query", msg: "User not found.", date: new Date()}]}); + } + + await banModel.banByUserDoc(userDB, permanent, expirationDays); + + res.status(200); + return res.send(await banModel.getBans()); + }else{ + res.status(400); + return res.send({errors: validResult.array()}) + } + }catch(err){ + return exceptionHandler(res, err); + } +} + +module.exports.delete = async function(req, res){ try{ const validResult = validationResult(req); if(validResult.isEmpty()){ @@ -44,11 +69,13 @@ module.exports.post = async function(req, res){ if(userDB == null){ res.status(400); - res.send({errors:[{type: "Bad Query", msg: "User not found.", date: new Date()}]}); + return res.send({errors:[{type: "Bad Query", msg: "User not found.", date: new Date()}]}); } - await banModel.banByUserDoc(userDB); + await banModel.unbanByUserDoc(userDB); + res.status(200); + return res.send(await banModel.getBans()); }else{ res.status(400); return res.send({errors: validResult.array()}) diff --git a/src/controllers/popup/placeholderController.js b/src/controllers/popup/placeholderController.js new file mode 100644 index 0000000..4ed74b6 --- /dev/null +++ b/src/controllers/popup/placeholderController.js @@ -0,0 +1,20 @@ +/*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 .*/ + +//root index functions +module.exports.get = async function(req, res){ + res.render('partial/popup/placeholder', {}); +} \ No newline at end of file diff --git a/src/controllers/popup/userBanController.js b/src/controllers/popup/userBanController.js new file mode 100644 index 0000000..911168d --- /dev/null +++ b/src/controllers/popup/userBanController.js @@ -0,0 +1,20 @@ +/*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 .*/ + +//root index functions +module.exports.get = async function(req, res){ + res.render('partial/popup/userBan'); +} \ No newline at end of file diff --git a/src/routers/api/adminRouter.js b/src/routers/api/adminRouter.js index aa96d7a..74a92a9 100644 --- a/src/routers/api/adminRouter.js +++ b/src/routers/api/adminRouter.js @@ -15,7 +15,7 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see .*/ //npm imports -const { checkExact } = require('express-validator'); +const { body, checkExact} = require('express-validator'); const { Router } = require('express'); @@ -42,6 +42,8 @@ router.get('/permissions', permissionsController.get); router.post('/permissions', checkExact([permissionsValidator.permissionsMap(), channelPermissionValidator.channelPermissionsMap()]), permissionsController.post); router.post('/changeRank', accountValidator.user(), accountValidator.rank(), changeRankController.post); router.get('/ban', banController.get); -router.post('/ban', accountValidator.user(), banController.post); +//Sometimes they're so simple you don't need to put your validators in their own special place :P +router.post('/ban', accountValidator.user(), body("permanent").isBoolean(), body("expirationDays").isInt(), banController.post); +router.delete('/ban', accountValidator.user(), banController.delete); module.exports = router; diff --git a/src/routers/popupRouter.js b/src/routers/popupRouter.js new file mode 100644 index 0000000..5d92616 --- /dev/null +++ b/src/routers/popupRouter.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 placeholderController = require("../controllers/popup/placeholderController"); +const userBanController = require("../controllers/popup/userBanController"); + +//globals +const router = Router(); + +//routing functions +router.get('/placeholder', placeholderController.get); +router.get('/userBan', userBanController.get); + +module.exports = router; diff --git a/src/schemas/userBanSchema.js b/src/schemas/userBanSchema.js index 0068f95..950dea1 100644 --- a/src/schemas/userBanSchema.js +++ b/src/schemas/userBanSchema.js @@ -48,7 +48,7 @@ const userBanSchema = new mongoose.Schema({ default: 30 }, //If true, then expiration date deletes associated accounts instead of deleting the ban record - deleteAccountOnExpire: { + permanent: { type: mongoose.SchemaTypes.Boolean, required: true, default: false @@ -73,17 +73,58 @@ userBanSchema.statics.checkBan = async function(user){ return this.checkBanByUserDoc(userDB); } -userBanSchema.statics.banByUserDoc = async function(userDB){ +userBanSchema.statics.banByUserDoc = async function(userDB, permanent, expirationDays){ + //Prevent missing users + if(userDB == null){ + throw new Error("User not found") + } + + //Ensure the user isn't already banned if(await this.checkBanByUserDoc(userDB) != null){ throw new Error("User already banned"); } - return await this.create({user: userDB._id}); + if(expirationDays < 0){ + throw new Error("Expiration Days must be a positive integer!"); + }else if(expirationDays < 30 && permanent){ + throw new Error("Permanent bans must be given at least 30 days before automatic account deletion!"); + }else if(expirationDays > 185){ + throw new Error("Expiration/Deletion date cannot be longer than half a year out from the original ban date."); + } + + //Log the user out + await userDB.killAllSessions(); + + //Add the ban to the database + return await this.create({user: userDB._id, permanent, expirationDays}); } -userBanSchema.statics.ban = async function(user){ +userBanSchema.statics.ban = async function(user, permanent, expirationDays){ const userDB = await userModel.findOne({user: user.user}); - return this.banByUserDoc(userDB); + return this.banByUserDoc(userDB, permanent, expirationDays); +} + +userBanSchema.statics.unbanByUserDoc = async function(userDB){ + + //Prevent missing users + if(userDB == null){ + throw new Error("User not found") + } + + const ban = await this.checkBanByUserDoc(userDB); + + if(!ban){ + throw new Error("User already un-banned"); + } + + //Use _id in-case mongoose wants to be a cunt + var oldBan = await this.deleteOne({_id: ban._id}); + return oldBan; +} + +userBanSchema.statics.unban = async function(user){ + const userDB = await userModel.findOne({user: user.user}); + return this.unbanByUserDoc(userDB); } userBanSchema.statics.getBans = async function(){ @@ -109,7 +150,7 @@ userBanSchema.statics.getBans = async function(){ user: userObj, ips: ban.ips, alts: ban.alts, - deleteAccountOnExpire: ban.deleteAccountOnExpire + permanent: ban.permanent } bans.push(banObj); diff --git a/src/schemas/userSchema.js b/src/schemas/userSchema.js index c18c578..b820a9f 100644 --- a/src/schemas/userSchema.js +++ b/src/schemas/userSchema.js @@ -211,7 +211,7 @@ userSchema.statics.getUserList = async function(fullList = false){ //create a user object with limited properties (safe for public consumption) var userObj = { id: user.id, - name: user.user, + user: user.user, img: user.img, date: user.date } @@ -270,7 +270,7 @@ userSchema.methods.nuke = async function(pass){ if(this.checkPass(pass)){ //Annoyingly there isnt a good way to do this from 'this' - var oldUser = await module.exports.deleteOne(this); + var oldUser = await module.exports.userModel.deleteOne(this); if(oldUser){ await this.killAllSessions(); diff --git a/src/server.js b/src/server.js index 1bc641e..c7a10f2 100644 --- a/src/server.js +++ b/src/server.js @@ -34,6 +34,7 @@ const adminPanelRouter = require('./routers/adminPanelRouter'); const channelRouter = require('./routers/channelRouter'); const newChannelRouter = require('./routers/newChannelRouter'); const panelRouter = require('./routers/panelRouter'); +const popupRouter = require('./routers/popupRouter'); const apiRouter = require('./routers/apiRouter'); //Define Config @@ -92,6 +93,8 @@ app.use('/c', channelRouter); app.use('/newChannel', newChannelRouter); //Panel app.use('/panel', panelRouter); +//Popup +app.use('/popup', popupRouter); //Bot-Ready app.use('/api', apiRouter); diff --git a/src/views/partial/adminPanel/userBanList.ejs b/src/views/partial/adminPanel/userBanList.ejs index 4e9293c..423f60c 100644 --- a/src/views/partial/adminPanel/userBanList.ejs +++ b/src/views/partial/adminPanel/userBanList.ejs @@ -38,6 +38,9 @@ along with this program. If not, see .-->

Expiration Action

+ +

Actions

+ \ No newline at end of file diff --git a/src/views/partial/adminPanel/userList.ejs b/src/views/partial/adminPanel/userList.ejs index 7da532f..bd87c8b 100644 --- a/src/views/partial/adminPanel/userList.ejs +++ b/src/views/partial/adminPanel/userList.ejs @@ -29,33 +29,36 @@ along with this program. If not, see .-->

Rank

- +

E-Mail

- +

Sign-Up Date

+ +

Actions

+ <% userList.forEach((curUser) => { %> - - - - + + + + - - + + <%- curUser.id %> - - - <%- curUser.name %> + + + <%- curUser.user %> - + <% if(rankEnum.indexOf(curUser.rank) < rankEnum.indexOf(user.rank)){%> - <%rankEnum.slice().reverse().forEach((rank)=>{ %> <% }); %> @@ -64,12 +67,16 @@ along with this program. If not, see .--> <%- curUser.rank %> <% } %> - + <%- curUser.email ? curUser.email : "N/A" %> - + <%- curUser.date.toUTCString() %> + + + + <% }); %> diff --git a/src/views/partial/popup/placeholder.ejs b/src/views/partial/popup/placeholder.ejs new file mode 100644 index 0000000..b5297c0 --- /dev/null +++ b/src/views/partial/popup/placeholder.ejs @@ -0,0 +1,16 @@ + +

This is a test popup!

\ No newline at end of file diff --git a/src/views/partial/popup/userBan.ejs b/src/views/partial/popup/userBan.ejs new file mode 100644 index 0000000..c9cb60d --- /dev/null +++ b/src/views/partial/popup/userBan.ejs @@ -0,0 +1,33 @@ + + + +
+ + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/www/css/adminPanel.css b/www/css/adminPanel.css index 39e58ed..db755dd 100644 --- a/www/css/adminPanel.css +++ b/www/css/adminPanel.css @@ -108,4 +108,9 @@ img.admin-list-entry-item{ #new-rank-select{ margin: auto; height: 1.3em; +} + +.admin-user-list-icon{ + cursor: pointer; + margin: 0 0.2em; } \ No newline at end of file diff --git a/www/css/global.css b/www/css/global.css index ac818ff..86f8b18 100644 --- a/www/css/global.css +++ b/www/css/global.css @@ -48,4 +48,43 @@ p.navbar-item, input.navbar-item{ .navbar-item input{ padding: 0.2em; +} + +.popup-backer{ + position: fixed; + top: 0; + bottom: 0; + right: 0; + left: 0; +} + +.popup-div{ + position: fixed; + display: flex; + flex-direction: column; + margin: auto; + top: 0; + bottom: 0; + right: 0; + left: 0; + height: fit-content; + width: fit-content; +} + +.popup-close-icon{ + text-align: right; + position:absolute; + right: 0; + margin: 0.5em; + margin-bottom: 0; + cursor: pointer; +} + +.popup-content-div{ + margin: 1em; + padding-top: 0.2em; +} + +.popup-title{ + margin-top: 0; } \ No newline at end of file diff --git a/www/css/popup/userBan.css b/www/css/popup/userBan.css new file mode 100644 index 0000000..b8ccd7f --- /dev/null +++ b/www/css/popup/userBan.css @@ -0,0 +1,13 @@ +.ban-popup-content{ + display: flex; + flex-direction: column; +} + +#ban-popup-button-span{ + display: flex; + flex-direction: row; +} + +#ban-popup-button-span-spacer{ + flex: 1; +} \ No newline at end of file diff --git a/www/css/theme/movie-night.css b/www/css/theme/movie-night.css index 7515d40..f9e2ba8 100644 --- a/www/css/theme/movie-night.css +++ b/www/css/theme/movie-night.css @@ -281,4 +281,22 @@ select.panel-head-element{ #new-rank-input:focus{ border: none; outline: none; +} + + +.popup-backer{ + background-color: var(--bg0-alpha1); + backdrop-filter: var(--background-panel-effect-fast); +} + +.popup-div{ + background-color: var(--bg1); + color: var(--accent1); + box-shadow: 3px 3px 1px var(--bg1-alt0) inset; + border-radius: 1em; +} + +#ban-popup-ban-button{ + background-color: var(--accent0-warning); + color: var(--accent1); } \ No newline at end of file diff --git a/www/js/adminPanel.js b/www/js/adminPanel.js index 131a029..7e75eda 100644 --- a/www/js/adminPanel.js +++ b/www/js/adminPanel.js @@ -19,6 +19,59 @@ class canopyAdminUtils{ } + //Statics + static banUserPopup = class{ + constructor(target){ + this.target = target; + this.popup = new canopyUXUtils.popup("userBan", true, this.asyncConstruction.bind(this)); + } + + asyncConstruction(){ + this.title = document.querySelector(".popup-title"); + //Setup title text real quick-like :P + this.title.innerHTML = `Ban ${this.target}`; + + this.permBan = document.querySelector("#ban-popup-perm"); + this.expiration = document.querySelector("#ban-popup-expiration"); + this.expirationPrefix = document.querySelector("#ban-popup-expiration-prefix"); + this.banButton = document.querySelector("#ban-popup-ban-button"); + this.cancelButton = document.querySelector("#ban-popup-cancel-button"); + + this.setupInput(); + } + + setupInput(){ + this.permBan.addEventListener("change", this.permaBanLabel.bind(this)); + this.cancelButton.addEventListener("click", this.popup.closePopup.bind(this.popup)); + this.banButton.addEventListener("click",this.ban.bind(this)); + } + + permaBanLabel(event){ + if(event.target.checked){ + this.expirationPrefix.innerHTML = "Account Deletion In: " + this.expiration.value = 30; + }else{ + this.expirationPrefix.innerHTML = "Ban Expires In: " + this.expiration.value = 14; + } + } + + async ban(event){ + //Close out the popup + this.popup.closePopup(); + + //Submit the user ban based on input + const bans = await adminUtil.banUser(this.target, this.permBan.checked, this.expiration.value); + + //For some reason comparing this against undefined or null wasnt working in and of itself... + if(typeof userBanList != "undefined" && bans != null){ + //Why add an extra get request when we already have the data? :P + await userBanList.renderBanList(bans); + } + } + } + + //Methods async setUserRank(user, rank){ var response = await fetch(`/api/admin/changeRank`,{ method: "POST", @@ -82,13 +135,30 @@ class canopyAdminUtils{ } } - async banUser(user){ + async banUser(user, permanent, expirationDays){ var response = await fetch(`/api/admin/ban`,{ method: "POST", headers: { "Content-Type": "application/json" }, //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}) + }); + + if(response.status == 200){ + return await response.json(); + }else{ + utils.ux.displayResponseError(await response.json()); + } + } + + async unbanUser(user){ + var response = await fetch(`/api/admin/ban`,{ + method: "DELETE", + headers: { + "Content-Type": "application/json" + }, + //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}) }); @@ -103,6 +173,7 @@ class canopyAdminUtils{ class adminUserList{ constructor(){ this.rankSelectors = document.querySelectorAll(".admin-user-list-rank-select"); + this.banIcons = document.querySelectorAll(".admin-user-list-ban-icon"); this.setupInput(); } @@ -111,6 +182,10 @@ class adminUserList{ this.rankSelectors.forEach((rankSelector)=>{ rankSelector.addEventListener("change", this.setRank.bind(this)) }); + + this.banIcons.forEach((banIcon) => { + banIcon.addEventListener("click", this.banPopup.bind(this)); + }) } async setRank(event){ @@ -120,6 +195,12 @@ class adminUserList{ this.updateSelect(await adminUtil.setUserRank(user, rank), event.target); } + banPopup(event){ + const user = event.target.id.replace("admin-user-list-ban-icon-",""); + + new canopyAdminUtils.banUserPopup(user); + } + updateSelect(update, select){ if(update != null){ select.value = update.rank; @@ -186,7 +267,22 @@ class adminUserBanList{ this.renderBanList(await adminUtil.getBans()); } + clearBanList(){ + const oldRows = this.table.querySelectorAll('tr.admin-list-entry'); + oldRows.forEach((row) => { + row.remove(); + }); + } + + async unban(event){ + //Get username from target id + const user = event.target.id.replace("admin-user-list-unban-icon-",""); + //Send unban command to server and display the resulting banlist + this.renderBanList(await adminUtil.unbanUser(user)); + } + renderBanList(banList){ + this.clearBanList(); banList.forEach((ban) => { //Create entry row const entryRow = document.createElement('tr'); @@ -197,26 +293,40 @@ class adminUserBanList{ imgNode.classList.add("admin-list-entry","admin-list-entry-item"); imgNode.src = ban.user.img; - console.log(new Date(ban.user.date).toDateString()); - + //Calculate expiration date and expiration days const expirationDate = new Date(ban.expirationDate); + const expirationDays = ((expirationDate - new Date()) / (1000 * 60 * 60 * 24)).toFixed(1); - const expirationDays = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24)); + //Create unban icon + const unbanIcon = document.createElement('i'); + unbanIcon.classList.add("bi-emoji-smile-fill","admin-user-list-icon","admin-user-list-unban-icon"); + unbanIcon.id = `admin-user-list-unban-icon-${ban.user.user}`; + unbanIcon.title = `Unban ${ban.user.user}`; + unbanIcon.addEventListener("click", this.unban.bind(this)); + + //Create nuke account icon + const nukeAccount = document.createElement('i'); + nukeAccount.classList.add("bi-radioactive","admin-user-list-icon","admin-user-list-unban-icon"); + nukeAccount.id = `admin-user-list-unban-icon-${ban.user.user}`; + nukeAccount.title = `Nuke accounts`; + nukeAccount.addEventListener("click",console.log); //Append cells to row - entryRow.appendChild(newCell(imgNode, true, true)); + entryRow.appendChild(newCell(imgNode, true)); entryRow.appendChild(newCell(ban.user.id)); entryRow.appendChild(newCell(ban.user.user)); entryRow.appendChild(newCell(new Date(ban.user.date).toDateString())); entryRow.appendChild(newCell(new Date(ban.banDate).toDateString())); entryRow.appendChild(newCell(`${expirationDate.toDateString()} (${expirationDays} days left)`)); - entryRow.appendChild(newCell(ban.deleteAccountOnExpire ? "Delete" : "Un-Ban")); + entryRow.appendChild(newCell(ban.permanent ? "Account Deletion" : "Un-Ban")); + entryRow.appendChild(newCell([unbanIcon, nukeAccount])); //Append row to table this.table.appendChild(entryRow); }); - function newCell(content, addAsNode = false, firstCol = false){ + //We should really move this over to uxutils along with newrow & newtable functions + function newCell(content, firstCol = false){ //Create a new 'td' element const cell = document.createElement('td'); cell.classList.add("admin-list-entry","admin-list-entry-item"); @@ -226,17 +336,33 @@ class adminUserBanList{ cell.classList.add("admin-list-entry-not-first-col"); } - //If we're adding as node - if(addAsNode){ - //append it like it's a node - cell.appendChild(content); + //check for arrays + if(content.forEach == null){ + //add single items + addContent(content); }else{ - //otherwise use it as innerHTML - cell.innerHTML = content; + //Crawl through content array + content.forEach((item)=>{ + //add each item + addContent(item); + }); } //return the resulting cell return cell; + + + function addContent(ct){ + //If we're adding as node + if(ct.cloneNode != null){ + //append it like it's a node + cell.appendChild(ct); + }else{ + //otherwise use it as innerHTML + cell.innerHTML = ct; + } + } + } } } diff --git a/www/js/utils.js b/www/js/utils.js index 412b4ad..41509a2 100644 --- a/www/js/utils.js +++ b/www/js/utils.js @@ -25,13 +25,68 @@ class canopyUXUtils{ constructor(){ } - async displayResponseError(body){ + displayResponseError(body){ const errors = body.errors; errors.forEach((err)=>{ window.alert(`ERROR: ${err.msg} \nTYPE: ${err.type} \nDATE: ${err.date}`); }); } + static popup = class{ + constructor(content, ajaxPopup = false, cb){ + //Define non-popup node values + this.content = content; + this.ajaxPopup = ajaxPopup; + this.cb = cb; + //define popup nodes + this.createPopup(); + + //fill popup nodes + this.fillPopupContent(); + } + + createPopup(){ + this.popupBacker = document.createElement('div'); + this.popupBacker.classList.add('popup-backer'); + + this.popupDiv = document.createElement('div'); + this.popupDiv.classList.add('popup-div'); + + this.closeIcon = document.createElement('i'); + this.closeIcon.classList.add('bi-x','popup-close-icon'); + this.closeIcon.addEventListener("click", this.closePopup.bind(this)); + + this.contentDiv = document.createElement('div'); + this.contentDiv.classList.add('popup-content-div'); + + this.popupDiv.appendChild(this.closeIcon); + this.popupDiv.appendChild(this.contentDiv); + } + + async fillPopupContent(){ + if(this.ajaxPopup){ + this.contentDiv.innerHTML = await utils.ajax.getPopup(this.content); + }else{ + this.contentDiv.innerHTML = this.content; + } + + //display popup nodes + this.displayPopup(); + + //Callbacks are kinda out of vogue, but there really isn't a good way to handle asynchronously constructed objects/classes + this.cb(); + } + + displayPopup(){ + document.body.prepend(this.popupDiv); + document.body.prepend(this.popupBacker); + } + + closePopup(){ + this.popupDiv.remove(); + this.popupBacker.remove(); + } + } static clickDragger = class{ constructor(handle, element, leftHandle = true){ @@ -309,6 +364,18 @@ class canopyAjaxUtils{ utils.ux.displayResponseError(await response.json()); } } + + async getPopup(popup){ + var response = await fetch(`/popup/${popup}`,{ + method: "GET" + }); + + if(response.status == 200){ + return (await response.text()) + }else{ + utils.ux.displayResponseError(await response.json()); + } + } } const utils = new canopyUtils() \ No newline at end of file