From 6222535c4790d37b263407a43e950e011179e394 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Wed, 17 Sep 2025 05:11:45 -0400 Subject: [PATCH 01/92] channelManager now tracks all active connectedUser objects for a given user. --- .gitignore | 3 + README.md | 4 +- package.json | 4 +- src/app/channel/activeChannel.js | 18 ++++-- src/app/channel/channelManager.js | 98 ++++++++++++++++++++++++++----- src/app/channel/connectedUser.js | 5 ++ src/app/channel/message.js | 52 ++++++++++++++++ 7 files changed, 160 insertions(+), 24 deletions(-) create mode 100644 src/app/channel/message.js diff --git a/.gitignore b/.gitignore index d15d9ec..bd8bfad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ node_modules/ log/crash/* +!log/crash www/doc/*/* +!www/doc/client +!www/doc/server package-lock.json config.json config.json.old diff --git a/README.md b/README.md index 65c8ec5..34a3c2e 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ Canopy - 0.3-INDEV - Hotfix 1 Canopy - /ˈkæ.nə.pi/: - The upper layer of foliage and branches of a forest, containing the majority of animal life. + - An honest attempt at an freedom/privacy respecting, libre, and open-source refrence implementation of what a stoner streaming service can be. Canopy is a community chat & synced video embedding web application, intended to replace fore.st as the server software for ourfore.st. This new codebase intends to solve the following issues with the current CyTube based software: @@ -22,10 +23,11 @@ The Canopy codebase does not, nor will it ever contain: - Cryptocurrency/Blockchain integration - 'Analytics/Telemtry' spyware - The use of video sources which require proprietary 'Digital ~~Rights Management~~ Ristricitons Malware' such as Widevine. + - The use of large language models, stable diffusion, or generative AI in either development or function. Thirdparty media providers may or may not contain all of the above atrocities :P (though browser-side DRM extensions will never be required), always use an ad-blocker! - Our current goal is to create a cleaner, more modern, purpose-built codebase that has feature-parity with the current version of fore.st, while writing improvements where possible. Once this is accomplished, and ourfore.st has been migrated, work will continue to re-create features from TTN, while also building completely new ones as well. + Our current goal is to create a cleaner, more modern, purpose-built back-end that has feature-parity with the current version of fore.st, writing improvements where possible. Paired with this functionality, are a mix of engineering and artistic choices which attempt to re-create the power-user friendly UX of desktop sites from the early 2010's, with the 'aged like wine' looks that late oughts/early web 2.0 designs graced us with. Making sure that pageloads are low, and GPU use is non-existant along the way, to ensure everything is usable, even on low-end machines. ## License Canopy is written by the community, and provided under the GNU Affero General Public License v3 in order to prevent Canopy from being used in proprietary software or shitcoin scams. \ No newline at end of file diff --git a/package.json b/package.json index da43c50..0c49972 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "canopy-of", - "version": "0.3", + "name": "canopy-of-indev", + "version": "0.4", "license": "AGPL-3.0-only", "dependencies": { "altcha": "^1.0.7", diff --git a/src/app/channel/activeChannel.js b/src/app/channel/activeChannel.js index 8c8374a..566e0c7 100644 --- a/src/app/channel/activeChannel.js +++ b/src/app/channel/activeChannel.js @@ -74,6 +74,7 @@ class activeChannel{ * @param {Mongoose.Document} userDB - User Document Passthrough to save on DB Access * @param {Mongoose.Document} chanDB - Channnel Document Passthrough to save on DB Access * @param {Socket} socket - Requesting Socket + * @returns {activeUser} active user object generated by the new connection */ async handleConnection(userDB, chanDB, socket){ //get current user object from the userlist @@ -104,10 +105,13 @@ class activeChannel{ this.playlistHandler.defineListeners(socket); //Hand off the connection initiation to it's user object - await userObj.handleConnection(userDB, chanDB, socket) + const activeUser = await userObj.handleConnection(userDB, chanDB, socket) //Send out the userlist this.broadcastUserList(socket.chan); + + //Return active user connection object for use by the channelManager object + return activeUser; } /** @@ -115,11 +119,11 @@ class activeChannel{ * @param {Socket} socket - Requesting Socket */ handleDisconnect(socket){ - //If we have more than one active connection - if(this.userList.get(socket.user.user).sockets.length > 1){ - //temporarily store userObj - var userObj = this.userList.get(socket.user.user); + //temporarily store userObj + let userObj = this.userList.get(socket.user.user); + //If we have more than one active connection + if(userObj.sockets.length > 1){ //Filter out disconnecting socket from socket list, and set as current socket list for user userObj.sockets = userObj.sockets.filter((id) => { return id != socket.id; @@ -127,7 +131,11 @@ class activeChannel{ //Update the userlist this.userList.set(socket.user.user, userObj); + //If this is the last one }else{ + //Tell the server to handle the disconnection of this user object + this.server.handleUserDisconnect(userObj); + //If this is the last connection for this user, remove them from the userlist this.userList.delete(socket.user.user); } diff --git a/src/app/channel/channelManager.js b/src/app/channel/channelManager.js index f264204..66a14cd 100644 --- a/src/app/channel/channelManager.js +++ b/src/app/channel/channelManager.js @@ -33,7 +33,7 @@ const chatHandler = require('./chatHandler'); class channelManager{ /** * Instantiates object containing global server-side channel conection management logic - * @param {Server} io - Socket.io server instanced passed down from server.js + * @param {Socket.io} io - Socket.io server instanced passed down from server.js */ constructor(io){ /** @@ -46,6 +46,11 @@ class channelManager{ */ this.activeChannels = new Map; + /** + * Map containing all active users. This may be redundant, however it improves preformance for user-specific inter-channel functionality + */ + this.activeUsers = new Map; + /** * Global Chat Handler Object */ @@ -88,13 +93,48 @@ class channelManager{ return; } + //Connection accepted past this point + //Define listeners for inter-channel classes this.defineListeners(socket); this.chatHandler.defineListeners(socket); //Hand off the connection to it's given active channel object //Lil' hacky to pass chanDB like that, but why double up on DB calls? - activeChan.handleConnection(userDB, chanDB, socket); + const activeUser = await activeChan.handleConnection(userDB, chanDB, socket); + + //Pull status from server-wide activeUsers map + let status = this.activeUsers.get(activeUser.user); + + //If this user isn't connected anywhere else + if(status == null){ + //initiate the entry + this.activeUsers.set(activeUser.user, [activeUser]); + //otherwise + }else{ + //Push user to array by default + let pushUser = true; + + //For each active connection within the status map + for(let curUser of status){ + //If we're already listing this active user (we're splitting a user connection amongst several sockets) + if(curUser.channel.name == activeUser.channel.name){ + //don't need to push it again + pushUser = false; + } + } + + //if the user is flagged as un-added + if(pushUser){ + //Add their connection object into the status array we pulled + status.push(activeUser); + + //Set status entry to updated array + this.activeUsers.set(activeUser.set, status); + } + } + + }else{ //Toss out anon's socket.emit("kick", {type: "disconnected", reason: "You must log-in to join this channel!"}); @@ -217,6 +257,34 @@ class channelManager{ activeChan.handleDisconnect(socket, reason); } + /** + * Handles a disconnection event for a single active user within a given channel (when all sockets disconnect) + * @param {*} userObj + */ + handleUserDisconnect(userObj){ + //Create array to hold + let stillConnected = []; + + //Crawl through all known user connections + this.crawlConnections(userObj.user, (curUser)=>{ + //If we have a matching username from a different channel + if(curUser.user == userObj.user && userObj.channel.name != curUser.channel.name){ + //Keep current user + stillConnected.push(curUser); + } + }); + + //If we have anyone left + if(stillConnected.length > 0){ + //save the remainder to the status map, otherwise unset the value. + this.activeUsers.set(userObj.user, stillConnected); + //Otherwise + }else{ + //Delete the user from the status map + this.activeUsers.delete(userObj.user); + } + } + /** * Pulls user information by socket * @param {Socket} socket - Socket to check @@ -257,30 +325,28 @@ class channelManager{ * @param {Function} cb - Callback function to run active connections of a given user against */ crawlConnections(user, cb){ - //For each channel - this.activeChannels.forEach((channel) => { - //Check and see if the user is connected - const foundUser = channel.userList.get(user); + //Pull connection list from status map + const list = this.activeUsers.get(user); - //If we found a user and this channel hasn't been added to the list - if(foundUser){ - cb(foundUser); + //If we have active connections + if(list != null){ + //For each connection + for(let user of list){ + //Run the callback against it + cb(user); } - }); + } + } /** * Iterates through connections by a given username, and runs them through a given callback function/method + * This function is deprecated. Instead use channelManager.activeUsers.get(user) * @param {String} user - Username to crawl connections against * @param {Function} cb - Callback function to run active connections of a given user against */ getConnections(user){ - //Create a list to store our connections - var connections = []; - - //crawl through connections - //this.crawlConnections(user,(foundUser)=>{connections.push(foundUser)}); - this.crawlConnections(user,(foundUser)=>{connections.push(foundUser)}); + const connections = this.activeUsers.get(user); //return connects return connections; diff --git a/src/app/channel/connectedUser.js b/src/app/channel/connectedUser.js index 3fe59d5..81b3220 100644 --- a/src/app/channel/connectedUser.js +++ b/src/app/channel/connectedUser.js @@ -91,6 +91,7 @@ class connectedUser{ * @param {Mongoose.Document} userDB - User Document Passthrough to save on DB Access * @param {Mongoose.Document} chanDB - Channnel Document Passthrough to save on DB Access * @param {Socket} socket - Requesting Socket + * @returns {activeUser} active user object generated by the new connection */ async handleConnection(userDB, chanDB, socket){ //send metadata to client @@ -115,6 +116,10 @@ class connectedUser{ //Tattoo hashed IP address to user account for seven days await userDB.tattooIPRecord(socket.handshake.address); } + + + //Return active user object for use by activeChannel and channelManager objects + return this; } /** diff --git a/src/app/channel/message.js b/src/app/channel/message.js new file mode 100644 index 0000000..eef6658 --- /dev/null +++ b/src/app/channel/message.js @@ -0,0 +1,52 @@ +/*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 .*/ + +/** + * Class representing a single chat message + */ +class message{ + /** + * Instantiates a chat message object + * @param {connectedUser} sender - User who sent the message + * @param {Array} recipients - Array of connected users who are supposed to receive the message + * @param {String} msg - Contents of the message, with links replaced with numbered file-seperator markers + * @param {Array} links - Array of URLs/Links included in the message. + */ + constructor(sender, recipients, msg, links){ + + /** + * User who sent the message + */ + this.sender = sender; + + /** + * Array of strings containing usernames to send message to + */ + this.recipients = recipients; + + /** + * Contenst of the messages, with links replaced with numbered file-seperator markers + */ + this.msg = msg; + + /** + * Array of URLs/Links included in the message. + */ + this.links = links; + } +} + +module.exports = message; \ No newline at end of file From 6445950f9029f75fab1ff03e2e888b0110644801 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Wed, 17 Sep 2025 20:17:41 -0400 Subject: [PATCH 02/92] Last user activity now marked on humie-friendly page-loads and last-socket disconnects, ensuring accurate 'online' status when disconnected from a channel. --- src/app/channel/channelManager.js | 6 ++- src/routers/adminPanelRouter.js | 3 +- src/routers/channelRouter.js | 4 ++ src/routers/indexRouter.js | 4 ++ src/routers/newChannelRouter.js | 4 ++ src/schemas/user/userSchema.js | 5 +++ src/utils/presenceUtils.js | 75 +++++++++++++++++++++++++++++++ 7 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 src/utils/presenceUtils.js diff --git a/src/app/channel/channelManager.js b/src/app/channel/channelManager.js index 66a14cd..4b594ad 100644 --- a/src/app/channel/channelManager.js +++ b/src/app/channel/channelManager.js @@ -24,6 +24,7 @@ const {userModel} = require('../../schemas/user/userSchema'); const userBanModel = require('../../schemas/user/userBanSchema'); const loggerUtils = require('../../utils/loggerUtils'); const csrfUtils = require('../../utils/csrfUtils'); +const presenceUtils = require('../../utils/presenceUtils'); const activeChannel = require('./activeChannel'); const chatHandler = require('./chatHandler'); @@ -259,7 +260,7 @@ class channelManager{ /** * Handles a disconnection event for a single active user within a given channel (when all sockets disconnect) - * @param {*} userObj + * @param {connectedUser} userObj - Connected user object to handle disconnection of */ handleUserDisconnect(userObj){ //Create array to hold @@ -282,6 +283,9 @@ class channelManager{ }else{ //Delete the user from the status map this.activeUsers.delete(userObj.user); + + //Mark last disconnection as user activity, as they'll no longer be marked as streaming. + presenceUtils.handlePresence(userObj.user); } } diff --git a/src/routers/adminPanelRouter.js b/src/routers/adminPanelRouter.js index 1d54411..129b403 100644 --- a/src/routers/adminPanelRouter.js +++ b/src/routers/adminPanelRouter.js @@ -17,16 +17,17 @@ along with this program. If not, see .*/ //npm imports const { Router } = require('express'); - //local imports const permissionSchema = require("../schemas/permissionSchema"); const adminPanelController = require("../controllers/adminPanelController"); +const presenceUtils = require("../utils/presenceUtils"); //globals const router = Router(); //Use authentication middleware router.use(permissionSchema.reqPermCheck("adminPanel")) +router.use(presenceUtils.presenceMiddleware); //routing functions router.get('/', adminPanelController.get); diff --git a/src/routers/channelRouter.js b/src/routers/channelRouter.js index c8343ad..fb32167 100644 --- a/src/routers/channelRouter.js +++ b/src/routers/channelRouter.js @@ -22,6 +22,7 @@ const { Router } = require('express'); const channelModel = require("../schemas/channel/channelSchema"); const channelController = require("../controllers/channelController"); const channelSettingsController = require("../controllers/channelSettingsController"); +const presenceUtils = require("../utils/presenceUtils"); //globals const router = Router(); @@ -29,6 +30,9 @@ const router = Router(); //User authentication middleware router.use("/*/settings",channelModel.reqPermCheck("manageChannel","/c/")); +//Use presence middleware +router.use(presenceUtils.presenceMiddleware); + //routing functions router.get('/*/settings', channelSettingsController.get); router.get('/*/', channelController.get); diff --git a/src/routers/indexRouter.js b/src/routers/indexRouter.js index 0b3528b..92135dc 100644 --- a/src/routers/indexRouter.js +++ b/src/routers/indexRouter.js @@ -20,10 +20,14 @@ const { Router } = require('express'); //local imports const indexController = require("../controllers/indexController"); +const presenceUtils = require("../utils/presenceUtils"); //globals const router = Router(); +//Use presence middleware +router.use(presenceUtils.presenceMiddleware); + //routing functions router.get('/', indexController.get); diff --git a/src/routers/newChannelRouter.js b/src/routers/newChannelRouter.js index 0dd998c..e77808b 100644 --- a/src/routers/newChannelRouter.js +++ b/src/routers/newChannelRouter.js @@ -21,6 +21,7 @@ const { Router } = require('express'); //local imports const permissionSchema = require("../schemas/permissionSchema"); const newChannelController = require("../controllers/newChannelController"); +const presenceUtils = require("../utils/presenceUtils"); //globals const router = Router(); @@ -28,6 +29,9 @@ const router = Router(); //user authentication middleware router.use("/",permissionSchema.reqPermCheck("registerChannel")); +//Use presence middleware +router.use(presenceUtils.presenceMiddleware); + //routing functions router.get('/', newChannelController.get); diff --git a/src/schemas/user/userSchema.js b/src/schemas/user/userSchema.js index 40a3e6e..5f718ed 100644 --- a/src/schemas/user/userSchema.js +++ b/src/schemas/user/userSchema.js @@ -60,6 +60,11 @@ const userSchema = new mongoose.Schema({ required: true, default: new Date() }, + lastActive: { + type: mongoose.SchemaTypes.Date, + required: true, + default: new Date() + }, rank: { type: mongoose.SchemaTypes.String, required: true, diff --git a/src/utils/presenceUtils.js b/src/utils/presenceUtils.js new file mode 100644 index 0000000..603b5c3 --- /dev/null +++ b/src/utils/presenceUtils.js @@ -0,0 +1,75 @@ +/*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 .*/ + +//local includes +const userSchema = require('../schemas/user/userSchema'); + +//User activity map to keep us from constantly reading off of the DB +let activityMap = new Map(); + +//How much difference between last write and now until we hit the DB again (in millis) +//Defaults to two minutes +const tolerance = 2 * (60 * 1000); + +module.exports.presenceMiddleware = function(req, res, next){ + //Pull user from session + const user = req.session.user; + + //if we have a user object + if(user != null){ + //Handle Presence + module.exports.handlePresence(user.user); + } + + //Go on to next part of the middleware chain + next(); +} + +module.exports.handlePresence = async function(user, userDB, noSave = false){ + //If we don't have a user + if(user == null || user == ''){ + //Drop that shit + return; + } + + //Get current date as epoch (millis) + const now = new Date(); + const millis = now.getTime(); + + //Check last user activity + const activity = activityMap.get(user); + + //If we have no recorded activity, or if the the time between now and the last activity is greater than two minutes + if(activity == null || millis - activity > tolerance){ + //Set last user activity + activityMap.set(user, millis); + + //If we wheren't handed a free user doc + if(userDB == null){ + //Pull one from the username + userDB = await userSchema.userModel.findOne({user: user}); + } + + //Set last active in user's DB document + userDB.lastActive = now; + + //If saving is enabled + if(!noSave){ + //Save document to + await userDB.save(); + } + } +} \ No newline at end of file From 1384b02f4dd6911f4e27e66291de0a3fc993768e Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Thu, 18 Sep 2025 02:43:43 -0400 Subject: [PATCH 03/92] Profile pages now display user status. --- README.md | 6 +-- src/controllers/panel/profileController.js | 6 ++- src/controllers/profileController.js | 6 +++ src/schemas/user/userSchema.js | 3 +- src/utils/presenceUtils.js | 59 ++++++++++++++++++++++ src/views/partial/panels/profile.ejs | 1 + src/views/partial/profile/status.ejs | 22 ++++++++ src/views/profile.ejs | 1 + www/css/panel/profile.css | 6 +++ www/css/profile.css | 5 ++ www/css/theme/movie-night.css | 9 ++++ 11 files changed, 119 insertions(+), 5 deletions(-) create mode 100644 src/views/partial/profile/status.ejs diff --git a/README.md b/README.md index 34a3c2e..2f346eb 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -Canopy - 0.3-INDEV - Hotfix 1 +Canopy - 0.4-INDEV ====== Canopy - /ˈkæ.nə.pi/: - The upper layer of foliage and branches of a forest, containing the majority of animal life. - - An honest attempt at an freedom/privacy respecting, libre, and open-source refrence implementation of what a stoner streaming service can be. + - An honest attempt at a freedom/privacy respecting, libre, and open-source refrence implementation of what a stoner streaming service can be. Canopy is a community chat & synced video embedding web application, intended to replace fore.st as the server software for ourfore.st. This new codebase intends to solve the following issues with the current CyTube based software: @@ -15,7 +15,7 @@ This new codebase intends to solve the following issues with the current CyTube - General Clunk - Less Unique Community Identity -Canopy intends to be a simple node/express.js app. It leverages yt-dlp and the internet archive REST api for metadata gathering. Persistant storage is handled by mongodb, as it's document based nature inherintly works well for cleanly storing large config documents for user/channel settings, and the low use of inter-collection references within the canopy software. All hardcore security functions like server-side input sanatization, session handling, CSRF mitigation, and password hashing are handled by industry-standard open source libraries such as validator/express-validator, express-sessions, csrf-sync, and bcrypt, however it IS hobbiest software, and it should be treated as such. +Canopy is a simple node/express.js app, leveraging yt-dlp and the internet archive REST api for metadata gathering. Persistant storage is handled by mongodb, as it's document based nature inherintly works well for cleanly storing large config documents for user/channel settings, and the low use of inter-collection references within the canopy software. All hardcore security functions like server-side input sanatization, session handling, CSRF mitigation, and password hashing are handled by industry-standard open source libraries such as validator/express-validator, express-sessions, csrf-sync, and bcrypt, however it IS hobbiest software, and it should be treated as such. The Canopy codebase does not, nor will it ever contain: - Advertisements (targetted or otherwise) diff --git a/src/controllers/panel/profileController.js b/src/controllers/panel/profileController.js index ac58d44..4a58c7d 100644 --- a/src/controllers/panel/profileController.js +++ b/src/controllers/panel/profileController.js @@ -18,6 +18,7 @@ along with this program. If not, see .*/ const {validationResult, matchedData} = require('express-validator'); //local imports +const presenceUtils = require('../../utils/presenceUtils'); const {userModel} = require('../../schemas/user/userSchema'); const {exceptionHandler, errorHandler} = require('../../utils/loggerUtils'); @@ -30,7 +31,10 @@ module.exports.get = async function(req, res){ const data = matchedData(req); const profile = await userModel.findProfile({user: data.user}); - return res.render('partial/panels/profile', {profile}); + //Pull presence (should be quick since everyone whos been on since last startup will be backed in RAM) + const presence = await presenceUtils.getPresence(profile.user); + + return res.render('partial/panels/profile', {profile, presence}); }else{ res.status(400); return res.send({errors: validResult.array()}) diff --git a/src/controllers/profileController.js b/src/controllers/profileController.js index c37fdf9..fd13cac 100644 --- a/src/controllers/profileController.js +++ b/src/controllers/profileController.js @@ -17,6 +17,7 @@ along with this program. If not, see .*/ //Local Imports const {userModel} = require('../schemas/user/userSchema'); const csrfUtils = require('../utils/csrfUtils'); +const presenceUtils = require('../utils/presenceUtils'); const {exceptionHandler, errorHandler} = require('../utils/loggerUtils'); //Config @@ -34,11 +35,15 @@ module.exports.get = async function(req, res){ //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; + //Pull presence (should be quick since everyone whos been on since last startup will be backed in RAM) + const presence = await presenceUtils.getPresence(profile.user); + res.render('profile', { instance: config.instanceName, user: req.session.user, profile, selfProfile, + presence, csrfToken: csrfUtils.generateToken(req) }); }else{ @@ -47,6 +52,7 @@ module.exports.get = async function(req, res){ user: req.session.user, profile: null, selfProfile: false, + presence: null, csrfToken: csrfUtils.generateToken(req) }); } diff --git a/src/schemas/user/userSchema.js b/src/schemas/user/userSchema.js index 5f718ed..434447c 100644 --- a/src/schemas/user/userSchema.js +++ b/src/schemas/user/userSchema.js @@ -63,7 +63,7 @@ const userSchema = new mongoose.Schema({ lastActive: { type: mongoose.SchemaTypes.Date, required: true, - default: new Date() + default: new Date(0) }, rank: { type: mongoose.SchemaTypes.String, @@ -505,6 +505,7 @@ userSchema.methods.getProfile = function(includeEmail = false){ id: this.id, user: this.user, date: this.date, + lastActive: this.lastActive, tokes: this.tokes, tokeCount: this.getTokeCount(), img: this.img, diff --git a/src/utils/presenceUtils.js b/src/utils/presenceUtils.js index 603b5c3..0096b69 100644 --- a/src/utils/presenceUtils.js +++ b/src/utils/presenceUtils.js @@ -15,6 +15,7 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see .*/ //local includes +const server = require('../server'); const userSchema = require('../schemas/user/userSchema'); //User activity map to keep us from constantly reading off of the DB @@ -23,6 +24,64 @@ let activityMap = new Map(); //How much difference between last write and now until we hit the DB again (in millis) //Defaults to two minutes const tolerance = 2 * (60 * 1000); +//How long a user has to be in-active to be considered offline +//Defaults to five minutes +const offlineTimeout = 5 * (60 * 1000); + +module.exports.getPresence = async function(user, userDB){ + //If we don't have a user + if(user == null || user == '' || user == 'Tokebot'){ + //Drop that shit + return; + } + + //Set status as offline + let status = "Offline" + //Attempt to pull from activity map to save on DB pull + let activity = activityMap.get(user); + //Pull current epoch in millis + const now = new Date().getTime(); + + //If we couldn't find anything in RAM + if(activity == null){ + //If we wheren't handed a free user doc + if(userDB == null){ + //Pull one from the username + userDB = await userSchema.userModel.findOne({user: user}); + } + + //If for some reason we can't find a user doc + if(userDB == null){ + //Bail with empty status object + return { + status, + activeConnections: [], + lastActive: 0 + } + } + + //Pull last active date from userDB + activity = userDB.lastActive.getTime(); + } + + //Pull active connections for user from the channel manager + const activeConnections = server.channelManager.getConnections(user); + + //If the user is connected to at least one channel + if(activeConnections != null && activeConnections.length > 0){ + status = "Streaming"; + //Otherwise, if it's been five minutes + }else if(now - activity < offlineTimeout){ + status = "Recently Active"; + } + + //Assemble and return status object + return { + status, + activeConnections, + lastActive: activity + } +} module.exports.presenceMiddleware = function(req, res, next){ //Pull user from session diff --git a/src/views/partial/panels/profile.ejs b/src/views/partial/panels/profile.ejs index c2ca2db..b5728c6 100644 --- a/src/views/partial/panels/profile.ejs +++ b/src/views/partial/panels/profile.ejs @@ -20,6 +20,7 @@ along with this program. If not, see . %> <% }else{ %> View Full Profile

<%- profile.user %>

+ <%- include('../profile/status', {profile, presence, auxClass:"panel"}); %>

Toke Count: <%- profile.tokeCount %>

<% if(profile.pronouns != '' && profile.pronouns != null){ %> diff --git a/src/views/partial/profile/status.ejs b/src/views/partial/profile/status.ejs new file mode 100644 index 0000000..d2bdff5 --- /dev/null +++ b/src/views/partial/profile/status.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 . %> +<% if(profile.user == "Tokebot"){ %> +

Perma-Couched

+<% }else{ %> + <% const statusClass = (presence.status == "Streaming") ? "positive" : ((presence.status == "Offline") ? "inactive" : "positive-low");%> + <% const curChan = (presence.activeConnections == null || presence.activeConnections.length <= 0) ? '' : (presence.activeConnections.length == 1 ? ` - /c/${presence.activeConnections[0].channel.name}` : " - Multiple Channels"); %> +

<%- presence.status %><%-curChan%>

+<% } %> \ No newline at end of file diff --git a/src/views/profile.ejs b/src/views/profile.ejs index ffb964b..3c956f0 100644 --- a/src/views/profile.ejs +++ b/src/views/profile.ejs @@ -33,6 +33,7 @@ along with this program. If not, see . %>

<%- profile.user %>

+ <%- include('partial/profile/status', {profile, presence, auxClass: ""}); %> <%- include('partial/profile/image', {profile, selfProfile}); %> <%- include('partial/profile/pronouns', {profile, selfProfile}); %> <%- include('partial/profile/signature', {profile, selfProfile}); %> diff --git a/www/css/panel/profile.css b/www/css/panel/profile.css index 299a2de..5ee912c 100644 --- a/www/css/panel/profile.css +++ b/www/css/panel/profile.css @@ -24,6 +24,12 @@ along with this program. If not, see .*/ .panel.profile-name{ text-align: center; + margin-bottom: 0; +} + +.panel.profile-status{ + text-align: center; + margin-bottom: 1em; } .panel.profile-img{ diff --git a/www/css/profile.css b/www/css/profile.css index 55c00fb..84a4088 100644 --- a/www/css/profile.css +++ b/www/css/profile.css @@ -56,6 +56,11 @@ along with this program. If not, see .*/ margin: 0; } +#profile-status{ + margin: 0; + text-wrap: nowrap; +} + #profile-img{ position: relative; display: flex; diff --git a/www/css/theme/movie-night.css b/www/css/theme/movie-night.css index d7443ae..fee6c3e 100644 --- a/www/css/theme/movie-night.css +++ b/www/css/theme/movie-night.css @@ -31,6 +31,7 @@ along with this program. If not, see .*/ --accent0-alt1: rgb(70, 70, 70); --accent1: rgb(245, 245, 245); --accent1-alt0: rgb(185, 185, 185); + --accent1-alt1: rgb(124, 124, 124); --accent2: var(--accent0-alt0); --focus0: rgb(51, 153, 51); @@ -157,6 +158,14 @@ textarea{ text-shadow: var(--focus-glow0); } +.positive-low{ + color: var(--focus0); +} + +.inactive{ + color: var(--accent1-alt1); +} + .danger-button{ background-color: var(--danger0); color: var(--accent1); From d541dce8c45490fb108d26048f4d6c9641faac23 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Thu, 18 Sep 2025 03:42:52 -0400 Subject: [PATCH 04/92] Starting work on private messaging back-end. --- src/app/{channel => pm}/message.js | 0 src/app/pm/pmHandler.js | 51 ++++++++++++++++++++++++++++++ src/server.js | 2 ++ www/js/channel/channel.js | 9 ++++-- 4 files changed, 59 insertions(+), 3 deletions(-) rename src/app/{channel => pm}/message.js (100%) create mode 100644 src/app/pm/pmHandler.js diff --git a/src/app/channel/message.js b/src/app/pm/message.js similarity index 100% rename from src/app/channel/message.js rename to src/app/pm/message.js diff --git a/src/app/pm/pmHandler.js b/src/app/pm/pmHandler.js new file mode 100644 index 0000000..29b4ad1 --- /dev/null +++ b/src/app/pm/pmHandler.js @@ -0,0 +1,51 @@ +/*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 .*/ + +//local includes +const config = require("../../../config.json"); +const csrfUtils = require("../../utils/csrfUtils"); +const userBanModel = require("../../schemas/user/userBanSchema"); + +/** + * Class containg global server-side private message relay logic + */ +class pmHandler{ + /** + * Instantiates object containing global server-side private message relay logic + * @param {Socket.io} io - Socket.io server instanced passed down from server.js + */ + constructor(io){ + /** + * Socket.io server instance passed down from server.js + */ + this.io = io; + + /** + * Socket.io server namespace for handling messaging + */ + this.namespace = io.of('/pm'); + + //Handle connections from private messaging namespace + this.namespace.on("connection", this.handleConnection.bind(this) ); + } + + async handleConnection(socket){ + + } + +} + +module.exports = pmHandler; \ No newline at end of file diff --git a/src/server.js b/src/server.js index f584830..6005d30 100644 --- a/src/server.js +++ b/src/server.js @@ -33,6 +33,7 @@ const mongoose = require('mongoose'); //Define Local Imports //Application const channelManager = require('./app/channel/channelManager'); +const pmHandler = require('./app/pm/pmHandler'); //Util const configCheck = require('./utils/configCheck'); const scheduler = require('./utils/scheduler'); @@ -196,6 +197,7 @@ scheduler.kickoff(); //Hand over general-namespace socket.io connections to the channel manager module.exports.channelManager = new channelManager(io) +module.exports.pmHandler = new pmHandler(io) //Listen Function webServer.listen(port, () => { diff --git a/www/js/channel/channel.js b/www/js/channel/channel.js index fd72349..f7955d1 100644 --- a/www/js/channel/channel.js +++ b/www/js/channel/channel.js @@ -68,12 +68,15 @@ class channel{ * Handles initial client connection */ connect(){ - this.socket = io({ - extraHeaders: { + const clientOptions = { + extraHeaders: { //Include CSRF token 'x-csrf-token': utils.ajax.getCSRFToken() } - }); + }; + + this.socket = io(clientOptions); + this.pmSocket = io("/pm", clientOptions); } /** From 7da07c8717a5cde134761fdcb03c340e56ab9cc3 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Thu, 18 Sep 2025 04:13:19 -0400 Subject: [PATCH 05/92] Seperated out socket validation/authorization from channel mangement logic. --- src/app/channel/channelManager.js | 71 ++-------------------- src/utils/presenceUtils.js | 6 +- src/utils/socketUtils.js | 97 +++++++++++++++++++++++++++++++ www/js/channel/channel.js | 8 +-- 4 files changed, 108 insertions(+), 74 deletions(-) create mode 100644 src/utils/socketUtils.js diff --git a/src/app/channel/channelManager.js b/src/app/channel/channelManager.js index 4b594ad..b36d7b5 100644 --- a/src/app/channel/channelManager.js +++ b/src/app/channel/channelManager.js @@ -22,6 +22,7 @@ const channelModel = require('../../schemas/channel/channelSchema'); const emoteModel = require('../../schemas/emoteSchema'); const {userModel} = require('../../schemas/user/userSchema'); const userBanModel = require('../../schemas/user/userBanSchema'); +const socketUtils = require('../../utils/socketUtils'); const loggerUtils = require('../../utils/loggerUtils'); const csrfUtils = require('../../utils/csrfUtils'); const presenceUtils = require('../../utils/presenceUtils'); @@ -68,7 +69,7 @@ class channelManager{ async handleConnection(socket){ try{ //ensure unbanned ip and valid CSRF token - if(!(await this.validateSocket(socket))){ + if(!(await socketUtils.validateSocket(socket))){ socket.disconnect(); return; } @@ -76,7 +77,7 @@ class channelManager{ //Prevent logged out connections and authenticate socket if(socket.request.session.user != null){ //Authenticate socket - const userDB = await this.authSocket(socket); + const userDB = await socketUtils.authSocket(socket); //Get the active channel based on the socket var {activeChan, chanDB} = await this.getActiveChan(socket); @@ -146,71 +147,7 @@ class channelManager{ //Flip a table if something fucks up return loggerUtils.socketCriticalExceptionHandler(socket, err); } - } - - /** - * Global server-side validation logic for new connections to any channel - * @param {Socket} socket - Requesting Socket - * @returns {Boolean} true on success - */ - async validateSocket(socket){ - //If we're proxied use passthrough IP - const ip = config.proxied ? socket.handshake.headers['x-forwarded-for'] : socket.handshake.address; - - //Look for ban by IP - const ipBanDB = await userBanModel.checkBanByIP(ip); - - //If this ip is randy bobandy - if(ipBanDB != null){ - //Make the number a little prettier despite the lack of precision since we're not doing calculations here :P - const expiration = ipBanDB.getDaysUntilExpiration() < 1 ? 0 : ipBanDB.getDaysUntilExpiration(); - - //If the ban is permanent - if(ipBanDB.permanent){ - //tell it to fuck off - socket.emit("kick", {type: "kicked", reason: `The IP address you are trying to connect from has been permanently banned. Your cleartext IP has been saved to the database. Any associated accounts will be nuked in ${expiration} day(s).`}); - //Otherwise - }else{ - //tell it to fuck off - socket.emit("kick", {type: "kicked", reason: `The IP address you are trying to connect from has been temporarily banned. Your cleartext IP has been saved to the database until the ban expires in ${expiration} day(s).`}); - } - - - 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; - } - - /** - * Global server-side authorization logic for new connections to any channel - * @param {Socket} socket - Requesting Socket - * @returns {Mongoose.Document} - Authorized User Document upon success - */ - async authSocket(socket){ - //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}); - - if(userDB == null){ - throw loggerUtils.exceptionSmith("User not found!", "unauthorized"); - } - - //Set socket user and channel values - socket.user = { - id: userDB.id, - user: userDB.user, - }; - - return userDB; - } + } /** * Gets active channel from a given socket diff --git a/src/utils/presenceUtils.js b/src/utils/presenceUtils.js index 0096b69..310db6c 100644 --- a/src/utils/presenceUtils.js +++ b/src/utils/presenceUtils.js @@ -16,7 +16,7 @@ along with this program. If not, see .*/ //local includes const server = require('../server'); -const userSchema = require('../schemas/user/userSchema'); +const {userModel} = require('../schemas/user/userSchema'); //User activity map to keep us from constantly reading off of the DB let activityMap = new Map(); @@ -47,7 +47,7 @@ module.exports.getPresence = async function(user, userDB){ //If we wheren't handed a free user doc if(userDB == null){ //Pull one from the username - userDB = await userSchema.userModel.findOne({user: user}); + userDB = await userModel.findOne({user: user}); } //If for some reason we can't find a user doc @@ -119,7 +119,7 @@ module.exports.handlePresence = async function(user, userDB, noSave = false){ //If we wheren't handed a free user doc if(userDB == null){ //Pull one from the username - userDB = await userSchema.userModel.findOne({user: user}); + userDB = await userModel.findOne({user: user}); } //Set last active in user's DB document diff --git a/src/utils/socketUtils.js b/src/utils/socketUtils.js new file mode 100644 index 0000000..68fc46a --- /dev/null +++ b/src/utils/socketUtils.js @@ -0,0 +1,97 @@ +/*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 .*/ + +const config = require('../../config.json'); +const csrfUtils = require('./csrfUtils'); +const {userModel} = require('../schemas/user/userSchema'); +const userBanModel = require('../schemas/user/userBanSchema'); + +module.exports.validateSocket = async function(socket, quiet = false){ + //If we're proxied use passthrough IP + const ip = config.proxied ? socket.handshake.headers['x-forwarded-for'] : socket.handshake.address; + + //Look for ban by IP + const ipBanDB = await userBanModel.checkBanByIP(ip); + + //If this ip is randy bobandy + if(ipBanDB != null){ + //Make the number a little prettier despite the lack of precision since we're not doing calculations here :P + const expiration = ipBanDB.getDaysUntilExpiration() < 1 ? 0 : ipBanDB.getDaysUntilExpiration(); + + if(quiet){ + socket.disconnect(); + }else{ + //If the ban is permanent + if(ipBanDB.permanent){ + //tell it to fuck off + socket.emit("kick", {type: "kicked", reason: `The IP address you are trying to connect from has been permanently banned. Your cleartext IP has been saved to the database. Any associated accounts will be nuked in ${expiration} day(s).`}); + //Otherwise + }else{ + //tell it to fuck off + socket.emit("kick", {type: "kicked", reason: `The IP address you are trying to connect from has been temporarily banned. Your cleartext IP has been saved to the database until the ban expires in ${expiration} day(s).`}); + } + } + + return false; + } + + + //Check for Cross-Site Request Forgery + if(!csrfUtils.isRequestValid(socket.request)){ + if(quiet){ + socket.disconnect(); + }else{ + socket.emit("kick", {type: "disconnected", reason: "Invalid CSRF Token!"}); + } + + return false; + } + + + return true; +} + +//socket.request.session is already trusted, we don't actually need to verify against DB for authorzation +//It's just a useful place to grab the DB doc, and is mostly a stand-in from when the only socket-related code was in the channel folder +module.exports.authSocketLite = async function(socket){ + const user = socket.request.session.user; + + //Set socket user and channel values + socket.user = { + id: user.id, + user: user.user, + }; + + //return user object from session + return user; +} + +module.exports.authSocket = async function(socket){ + //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}); + + if(userDB == null){ + throw loggerUtils.exceptionSmith("User not found!", "unauthorized"); + } + + //Set socket user and channel values + socket.user = { + id: userDB.id, + user: userDB.user, + }; + + return userDB; +} \ No newline at end of file diff --git a/www/js/channel/channel.js b/www/js/channel/channel.js index f7955d1..0377d1e 100644 --- a/www/js/channel/channel.js +++ b/www/js/channel/channel.js @@ -89,11 +89,11 @@ class channel{ this.socket.on("kick", async (data) => { if(data.reason == "Invalid CSRF Token!"){ - //Reload the CSRF token - await utils.ajax.reloadCSRFToken(); + //Warn the user + new canopyUXUtils.popup('Invalid CSRF Token detected, reloading client...'); - //Retry the connection - this.connect(); + //Just reload the fucker + setTimeout(()=>{location.reload();}, 1000); }else{ new canopyUXUtils.popup(`You have been ${data.type} from the channel for the following reason:
${data.reason}`); } From 67edef9035932b0ae5c6e383329acbf4b0d170c7 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Fri, 19 Sep 2025 03:47:19 -0400 Subject: [PATCH 06/92] Base implementation of PM back-end started. --- src/app/pm/message.js | 8 +-- src/app/pm/pmHandler.js | 125 +++++++++++++++++++++++++++++++++++- src/utils/loggerUtils.js | 29 ++++++++- src/utils/socketUtils.js | 8 ++- www/js/channel/pmHandler.js | 24 +++++++ 5 files changed, 182 insertions(+), 12 deletions(-) create mode 100644 www/js/channel/pmHandler.js diff --git a/src/app/pm/message.js b/src/app/pm/message.js index eef6658..f2c216c 100644 --- a/src/app/pm/message.js +++ b/src/app/pm/message.js @@ -20,20 +20,20 @@ along with this program. If not, see .*/ class message{ /** * Instantiates a chat message object - * @param {connectedUser} sender - User who sent the message - * @param {Array} recipients - Array of connected users who are supposed to receive the message + * @param {String} sender - Name of user who sent the message + * @param {Array} recipients - Array of usernames who are supposed to receive the message * @param {String} msg - Contents of the message, with links replaced with numbered file-seperator markers * @param {Array} links - Array of URLs/Links included in the message. */ constructor(sender, recipients, msg, links){ /** - * User who sent the message + * Name of user who sent the message */ this.sender = sender; /** - * Array of strings containing usernames to send message to + * Array of usernames who are supposed to receive the message */ this.recipients = recipients; diff --git a/src/app/pm/pmHandler.js b/src/app/pm/pmHandler.js index 29b4ad1..01af129 100644 --- a/src/app/pm/pmHandler.js +++ b/src/app/pm/pmHandler.js @@ -14,10 +14,13 @@ 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 validator = require('validator');//No express here, so regular validator it is! + //local includes -const config = require("../../../config.json"); -const csrfUtils = require("../../utils/csrfUtils"); -const userBanModel = require("../../schemas/user/userBanSchema"); +const loggerUtils = require("../../utils/loggerUtils"); +const socketUtils = require("../../utils/socketUtils"); +const message = require("./message"); /** * Class containg global server-side private message relay logic @@ -43,7 +46,123 @@ class pmHandler{ } async handleConnection(socket){ + try{ + //ensure unbanned ip and valid CSRF token + if(!(await socketUtils.validateSocket(socket))){ + socket.disconnect(); + return; + } + //If the socket wasn't authorized + if(await socketUtils.authSocketLite(socket) == null){ + socket.disconnect(); + return; + } + + //Throw socket into room named after it's user + socket.join(socket.user.user); + + //Define network related event listeners against socket + this.defineListeners(socket); + }catch(err){ + //Flip a table if something fucks up + return loggerUtils.socketCriticalExceptionHandler(socket, err); + } + } + + defineListeners(socket){ + socket.on("pm", (data)=>{this.handlePM(data, socket)}); + } + + async handlePM(data, socket){ + try{ + //Create empty list of recipients + let recipients = []; + + //For each requested recipient + for(let user of data.recipients){ + //If the given user is online and didn't send the message + if(this.checkPresence(user) && user != socket.user.user){ + //Add the recipient to the list + recipients.push(user); + } + } + + //If we don't have any valid recipients + if(recipients.length <= 0){ + //Drop that shit + return; + } + + //Sanatize Message + const msg = this.sanatizeMessage(data.msg); + + //If we have an invalid message + if(msg == null){ + //Drop that shit + return; + } + + //Create message object and relay it off to the recipients + this.relayPMObj(new message( + socket.user.user, + recipients, + msg, + [] + )); + + //If something fucked up + }catch(err){ + //Bitch and moan + return loggerUtils.socketExceptionHandler(socket, err); + } + } + + relayPMObj(msg){ + //For each recipient + for(let user of msg.recipients){ + //Send the message + this.namespace.to(user).emit("message", msg); + } + + //Acknowledge the sent message + this.namespace.to(msg.sender).emit("sent", msg); + } + + /** + * Basic function for checking presence + * This could be done using Channel Presence, but running off of bare Socket.io functionality makes this easier to implement outside the channel if need be + * @param {String} user - Username to check presence of + * @returns {Boolean} Whether or not the user is currently able to accept messages + */ + checkPresence(user){ + //Pull room map from the guts of socket.io and run a null check against the given username + return this.namespace.adapter.rooms.get(user) != null; + } + + /** + * Sanatizes and Validates a single message, Temporary until we get commandPreprocessor split up. + * @param {String} msg - message to validate/sanatize + * @returns {String} sanatized/validates message, returns null on validation failure + */ + sanatizeMessage(msg){ + //if msg is empty or null + if(msg == null || msg == ''){ + //Pimp slap that shit into fucking oblivion + return null; + } + + //Trim and Sanatize for XSS + msg = validator.trim(validator.escape(msg)); + + //Return whether or not the shit was long enough + if(validator.isLength(msg, {min: 1, max: 255})){ + //If it's valid return the message + return msg; + } + + //if not return nothing + return null; } } diff --git a/src/utils/loggerUtils.js b/src/utils/loggerUtils.js index 4c6b2cf..a3100e8 100644 --- a/src/utils/loggerUtils.js +++ b/src/utils/loggerUtils.js @@ -173,17 +173,40 @@ module.exports.errorMiddleware = function(err, req, res, next){ * @param {Error} err - error to dump to file * @param {Date} date - Date of error, defaults to now */ -module.exports.dumpError = function(err, date = new Date()){ +module.exports.dumpError = async function(err, date = new Date()){ try{ - const content = `Error Date: ${date.toLocaleString()} (UTC-${date.getTimezoneOffset()/60})\nError Type: ${err.name}\nError Msg:${err.message}\nStack Trace:\n\n${err.stack}`; - const path = `log/crash/${date.getTime()}.log`; + //Crash directory + const dir = "./log/crash/" + //Double check crash folder exists + try{ + await fs.stat(dir); + //If we caught an error (most likely it's missing) + }catch(err){ + //Shout about it + module.exports.consoleWarn("Log folder missing, mking dir!") + + //Make it if doesn't + await fs.mkdir(dir, {recursive: true}); + } + + //Assemble log file path + const path = `${dir}${date.getTime()}.log`; + //Generate error file content + const content = `Error Date: ${date.toLocaleString()} (UTC-${date.getTimezoneOffset()/60})\nError Type: ${err.name}\nError Msg:${err.message}\nStack Trace:\n\n${err.stack}`; + + //Write content to file fs.writeFile(path, content); + //Whine about the error module.exports.consoleWarn(`Warning: Unexpected Server Crash gracefully dumped to '${path}'... SOMETHING MAY BE VERY BROKEN!!!!`); + //If somethine went really really wrong }catch(doubleErr){ + //Use humor to cope with the pain module.exports.consoleWarn("Yo Dawg, I herd you like errors, so I put an error in your error dump, so you can dump while you dump:"); + //Dump the original error to console module.exports.consoleWarn(err); + //Dump the error we had saving that error to file to console module.exports.consoleWarn(doubleErr); } } \ No newline at end of file diff --git a/src/utils/socketUtils.js b/src/utils/socketUtils.js index 68fc46a..765ea02 100644 --- a/src/utils/socketUtils.js +++ b/src/utils/socketUtils.js @@ -67,7 +67,11 @@ module.exports.validateSocket = async function(socket, quiet = false){ //socket.request.session is already trusted, we don't actually need to verify against DB for authorzation //It's just a useful place to grab the DB doc, and is mostly a stand-in from when the only socket-related code was in the channel folder module.exports.authSocketLite = async function(socket){ - const user = socket.request.session.user; + const user = socket.request.session.user; + + if(user == null){ + return null; + } //Set socket user and channel values socket.user = { @@ -84,7 +88,7 @@ module.exports.authSocket = async function(socket){ const userDB = await userModel.findOne({user: socket.request.session.user.user}); if(userDB == null){ - throw loggerUtils.exceptionSmith("User not found!", "unauthorized"); + return null; } //Set socket user and channel values diff --git a/www/js/channel/pmHandler.js b/www/js/channel/pmHandler.js new file mode 100644 index 0000000..94f392b --- /dev/null +++ b/www/js/channel/pmHandler.js @@ -0,0 +1,24 @@ +/*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 .*/ + +/** + * Class for handling incoming Private Messages + */ +class pmHandler{ + constructor(client){ + this.client = client; + } +} \ No newline at end of file From e19ae744121aabd4478ab2341f754cdbee57bbef Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Thu, 25 Sep 2025 23:32:04 -0400 Subject: [PATCH 07/92] Started work on PM Panel layout --- src/app/pm/pmHandler.js | 4 ++ src/controllers/panel/pmController.js | 20 +++++++++ src/routers/panelRouter.js | 2 + src/views/channel.ejs | 2 + src/views/partial/channel/chatPanel.ejs | 1 + src/views/partial/panels/pm.ejs | 37 +++++++++++++++++ www/css/panel/pm.css | 54 +++++++++++++++++++++++++ www/css/theme/movie-night.css | 17 ++++++++ www/js/channel/channel.js | 5 +++ www/js/channel/panels/pmPanel.js | 43 ++++++++++++++++++++ www/js/channel/pmHandler.js | 13 ++++++ 11 files changed, 198 insertions(+) create mode 100644 src/controllers/panel/pmController.js create mode 100644 src/views/partial/panels/pm.ejs create mode 100644 www/css/panel/pm.css create mode 100644 www/js/channel/panels/pmPanel.js diff --git a/src/app/pm/pmHandler.js b/src/app/pm/pmHandler.js index 01af129..cf8daab 100644 --- a/src/app/pm/pmHandler.js +++ b/src/app/pm/pmHandler.js @@ -45,6 +45,10 @@ class pmHandler{ this.namespace.on("connection", this.handleConnection.bind(this) ); } + /** + * Handles global server-side initialization for new connections to the private messaging system + * @param {Socket} socket - Requesting Socket + */ async handleConnection(socket){ try{ //ensure unbanned ip and valid CSRF token diff --git a/src/controllers/panel/pmController.js b/src/controllers/panel/pmController.js new file mode 100644 index 0000000..7b46625 --- /dev/null +++ b/src/controllers/panel/pmController.js @@ -0,0 +1,20 @@ +/*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 .*/ + +//root index functions +module.exports.get = async function(req, res){ + res.render('partial/panels/pm', {}); +} \ No newline at end of file diff --git a/src/routers/panelRouter.js b/src/routers/panelRouter.js index 7cafe14..59888c1 100644 --- a/src/routers/panelRouter.js +++ b/src/routers/panelRouter.js @@ -25,6 +25,7 @@ const popoutContainerController = require("../controllers/panel/popoutContainerC const profileController = require("../controllers/panel/profileController"); const queueController = require("../controllers/panel/queueController"); const settingsController = require("../controllers/panel/settingsController"); +const pmController = require("../controllers/panel/pmController"); //Validators const accountValidator = require("../validators/accountValidator"); @@ -38,5 +39,6 @@ router.get('/popoutContainer', popoutContainerController.get); router.get('/profile', accountValidator.user(), profileController.get); router.get('/queue', queueController.get); router.get('/settings', settingsController.get); +router.get('/pm', pmController.get); module.exports = router; diff --git a/src/views/channel.ejs b/src/views/channel.ejs index 561702d..07d9f36 100644 --- a/src/views/channel.ejs +++ b/src/views/channel.ejs @@ -47,12 +47,14 @@ along with this program. If not, see . %> + <%# panels %> + <%# main client %> diff --git a/src/views/partial/channel/chatPanel.ejs b/src/views/partial/channel/chatPanel.ejs index b5c4bef..e56c28a 100644 --- a/src/views/partial/channel/chatPanel.ejs +++ b/src/views/partial/channel/chatPanel.ejs @@ -74,6 +74,7 @@ along with this program. If not, see . %>
+ diff --git a/src/views/partial/panels/pm.ejs b/src/views/partial/panels/pm.ejs new file mode 100644 index 0000000..af3e372 --- /dev/null +++ b/src/views/partial/panels/pm.ejs @@ -0,0 +1,37 @@ +<%# 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 . %> + +
+
+
+ + Start Sesh +
+
+ +
+
+
+
+ +
+
+ + +
+
+
+
\ No newline at end of file diff --git a/www/css/panel/pm.css b/www/css/panel/pm.css new file mode 100644 index 0000000..09b3c56 --- /dev/null +++ b/www/css/panel/pm.css @@ -0,0 +1,54 @@ +/*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 .*/ +#pm-panel-main-div{ + display: flex; + flex-direction: horizontal; + height: 100%; +} + +#pm-panel-sesh-list-container{ + flex: 1; + max-width: 10em; +} + +#pm-panel-sesh-container{ + display: flex; + flex-direction: column; + flex: 1; + height: calc(100% - 0.25em); + margin-top: 0; +} + +#pm-panel-start-sesh{ + width: calc(100% - 1.25em); + margin-left: 0.25em; + padding: 0 0.5em; + text-wrap: nowrap; + display: flex; +} + +#pm-panel-start-sesh span{ + flex: 1; + text-align: center; +} + +#pm-panel-sesh-buffer{ + flex: 1; +} + +#pm-panel-sesh-control-div{ + margin: 0.5em; +} \ No newline at end of file diff --git a/www/css/theme/movie-night.css b/www/css/theme/movie-night.css index fee6c3e..1cfda84 100644 --- a/www/css/theme/movie-night.css +++ b/www/css/theme/movie-night.css @@ -103,6 +103,14 @@ a:active, i:active:not(button i), .interactive:active{ text-shadow: var(--focus-glow0-alt0); } +div.interactive:hover{ + background-color: var(--bg2); +} + +div.interactive:active{ + background-color: var(--bg1); +} + button i{ margin: 0.05em; text-wrap: nowrap; @@ -610,6 +618,15 @@ div.archived p{ border-left: var(--accent1-alt0) solid 1px; } +/* PM Panel */ +#pm-panel-sesh-container{ + border-left: 1px solid var(--accent0); +} + +#pm-panel-start-sesh{ + border-bottom: 1px solid var(--accent0); +} + /* altcha theming*/ div.altcha{ box-shadow: 4px 4px 1px var(--bg1-alt0) inset; diff --git a/www/js/channel/channel.js b/www/js/channel/channel.js index 0377d1e..523cf6b 100644 --- a/www/js/channel/channel.js +++ b/www/js/channel/channel.js @@ -51,6 +51,11 @@ class channel{ * Child User List Object */ this.userList = new userList(this); + + /** + * Child PM Handler + */ + this.pmHandler = new pmHandler(this); /** * Child Canopy Panel Object diff --git a/www/js/channel/panels/pmPanel.js b/www/js/channel/panels/pmPanel.js new file mode 100644 index 0000000..761c2d8 --- /dev/null +++ b/www/js/channel/panels/pmPanel.js @@ -0,0 +1,43 @@ +/*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 .*/ + +/** + * Class representing the settings panel + * @extends panelObj + */ +class pmPanel extends panelObj{ + /** + * Instantiates a new Panel Object + * @param {channel} client - Parent client Management Object + * @param {Document} panelDocument - Panel Document + */ + constructor(client, panelDocument){ + super(client, "Private Messaging", "/panel/pm", panelDocument); + } + + closer(){ + } + + docSwitch(){ + this.setupInput(); + } + + /** + * Defines input-related event handlers + */ + setupInput(){ + } +} \ No newline at end of file diff --git a/www/js/channel/pmHandler.js b/www/js/channel/pmHandler.js index 94f392b..956bd57 100644 --- a/www/js/channel/pmHandler.js +++ b/www/js/channel/pmHandler.js @@ -20,5 +20,18 @@ along with this program. If not, see .*/ class pmHandler{ constructor(client){ this.client = client; + + this.pmIcon = document.querySelector('#chat-panel-pm-icon'); + + this.defineListeners(); + this.setupInput(); + } + + defineListeners(){ + + } + + setupInput(){ + this.pmIcon.addEventListener("click", ()=>{this.client.cPanel.setActivePanel(new pmPanel(client))}); } } \ No newline at end of file From d8e5c64c139ac524f236a2632639a7b07705075a Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Tue, 30 Sep 2025 03:25:15 -0400 Subject: [PATCH 08/92] Starting work on client-side chat sesh handling --- www/js/channel/pmHandler.js | 81 ++++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 2 deletions(-) diff --git a/www/js/channel/pmHandler.js b/www/js/channel/pmHandler.js index 956bd57..c26f47c 100644 --- a/www/js/channel/pmHandler.js +++ b/www/js/channel/pmHandler.js @@ -15,23 +15,100 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see .*/ /** - * Class for handling incoming Private Messages + * Class for handling incoming Private Messages for the entire client */ class pmHandler{ + /** + * Instantiates a new Private Message Handler object + * @param {channel} client - Parent client Management Object + */ constructor(client){ + /** + * Parent client management object + */ this.client = client; + /** + * PM Icon in the main chat bar + */ this.pmIcon = document.querySelector('#chat-panel-pm-icon'); + /** + * List of PM Sessions + */ + this.seshList = []; + this.defineListeners(); this.setupInput(); } + /** + * Defines network related event listeners for PM Handler + */ defineListeners(){ - + this.client.pmSocket.on("message", this.handlePM.bind(this)); + this.client.pmSocket.on("sent", this.handlePM.bind(this)); } + /** + * Defines inpet related event listeners for PM handler + */ setupInput(){ this.pmIcon.addEventListener("click", ()=>{this.client.cPanel.setActivePanel(new pmPanel(client))}); } + + /** + * Handles received Private Messages from the PM service on the server, organizing it into the proper session from the sesh list + * Or creating a new sesh where a matching one does not a exist + * @param {object} data - Private Message data from the server + */ + handlePM(data){ + //Store whether or not current message has been consumed by an existing sesh + let consumed = false; + + //For each existing sesh + for(let seshIndex in this.seshList){ + //Get current sesh + const sesh = this.seshList[seshIndex]; + + //Check to see if the length of sesh recipients equals current length (only check on arrays that actually make sense to save time) + if(sesh.recipients.length == data.recipients.length){ + /*Feels like cheating to have the JS engine to the hard bits by just telling it to sort them. + That being said, since the function is implemented into the JS Engine itself + It will be quicker than any custom comparison code we can write*/ + + //Sort recipient lists so lists with the same user will be equal when joined together in a string and compare, if they're the same... + if(sesh.recipients.sort().join() == data.recipients.sort().join()){ + //Dump collected message into the matching session + this.seshList[seshIndex].messages.push(data); + + //Let the rest of the method know that we've consumed this message + consumed = true; + } + } + } + + //If we made it through the loop without consuming the message + if(!consumed){ + //Add it to it's own fresh new sesh + this.seshList.push(new pmSesh(data)); + } + } +} + +/** + * Class which represents an existing Private Messaging session between two or more users + */ +class pmSesh{ + /** + * Instatiates a new pmSession object + * @param {Object} message - Initial Private Message object from server that initiated the session + */ + constructor(message){ + //Add recipients from message + this.recipients = message.recipients; + + //Add message to messages array + this.messages = [message]; + } } \ No newline at end of file From e2020406a7884815b8b83bd1758455faadf88081 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Tue, 30 Sep 2025 04:39:17 -0400 Subject: [PATCH 09/92] Improved Private Message Session Management --- www/js/channel/pmHandler.js | 56 ++++++++++++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/www/js/channel/pmHandler.js b/www/js/channel/pmHandler.js index c26f47c..09e1a3a 100644 --- a/www/js/channel/pmHandler.js +++ b/www/js/channel/pmHandler.js @@ -66,19 +66,37 @@ class pmHandler{ //Store whether or not current message has been consumed by an existing sesh let consumed = false; + //Create members array from scratch to avoid changing the input data for further processing + const members = []; + + //Manually iterate through recipients + for(const member of data.recipients){ + //check to make sure we're not adding ourselves + if(member != this.client.user.user){ + //Copy relevant array members by value instead of reference + members.push(member); + } + } + + //If this wasn't our message + if(data.sender != this.client.user.user){ + //Push sender onto members list + members.push(data.sender); + } + //For each existing sesh for(let seshIndex in this.seshList){ //Get current sesh const sesh = this.seshList[seshIndex]; //Check to see if the length of sesh recipients equals current length (only check on arrays that actually make sense to save time) - if(sesh.recipients.length == data.recipients.length){ + if(sesh.recipients.length == members.length){ /*Feels like cheating to have the JS engine to the hard bits by just telling it to sort them. That being said, since the function is implemented into the JS Engine itself It will be quicker than any custom comparison code we can write*/ //Sort recipient lists so lists with the same user will be equal when joined together in a string and compare, if they're the same... - if(sesh.recipients.sort().join() == data.recipients.sort().join()){ + if(sesh.recipients.sort().join() == members.sort().join()){ //Dump collected message into the matching session this.seshList[seshIndex].messages.push(data); @@ -91,7 +109,7 @@ class pmHandler{ //If we made it through the loop without consuming the message if(!consumed){ //Add it to it's own fresh new sesh - this.seshList.push(new pmSesh(data)); + this.seshList.push(new pmSesh(data, client)); } } } @@ -104,11 +122,35 @@ class pmSesh{ * Instatiates a new pmSession object * @param {Object} message - Initial Private Message object from server that initiated the session */ - constructor(message){ - //Add recipients from message - this.recipients = message.recipients; + constructor(message, client){ + /** + * Parent client management object + */ + this.client = client; - //Add message to messages array + /** + * Members of session excluding the currently logged in user + */ + this.recipients = []; + + //Manually iterate through recipients + for(const member of message.recipients){ + //check to make sure we're not adding ourselves + if(member != this.client.user.user){ + //Copy relevant array members by value instead of reference + this.recipients.push(member); + } + } + + //If this wasn't our message + if(message.sender != this.client.user.user){ + //Push sender onto members list + this.recipients.push(message.sender); + } + + /** + * Array containing all session messages + */ this.messages = [message]; } } \ No newline at end of file From a681bddbf78002a4f4f1bfafe5d88d5400c5a39f Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Tue, 30 Sep 2025 04:39:39 -0400 Subject: [PATCH 10/92] Cleaned up emotePanel.js --- www/js/channel/panels/emotePanel.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/www/js/channel/panels/emotePanel.js b/www/js/channel/panels/emotePanel.js index 98a412b..877b4f7 100644 --- a/www/js/channel/panels/emotePanel.js +++ b/www/js/channel/panels/emotePanel.js @@ -27,6 +27,13 @@ class emotePanel extends panelObj{ constructor(client, panelDocument){ super(client, "Emote Palette", "/panel/emote", panelDocument); + this.defineListeners(); + } + + /** + * Defines network related listeners + */ + defineListeners(){ this.client.socket.on("personalEmotes", this.renderEmoteLists.bind(this)); } From f109314163d5f7907b7c8e460c15f500e03c9d7f Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Tue, 30 Sep 2025 05:03:36 -0400 Subject: [PATCH 11/92] Added basic sesh list rendering to PM panel UX --- www/css/panel/pm.css | 18 +++++++++++-- www/css/theme/movie-night.css | 5 ++++ www/js/channel/panels/pmPanel.js | 45 ++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/www/css/panel/pm.css b/www/css/panel/pm.css index 09b3c56..df2db42 100644 --- a/www/css/panel/pm.css +++ b/www/css/panel/pm.css @@ -22,6 +22,8 @@ along with this program. If not, see .*/ #pm-panel-sesh-list-container{ flex: 1; max-width: 10em; + width: calc(100% - 1.25em); + margin-left: 0.25em; } #pm-panel-sesh-container{ @@ -33,11 +35,11 @@ along with this program. If not, see .*/ } #pm-panel-start-sesh{ - width: calc(100% - 1.25em); - margin-left: 0.25em; padding: 0 0.5em; text-wrap: nowrap; display: flex; + font-size: 1.2em; + font-weight: bold; } #pm-panel-start-sesh span{ @@ -51,4 +53,16 @@ along with this program. If not, see .*/ #pm-panel-sesh-control-div{ margin: 0.5em; +} + +div.pm-panel-sesh-list-entry{ + padding: 0 0.5em; + display: flex; + flex-direction: row; +} + +div.pm-panel-sesh-list-entry, div.pm-panel-sesh-list-entry p{ + margin: 0; + text-wrap: nowrap; + text-align: center; } \ No newline at end of file diff --git a/www/css/theme/movie-night.css b/www/css/theme/movie-night.css index 1cfda84..96e5793 100644 --- a/www/css/theme/movie-night.css +++ b/www/css/theme/movie-night.css @@ -627,6 +627,11 @@ div.archived p{ border-bottom: 1px solid var(--accent0); } + +div.pm-panel-sesh-list-entry{ + border-bottom: 1px solid var(--accent0); +} + /* altcha theming*/ div.altcha{ box-shadow: 4px 4px 1px var(--bg1-alt0) inset; diff --git a/www/js/channel/panels/pmPanel.js b/www/js/channel/panels/pmPanel.js index 761c2d8..e502f4f 100644 --- a/www/js/channel/panels/pmPanel.js +++ b/www/js/channel/panels/pmPanel.js @@ -26,13 +26,26 @@ class pmPanel extends panelObj{ */ constructor(client, panelDocument){ super(client, "Private Messaging", "/panel/pm", panelDocument); + + this.defineListeners(); } closer(){ } docSwitch(){ + this.seshList = this.panelDocument.querySelector('#pm-panel-sesh-list'); + this.setupInput(); + + this.renderSeshList(); + } + + /** + * Defines network related event listeners + */ + defineListeners(){ + } /** @@ -40,4 +53,36 @@ class pmPanel extends panelObj{ */ setupInput(){ } + + /** + * Render out current sesh array to sesh list UI + */ + renderSeshList(){ + //For each session tracked by the pmHandler + for(const sesh of this.client.pmHandler.seshList){ + this.renderSeshListEntry(sesh); + } + } + + /** + * Renders out a given messaging sesh to the sesh list UI + */ + renderSeshListEntry(sesh){ + //Create container div + const entryDiv = document.createElement('div'); + //Set conatiner div classes + entryDiv.classList.add('pm-panel-sesh-list-entry','interactive'); + + + //Create sesh label + const seshLabel = document.createElement('p'); + //Create human-readable label out of members array + seshLabel.textContent = utils.unescapeEntities(sesh.recipients.sort().join(', ')); + + //append sesh label to entry div + entryDiv.appendChild(seshLabel); + + //Append entry div to sesh list + this.seshList.appendChild(entryDiv); + } } \ No newline at end of file From e81a4c0973f6a17b4f2feff33b791ab470ee8cca Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Wed, 1 Oct 2025 04:33:24 -0400 Subject: [PATCH 12/92] Basic chat UI complete. --- src/app/pm/pmHandler.js | 11 +- src/views/partial/panels/pm.ejs | 8 +- www/css/panel/pm.css | 24 ++ www/css/popup/startChatSesh.css | 27 ++ www/css/theme/movie-night.css | 12 +- www/js/channel/panels/emotePanel.js | 3 + www/js/channel/panels/pmPanel.js | 246 +++++++++++++++++- .../channel/panels/queuePanel/queuePanel.js | 2 +- www/js/channel/panels/settingsPanel.js | 3 + www/js/channel/pmHandler.js | 105 ++++---- www/popup/startChatSesh.html | 23 ++ 11 files changed, 393 insertions(+), 71 deletions(-) create mode 100644 www/css/popup/startChatSesh.css create mode 100644 www/popup/startChatSesh.html diff --git a/src/app/pm/pmHandler.js b/src/app/pm/pmHandler.js index cf8daab..0014ec2 100644 --- a/src/app/pm/pmHandler.js +++ b/src/app/pm/pmHandler.js @@ -150,17 +150,14 @@ class pmHandler{ * @returns {String} sanatized/validates message, returns null on validation failure */ sanatizeMessage(msg){ - //if msg is empty or null - if(msg == null || msg == ''){ - //Pimp slap that shit into fucking oblivion - return null; - } + //Normally I'd kill empty messages here + //But instead we're allowing them for sesh startups //Trim and Sanatize for XSS msg = validator.trim(validator.escape(msg)); - //Return whether or not the shit was long enough - if(validator.isLength(msg, {min: 1, max: 255})){ + //Return whether or not the shit was too long + if(validator.isLength(msg, {min: 0, max: 255})){ //If it's valid return the message return msg; } diff --git a/src/views/partial/panels/pm.ejs b/src/views/partial/panels/pm.ejs index af3e372..958a18c 100644 --- a/src/views/partial/panels/pm.ejs +++ b/src/views/partial/panels/pm.ejs @@ -26,11 +26,13 @@ along with this program. If not, see . %>
- +
+

Start a sesh to start chatting!

+
- - + +
diff --git a/www/css/panel/pm.css b/www/css/panel/pm.css index df2db42..2e6663c 100644 --- a/www/css/panel/pm.css +++ b/www/css/panel/pm.css @@ -61,8 +61,32 @@ div.pm-panel-sesh-list-entry{ flex-direction: row; } + +div.pm-panel-sesh-list-entry p{ + pointer-events: none; +} + div.pm-panel-sesh-list-entry, div.pm-panel-sesh-list-entry p{ margin: 0; text-wrap: nowrap; text-align: center; +} + +#pm-panel-sesh-buffer span{ + display: flex; + flex-direction: row; + margin: 0; +} + +.pm-panel-sesh-message-sender, .pm-panel-sesh-message-content{ + margin: 0; + font-size: 10pt; +} + +#pm-panel-sesh-welcome{ + display: flex; + flex-direction: column; + justify-content: center; + text-align: center; + height: 100%; } \ No newline at end of file diff --git a/www/css/popup/startChatSesh.css b/www/css/popup/startChatSesh.css new file mode 100644 index 0000000..86cd92f --- /dev/null +++ b/www/css/popup/startChatSesh.css @@ -0,0 +1,27 @@ +/*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 .*/ +#pm-sesh-popup-div{ + display: flex; +} + + +#pm-sesh-popup-div p{ + margin: 0; +} + +#pm-sesh-popup-sup{ + font-size: 0.7em +} \ No newline at end of file diff --git a/www/css/theme/movie-night.css b/www/css/theme/movie-night.css index 96e5793..00e2f24 100644 --- a/www/css/theme/movie-night.css +++ b/www/css/theme/movie-night.css @@ -129,13 +129,13 @@ button{ border-radius: 0.5em; } -button:hover{ +button:hover:not([disabled]){ color: var(--focus0-alt1); background-color: var(--focus0-alt0); box-shadow: var(--focus-glow0); } -button:active{ +button:active:not([disabled]){ color: var(--focus0-alt0); background-color: var(--focus0-alt1); box-shadow: var(--focus-glow0-alt0); @@ -179,13 +179,13 @@ textarea{ color: var(--accent1); } -.danger-button:hover, .critical-danger-button, .critical-danger-button:hover{ +.danger-button:hover:not([disabled]), .critical-danger-button, .critical-danger-button:hover{ background-color: var(--danger0-alt1); color: var(--danger0-alt0); box-shadow: var(--danger-glow0); } -.critical-danger-button:hover{ +.critical-danger-button:hover:not([disabled]){ background-color: var(--danger0-alt2); } @@ -219,12 +219,12 @@ textarea{ color: white; } -.positive-button:hover{ +.positive-button:hover:not([disabled]){ color: var(--focus0-alt1); background-color: var(--focus0-alt0); } -.positive-button:active{ +.positive-button:active:not([disabled]){ color: var(--focus0-alt0); background-color: var(--focus0-alt1); } diff --git a/www/js/channel/panels/emotePanel.js b/www/js/channel/panels/emotePanel.js index 877b4f7..ee19936 100644 --- a/www/js/channel/panels/emotePanel.js +++ b/www/js/channel/panels/emotePanel.js @@ -64,6 +64,9 @@ class emotePanel extends panelObj{ this.setupInput(); this.renderEmoteLists(); + + //Call derived method + super.docSwitch(); } /** diff --git a/www/js/channel/panels/pmPanel.js b/www/js/channel/panels/pmPanel.js index e502f4f..85e191e 100644 --- a/www/js/channel/panels/pmPanel.js +++ b/www/js/channel/panels/pmPanel.js @@ -27,6 +27,11 @@ class pmPanel extends panelObj{ constructor(client, panelDocument){ super(client, "Private Messaging", "/panel/pm", panelDocument); + /** + * String to hold name of currently active sesh + */ + this.activeSesh = ""; + this.defineListeners(); } @@ -34,33 +39,112 @@ class pmPanel extends panelObj{ } docSwitch(){ + this.startSeshButton = this.panelDocument.querySelector('#pm-panel-start-sesh'); this.seshList = this.panelDocument.querySelector('#pm-panel-sesh-list'); + this.seshBuffer = this.panelDocument.querySelector('#pm-panel-sesh-buffer'); + this.seshPrompt = this.panelDocument.querySelector('#pm-panel-message-prompt'); + this.seshSendButton = this.panelDocument.querySelector('#pm-panel-send-button'); this.setupInput(); this.renderSeshList(); + + //If we have an active sesh + if(this.activeSesh != null && this.activeSesh != ""){ + //Render messages + this.renderMessages(); + } + + //Call derived method + super.docSwitch(); } /** * Defines network related event listeners */ defineListeners(){ - + this.client.pmSocket.on("message", this.handlePM.bind(this)); + this.client.pmSocket.on("sent", this.handlePM.bind(this)); } /** * Defines input-related event handlers */ setupInput(){ + this.startSeshButton.addEventListener('click', this.startSesh.bind(this)); + this.seshPrompt.addEventListener("keydown", this.send.bind(this)); + this.seshSendButton.addEventListener("click", this.send.bind(this)); + } + + startSesh(event){ + new startSeshPopup(event, this.client, this.renderSeshList.bind(this), this.ownerDoc); + } + + handlePM(data){ + const nameObj = pmHandler.genSeshName(data); + + //If this message is for the active sesh + if(nameObj.name == this.activeSesh){ + //Render out the newest message + this.renderMessage(data); + }else{ + //pull current session entry if it exists + const curEntry = this.panelDocument.querySelector(`[data-id="${nameObj.name}"]`); + + //If it doesn't exist + if(curEntry == null){ + //Re-render out the sesh list + this.renderSeshList(); + } + } + } + + /** + * sends private message from sesh prompt to server + * @param {Event} event - Event passed down from Event Handler + */ + send(event){ + if((!event || !event.key || event.key == "Enter") && this.seshPrompt.value && this.activeSesh != ''){ + //Pull current sesh from sesh list + const sesh = this.client.pmHandler.seshList.get(this.activeSesh); + + //Send message out to server + this.client.pmSocket.emit("pm", { + recipients: sesh.recipients, + msg: this.seshPrompt.value + }); + + //Clear our prompt + this.seshPrompt.value = ""; + } } /** * Render out current sesh array to sesh list UI */ renderSeshList(){ + //Clear out the sesh list + this.seshList.innerHTML = ""; + + //Assemble temporary array from client PM Handler sesh list + const seshList = Array.from(this.client.pmHandler.seshList); + + //If we have existing sessions and no active sesh + if(this.activeSesh == "" && seshList[0] != null){ + //Enable UI elements + this.seshPrompt.disabled = false; + this.seshSendButton.disabled = false; + + //Render out messages + this.renderMessages(); + + //Set the first one as active + this.activeSesh = seshList[0][1].id; + } + //For each session tracked by the pmHandler - for(const sesh of this.client.pmHandler.seshList){ - this.renderSeshListEntry(sesh); + for(const seshEntry of seshList){ + this.renderSeshListEntry(seshEntry[1]); } } @@ -72,17 +156,171 @@ class pmPanel extends panelObj{ const entryDiv = document.createElement('div'); //Set conatiner div classes entryDiv.classList.add('pm-panel-sesh-list-entry','interactive'); + //Set dataset sesh name + entryDiv.dataset.id = sesh.id; + //If the current entry is the active sesh + if(sesh.id == this.activeSesh){ + //mark it as such + entryDiv.classList.add('positive'); + } //Create sesh label const seshLabel = document.createElement('p'); //Create human-readable label out of members array - seshLabel.textContent = utils.unescapeEntities(sesh.recipients.sort().join(', ')); + seshLabel.textContent = utils.unescapeEntities(sesh.id); //append sesh label to entry div entryDiv.appendChild(seshLabel); //Append entry div to sesh list this.seshList.appendChild(entryDiv); + + //Add input-related event listener for entry div + entryDiv.addEventListener('click', this.selectSesh.bind(this)); + } + + selectSesh(event){ + //Attempt to pull previously active sesh item from list + const wasActive = this.panelDocument.querySelector('.positive'); + + //If there was an active sesh + if(wasActive != null){ + //Remove active sesh class from old item + wasActive.classList.remove('positive'); + } + + //Pull new active sesh name from target dataset + this.activeSesh = event.target.dataset.id; + + //Set new sesh as active sesh + event.target.classList.add('positive'); + + //Re-render message buffer + this.renderMessages(); + } + + renderMessages(){ + //Empty out the sesh buffer + this.seshBuffer.innerHTML = ""; + + //Pull sesh from pmHandler + const sesh = this.client.pmHandler.seshList.get(this.activeSesh); + + //If the sesh is real + if(sesh != null){ + //for each message in the sesh + for(const message of sesh.messages){ + //Render out messages to the buffer + this.renderMessage(message); + } + } + } + + renderMessage(message){ + const msgSpan = document.createElement('span'); + + const msgSender = document.createElement('p'); + msgSender.innerText = utils.unescapeEntities(`${message.sender}:`); + msgSender.classList.add('pm-panel-sesh-message-sender'); + + const msgContent = document.createElement('p'); + msgContent.innerText = utils.unescapeEntities(message.msg); + msgContent.classList.add('pm-panel-sesh-message-content'); + + msgSpan.appendChild(msgSender); + msgSpan.appendChild(msgContent); + + this.seshBuffer.appendChild(msgSpan); + } +} + +/** + * Class representing pop-up dialogue to start a private messaging sesh + */ +class startSeshPopup{ + /** + * Instantiates a new schedule media Pop-up + * @param {Event} event - Event passed down from Event Listener + * @param {channel} client - Parent Client Management Object + * @param {String} url - URL/link to media to queue + * @param {String} title - Title of media to queue + * @param {Function} cb - Callback function, passed upon pop-up creation + * @param {Document} doc - Current owner documnet of the panel, so we know where to drop our pop-up + */ + constructor(event, client, cb, doc){ + /** + * Parent Client Management Object + */ + this.client = client; + + /** + * Callback function, passed upon pop-up creation + */ + this.cb = cb; + + //Create media popup and call async constructor when done + //unfortunately we cant call constructors asyncronously, and we cant call back to this from super, so we can't extend this as it stands :( + /** + * canopyUXUtils.popup() object + */ + this.popup = new canopyUXUtils.popup('/startChatSesh', true, this.asyncConstructor.bind(this), doc); + } + + /** + * Continuation of object construction, called after child popup object construction + */ + asyncConstructor(){ + //Grab required UI elements + this.startSeshButton = this.popup.contentDiv.querySelector('#pm-sesh-popup-button'); + this.usernamePrompt = this.popup.contentDiv.querySelector('#pm-sesh-popup-prompt'); + + //Setup input + this.setupInput(); + } + + /** + * Defines input-related Event Handlers + */ + setupInput(){ + //Setup input + this.startSeshButton.addEventListener('click', this.startSesh.bind(this)); + this.popup.popupDiv.addEventListener('keydown', this.startSesh.bind(this)); + } + + /** + * Handles sending request to schedule item to the queue + * @param {Event} event - Event passed down from Event Listener + */ + startSesh(event){ + //If we clicked or hit enter + if(event.key == null || event.key == "Enter"){ + /* + //Cook a new sesh from + const newSesh = new pmSesh({ + //Split usernames by space + sender: this.client.user.user, + recipients: this.usernamePrompt.value.split(" ") + }); + + //Pop new sesh into pmHandler + this.client.pmHandler.seshList.set(newSesh.id, newSesh); + */ + + //Send message out to server + this.client.pmSocket.emit("pm", { + recipients: this.usernamePrompt.value.split(" "), + msg: "" + }); + + //If we have a function + if(typeof this.cb == "function"){ + //Call any callbacks we where given + this.cb(); + } + + //Close the popup + this.popup.closePopup(); + } } } \ No newline at end of file diff --git a/www/js/channel/panels/queuePanel/queuePanel.js b/www/js/channel/panels/queuePanel/queuePanel.js index 5fb159e..2b05766 100644 --- a/www/js/channel/panels/queuePanel/queuePanel.js +++ b/www/js/channel/panels/queuePanel/queuePanel.js @@ -1542,7 +1542,7 @@ class reschedulePopup extends schedulePopup{ this.media = media; } - schedule(event){ + startSesh(event){ //If we clicked or hit enter if(event.key == null || event.key == "Enter"){ //Get localized input date diff --git a/www/js/channel/panels/settingsPanel.js b/www/js/channel/panels/settingsPanel.js index 7f6b707..976afee 100644 --- a/www/js/channel/panels/settingsPanel.js +++ b/www/js/channel/panels/settingsPanel.js @@ -64,6 +64,9 @@ class settingsPanel extends panelObj{ this.renderSettings(); this.setupInput(); + + //Call derived method + super.docSwitch(); } /** diff --git a/www/js/channel/pmHandler.js b/www/js/channel/pmHandler.js index 09e1a3a..c3562f5 100644 --- a/www/js/channel/pmHandler.js +++ b/www/js/channel/pmHandler.js @@ -36,7 +36,7 @@ class pmHandler{ /** * List of PM Sessions */ - this.seshList = []; + this.seshList = new Map(); this.defineListeners(); this.setupInput(); @@ -66,50 +66,63 @@ class pmHandler{ //Store whether or not current message has been consumed by an existing sesh let consumed = false; + const nameObj = pmHandler.genSeshName(data); + //Create members array from scratch to avoid changing the input data for further processing - const members = []; - - //Manually iterate through recipients - for(const member of data.recipients){ - //check to make sure we're not adding ourselves - if(member != this.client.user.user){ - //Copy relevant array members by value instead of reference - members.push(member); - } - } - - //If this wasn't our message - if(data.sender != this.client.user.user){ - //Push sender onto members list - members.push(data.sender); - } + const members = nameObj.recipients; //For each existing sesh - for(let seshIndex in this.seshList){ - //Get current sesh - const sesh = this.seshList[seshIndex]; + for(const seshEntry of this.seshList){ + //Pull sesh object from map entry + const sesh = seshEntry[1]; - //Check to see if the length of sesh recipients equals current length (only check on arrays that actually make sense to save time) - if(sesh.recipients.length == members.length){ - /*Feels like cheating to have the JS engine to the hard bits by just telling it to sort them. - That being said, since the function is implemented into the JS Engine itself - It will be quicker than any custom comparison code we can write*/ + //If currently checked sesh ID matches calculated message sesh id + if(sesh.id == nameObj.name){ + //Dump collected message into the matching session + sesh.messages.push(data); - //Sort recipient lists so lists with the same user will be equal when joined together in a string and compare, if they're the same... - if(sesh.recipients.sort().join() == members.sort().join()){ - //Dump collected message into the matching session - this.seshList[seshIndex].messages.push(data); + //Add sesh to sesh map + this.seshList.set(sesh.id, sesh); - //Let the rest of the method know that we've consumed this message - consumed = true; - } + //Let the rest of the method know that we've consumed this message + consumed = true; } } //If we made it through the loop without consuming the message if(!consumed){ - //Add it to it's own fresh new sesh - this.seshList.push(new pmSesh(data, client)); + //Generate a new sesh + const sesh = new pmSesh(data, client); + + //Add it to the sesh list + this.seshList.set(sesh.id, sesh); + } + } + + static genSeshName(message){ + const recipients = []; + + //Manually iterate through recipients + for(const member of message.recipients){ + //check to make sure we're not adding ourselves + if(member != client.user.user){ + //Copy relevant array members by value instead of reference + recipients.push(member); + } + } + + //If this wasn't our message + if(message.sender != client.user.user){ + //Push sender onto members list + recipients.push(message.sender); + } + + //Sort recipients + recipients.sort(); + + return { + name: recipients.join(', '), + recipients } } } @@ -128,29 +141,21 @@ class pmSesh{ */ this.client = client; + const nameObj = pmHandler.genSeshName(message); + /** * Members of session excluding the currently logged in user */ - this.recipients = []; + this.recipients = nameObj.recipients - //Manually iterate through recipients - for(const member of message.recipients){ - //check to make sure we're not adding ourselves - if(member != this.client.user.user){ - //Copy relevant array members by value instead of reference - this.recipients.push(member); - } - } - - //If this wasn't our message - if(message.sender != this.client.user.user){ - //Push sender onto members list - this.recipients.push(message.sender); - } + /** + * Name of the chat sesh, named after out-going recipients + */ + this.id = nameObj.name; /** * Array containing all session messages */ - this.messages = [message]; + this.messages = (message.msg == "" || message.msg == null) ? [] : [message]; } } \ No newline at end of file diff --git a/www/popup/startChatSesh.html b/www/popup/startChatSesh.html new file mode 100644 index 0000000..b8bb3cc --- /dev/null +++ b/www/popup/startChatSesh.html @@ -0,0 +1,23 @@ + + + +
+

Enter user(s) to chat with:

+ + +
+Users must be online and connected to a channel (it doesn't have to be the same one.) \ No newline at end of file From d465863ee6512ee90136f5860d010d3b99c0aa97 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Wed, 1 Oct 2025 19:43:46 -0400 Subject: [PATCH 13/92] Split commandPreprocessor in preperation for formatted private messaging. --- src/app/channel/commandPreprocessor.js | 145 ++------------------- src/app/chatPreprocessor.js | 171 +++++++++++++++++++++++++ src/schemas/channel/chatSchema.js | 3 +- 3 files changed, 182 insertions(+), 137 deletions(-) create mode 100644 src/app/chatPreprocessor.js diff --git a/src/app/channel/commandPreprocessor.js b/src/app/channel/commandPreprocessor.js index 8b75261..c08c653 100644 --- a/src/app/channel/commandPreprocessor.js +++ b/src/app/channel/commandPreprocessor.js @@ -18,6 +18,7 @@ along with this program. If not, see .*/ const validator = require('validator');//No express here, so regular validator it is! //Local Imports +const chatPreprocessor = require('../chatPreprocessor'); const tokebot = require('./tokebot'); const linkUtils = require('../../utils/linkUtils'); const permissionModel = require('../../schemas/permissionSchema'); @@ -26,148 +27,20 @@ const channelModel = require('../../schemas/channel/channelSchema'); /** * Class containing global server-side chat/command pre-processing logic */ -class commandPreprocessor{ +class commandPreprocessor extends chatPreprocessor{ /** * Instantiates a commandPreprocessor object * @param {channelManager} server - Parent Server Object * @param {chatHandler} chatHandler - Parent Chat Handler Object */ constructor(server, chatHandler){ - /** - * Parent Server Object - */ - this.server = server; - - /** - * Parent Chat Handler Object - */ - this.chatHandler = chatHandler; - - /** - * Child Command Processor Object - */ - this.commandProcessor = new commandProcessor(server, chatHandler); - - /** - * Child Tokebot Object - */ - this.tokebot = new tokebot(server, chatHandler); - } - - /** - * Ingests a command/chat request from Chat Handler and pre-processes and processes it accordingly - * @param {Socket} socket - Socket we're receiving the request from - * @param {Object} data - Event payload - */ - async preprocess(socket, data){ - //Set command object - const commandObj = { - socket, - sendFlag: true, - rawData: data, - chatType: 'chat' - } - - //If we don't pass sanatization/validation turn this car around - if(!this.sanatizeCommand(commandObj)){ - return; - } - - //split the command - this.splitCommand(commandObj); - - //Process the command - await this.processServerCommand(commandObj); - - //If we're going to relay this command as a message, continue on to chat processing - if(commandObj.sendFlag){ - //Prep the message - await this.prepMessage(commandObj); - - //Send the chat - this.sendChat(commandObj); - } - } - - /** - * Sanatizes and Validates a single user chat message/command - * @param {Object} commandObj - Object representing a single given command/chat request - * @returns {Boolean} false if Command/Message is too long to send - */ - sanatizeCommand(commandObj){ - //Trim and Sanatize for XSS - commandObj.command = validator.trim(validator.escape(commandObj.rawData.msg)); - - //Return whether or not the shit was long enough - return (validator.isLength(commandObj.rawData.msg, {min: 1, max: 255})); - } - - /** - * Splits raw chat/command data into seperate arrays, one by word-borders and words surrounded by word-borders - * These arrays are used to handle further command/chat processing - * @param {Object} commandObj - Object representing a single given command/chat request - */ - splitCommand(commandObj){ - //Split string by words - commandObj.commandArray = commandObj.command.split(/\b/g);//Split by word-borders - commandObj.argumentArray = commandObj.command.match(/\b\w+\b/g);//Match by words surrounded by borders - } - - /** - * Uses the server's Command Processor object to process the chat/command request. - * @param {Object} commandObj - Object representing a single given command/chat request - */ - async processServerCommand(commandObj){ - //If the raw message starts with '!' (skip commands that start with whitespace so people can send example commands in chat) - if(commandObj.rawData.msg[0] == '!'){ - //if it isn't just an exclimation point, and we have a real command - if(commandObj.argumentArray != null){ - //If the command processor knows what to do with whatever the fuck the user sent us - if(this.commandProcessor[commandObj.argumentArray[0].toLowerCase()] != null){ - //Process the command and use the return value to set the sendflag (true if command valid) - commandObj.sendFlag = await this.commandProcessor[commandObj.argumentArray[0].toLowerCase()](commandObj, this); - }else{ - //Process as toke command if we didnt get a match from the standard server-side command processor - commandObj.sendFlag = await this.tokebot.tokeProcessor(commandObj); - } - } - } - } - - /** - * Iterates through links in message and marks them by link type for later use by client-side post-processing - * @param {Object} commandObj - Object representing a single given command/chat request - */ - async markLinks(commandObj){ - //Setup the links array - commandObj.links = []; - - //For each link sent from the client - //this.rawData.links.forEach((link) => { - for (const link of commandObj.rawData.links){ - //Add a marked link object to our links array - commandObj.links.push(await linkUtils.markLink(link)); - } - } - - /** - * Re-creates message string from processed Command Array - * @param {Object} commandObj - Object representing a single given command/chat request - */ - async prepMessage(commandObj){ - //Create message from commandArray - commandObj.message = commandObj.commandArray.join('').trimStart(); - //Validate links and mark them by embed type - await this.markLinks(commandObj); - } - - /** - * Relays chat to channel via parent Chat Handler object - * @param {Object} commandObj - Object representing a single given command/chat request - */ - sendChat(commandObj){ - //FUCKIN' SEND IT! - this.chatHandler.relayUserChat(commandObj.socket, commandObj.message, commandObj.chatType, commandObj.links); + //Call derived constructor + super( + server, + chatHandler, + new commandProcessor(server, chatHandler), + new tokebot(server, chatHandler) + ); } } diff --git a/src/app/chatPreprocessor.js b/src/app/chatPreprocessor.js new file mode 100644 index 0000000..43e77fc --- /dev/null +++ b/src/app/chatPreprocessor.js @@ -0,0 +1,171 @@ +/*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 validator = require('validator');//No express here, so regular validator it is! + +//Local Imports +const linkUtils = require('../utils/linkUtils'); + +/** + * Class containing global server-side chat/command pre-processing logic + */ +class chatPreprocessor{ + /** + * Instantiates a commandPreprocessor object + * @param {channelManager} server - Parent Server Object + * @param {chatHandler} chatHandler - Parent Chat Handler Object + */ + constructor(server, chatHandler, commandProcessor, tokebot){ + /** + * Parent Server Object + */ + this.server = server; + + /** + * Parent Chat Handler Object + */ + this.chatHandler = chatHandler; + + /** + * Child Command Processor Object. Contains functions named after commands. + */ + this.commandProcessor = commandProcessor; + + /** + * Child Tokebot Object + */ + this.tokebot = tokebot; + } + + /** + * Ingests a command/chat request from Chat Handler and pre-processes and processes it accordingly + * @param {Socket} socket - Socket we're receiving the request from + * @param {Object} data - Event payload + */ + async preprocess(socket, data){ + //Set command object + const commandObj = { + socket, + sendFlag: true, + rawData: data, + chatType: 'chat' + } + + //If we don't pass sanatization/validation turn this car around + if(!this.sanatizeCommand(commandObj)){ + return; + } + + //split the command + this.splitCommand(commandObj); + + //Process the command + await this.processServerCommand(commandObj); + + //If we're going to relay this command as a message, continue on to chat processing + if(commandObj.sendFlag){ + //Prep the message + await this.prepMessage(commandObj); + + //Send the chat + this.sendChat(commandObj); + } + } + + /** + * Sanatizes and Validates a single user chat message/command + * @param {Object} commandObj - Object representing a single given command/chat request + * @returns {Boolean} false if Command/Message is too long to send + */ + sanatizeCommand(commandObj){ + //Trim and Sanatize for XSS + commandObj.command = validator.trim(validator.escape(commandObj.rawData.msg)); + + //Return whether or not the shit was long enough + return (validator.isLength(commandObj.rawData.msg, {min: 1, max: 255})); + } + + /** + * Splits raw chat/command data into seperate arrays, one by word-borders and words surrounded by word-borders + * These arrays are used to handle further command/chat processing + * @param {Object} commandObj - Object representing a single given command/chat request + */ + splitCommand(commandObj){ + //Split string by words + commandObj.commandArray = commandObj.command.split(/\b/g);//Split by word-borders + commandObj.argumentArray = commandObj.command.match(/\b\w+\b/g);//Match by words surrounded by borders + } + + /** + * Uses the server's Command Processor object to process the chat/command request. + * @param {Object} commandObj - Object representing a single given command/chat request + */ + async processServerCommand(commandObj){ + //If the raw message starts with '!' (skip commands that start with whitespace so people can send example commands in chat) + if(commandObj.rawData.msg[0] == '!'){ + //if it isn't just an exclimation point, and we have a real command + if(commandObj.argumentArray != null){ + //If the command processor knows what to do with whatever the fuck the user sent us + if(this.commandProcessor[commandObj.argumentArray[0].toLowerCase()] != null){ + //Process the command and use the return value to set the sendflag (true if command valid) + commandObj.sendFlag = await this.commandProcessor[commandObj.argumentArray[0].toLowerCase()](commandObj, this); + }else{ + //Process as toke command if we didnt get a match from the standard server-side command processor + commandObj.sendFlag = await this.tokebot.tokeProcessor(commandObj); + } + } + } + } + + /** + * Iterates through links in message and marks them by link type for later use by client-side post-processing + * @param {Object} commandObj - Object representing a single given command/chat request + */ + async markLinks(commandObj){ + //Setup the links array + commandObj.links = []; + + //For each link sent from the client + //this.rawData.links.forEach((link) => { + for (const link of commandObj.rawData.links){ + //Add a marked link object to our links array + commandObj.links.push(await linkUtils.markLink(link)); + } + } + + /** + * Re-creates message string from processed Command Array + * @param {Object} commandObj - Object representing a single given command/chat request + */ + async prepMessage(commandObj){ + //Create message from commandArray + commandObj.message = commandObj.commandArray.join('').trimStart(); + //Validate links and mark them by embed type + await this.markLinks(commandObj); + } + + /** + * Relays chat to channel via parent Chat Handler object + * @param {Object} commandObj - Object representing a single given command/chat request + */ + sendChat(commandObj){ + //FUCKIN' SEND IT! + this.chatHandler.relayUserChat(commandObj.socket, commandObj.message, commandObj.chatType, commandObj.links); + } +} + +module.exports = chatPreprocessor; \ No newline at end of file diff --git a/src/schemas/channel/chatSchema.js b/src/schemas/channel/chatSchema.js index 537afd0..c0d81b0 100644 --- a/src/schemas/channel/chatSchema.js +++ b/src/schemas/channel/chatSchema.js @@ -32,10 +32,11 @@ const chatSchema = new mongoose.Schema({ }, flair: { type: mongoose.SchemaTypes.String, - required: true, + //Leave this as unreq'd for internal type chats that have no flair }, highLevel: { type: mongoose.SchemaTypes.Number, + default: 0, required: true, }, msg: { From b26dd1094ce342ff3ccf88eec507ac04d667e0a0 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Wed, 1 Oct 2025 20:38:16 -0400 Subject: [PATCH 14/92] Modified commandPreprocessor to be easily shared between chat.js and pmHandler.js --- www/js/channel/chat.js | 17 +++++++++++++++-- www/js/channel/commandPreprocessor.js | 17 ++++++++--------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/www/js/channel/chat.js b/www/js/channel/chat.js index a8eb996..0402a4b 100644 --- a/www/js/channel/chat.js +++ b/www/js/channel/chat.js @@ -274,7 +274,7 @@ class chatBox{ * @param {String} user - User to toke with */ tokeWith(user){ - this.commandPreprocessor.preprocess(user == this.client.user.user ? "!toke up fuckers" : `!toke up ${user}`); + this.transmit(user == this.client.user.user ? "!toke up fuckers" : `!toke up ${user}`); } /** @@ -283,7 +283,9 @@ class chatBox{ */ send(event){ if((!event || !event.key || event.key == "Enter") && this.chatPrompt.value){ - this.commandPreprocessor.preprocess(this.chatPrompt.value); + //Transmit the chat + this.transmit(this.chatPrompt.value); + //Clear our prompt and autocomplete nodes this.chatPrompt.value = ""; this.autocompletePlaceholder.innerHTML = ''; @@ -291,6 +293,17 @@ class chatBox{ } } + transmit(msg){ + //Pre-process chat string + const preprocessedChat = this.commandPreprocessor.preprocess(msg); + + //If we passed through pre-processing + if(preprocessedChat != false){ + //Send pre-processed chat data off to server + this.client.socket.emit("chatMessage", preprocessedChat); + } + } + /** * Displays auto-complete text against current prompt input * @param {Event} event - Event passed down from Event Handler diff --git a/www/js/channel/commandPreprocessor.js b/www/js/channel/commandPreprocessor.js index ddfb9f6..af05cfe 100644 --- a/www/js/channel/commandPreprocessor.js +++ b/www/js/channel/commandPreprocessor.js @@ -73,13 +73,19 @@ class commandPreprocessor{ if(this.sendFlag){ //Set the message to the command this.message = command; + //Process message emotes into links this.processEmotes(); + //Process unmarked links into marked links this.processLinks(); - //Send command off to server - this.sendRemoteCommand(); + + //Return pre-processed message data + return {msg: this.message, links: this.links}; } + + //Return false for bad message/command on fall-through + return false; } /** @@ -150,13 +156,6 @@ class commandPreprocessor{ this.message = splitMessage.join(''); } - /** - * Transmits message/command off to server - */ - sendRemoteCommand(){ - this.client.socket.emit("chatMessage",{msg: this.message, links: this.links}); - } - /** * Sets site emotes * @param {Object} data - Emote data from server From 57db26a827f124fae4d15ef4d7796b9c04c757c3 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Wed, 1 Oct 2025 21:41:49 -0400 Subject: [PATCH 15/92] Quick Cleanup --- src/app/channel/channelManager.js | 3 --- www/css/theme/movie-night.css | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/app/channel/channelManager.js b/src/app/channel/channelManager.js index b36d7b5..289c227 100644 --- a/src/app/channel/channelManager.js +++ b/src/app/channel/channelManager.js @@ -20,11 +20,8 @@ const config = require('../../../config.json'); //Local Imports const channelModel = require('../../schemas/channel/channelSchema'); const emoteModel = require('../../schemas/emoteSchema'); -const {userModel} = require('../../schemas/user/userSchema'); -const userBanModel = require('../../schemas/user/userBanSchema'); const socketUtils = require('../../utils/socketUtils'); const loggerUtils = require('../../utils/loggerUtils'); -const csrfUtils = require('../../utils/csrfUtils'); const presenceUtils = require('../../utils/presenceUtils'); const activeChannel = require('./activeChannel'); const chatHandler = require('./chatHandler'); diff --git a/www/css/theme/movie-night.css b/www/css/theme/movie-night.css index 00e2f24..fa3d836 100644 --- a/www/css/theme/movie-night.css +++ b/www/css/theme/movie-night.css @@ -214,7 +214,7 @@ textarea{ text-shadow: var(--danger-glow0-alt1); } -.positive-button{ +.positive-button:not([disabled]){ background-color: var(--focus0); color: white; } From 23ad6794738c928ebcec5cf37e1e23173ee46b92 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Thu, 2 Oct 2025 02:29:29 -0400 Subject: [PATCH 16/92] Client-side PM Pre-processing complete. --- www/js/channel/panels/pmPanel.js | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/www/js/channel/panels/pmPanel.js b/www/js/channel/panels/pmPanel.js index 85e191e..261c694 100644 --- a/www/js/channel/panels/pmPanel.js +++ b/www/js/channel/panels/pmPanel.js @@ -108,14 +108,20 @@ class pmPanel extends panelObj{ //Pull current sesh from sesh list const sesh = this.client.pmHandler.seshList.get(this.activeSesh); - //Send message out to server - this.client.pmSocket.emit("pm", { - recipients: sesh.recipients, - msg: this.seshPrompt.value - }); + //Preprocess message from prompt + const preprocessedMessage = this.client.chatBox.commandPreprocessor.preprocess(this.seshPrompt.value); - //Clear our prompt - this.seshPrompt.value = ""; + //If preprocessedMessage had it's send flag thrown as false + if(preprocessedMessage != false){ + //Stick recipients into the pre-processed message + preprocessedMessage.recipients = sesh.recipients; + + //Send message out to server + this.client.pmSocket.emit("pm", preprocessedMessage); + } + + //Clear our prompt + this.seshPrompt.value = ""; } } From 0ed1c0dd8980d4aeda17c80e2e68fece8063449c Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Thu, 2 Oct 2025 03:38:40 -0400 Subject: [PATCH 17/92] More work decoupling chatPreprocessor from src/app/channel. --- src/app/channel/chatHandler.js | 21 +++++++++++--- ...andPreprocessor.js => commandProcessor.js} | 28 +------------------ src/app/chatPreprocessor.js | 21 +++----------- src/schemas/tokebot/tokeCommandSchema.js | 4 +-- 4 files changed, 24 insertions(+), 50 deletions(-) rename src/app/channel/{commandPreprocessor.js => commandProcessor.js} (92%) diff --git a/src/app/channel/chatHandler.js b/src/app/channel/chatHandler.js index 4e4c14f..df21492 100644 --- a/src/app/channel/chatHandler.js +++ b/src/app/channel/chatHandler.js @@ -18,7 +18,9 @@ along with this program. If not, see .*/ const validator = require('validator') //local imports -const commandPreprocessor = require('./commandPreprocessor'); +const chatPreprocessor = require('../chatPreprocessor'); +const commandProcessor = require('./commandProcessor'); +const tokebot = require('./tokebot'); const loggerUtils = require('../../utils/loggerUtils'); const linkUtils = require('../../utils/linkUtils'); const emoteValidator = require('../../validators/emoteValidator'); @@ -42,7 +44,11 @@ class chatHandler{ /** * Child Command Pre-Processor Object */ - this.commandPreprocessor = new commandPreprocessor(server, this) + this.chatPreprocessor = new chatPreprocessor( + server, + new commandProcessor(server, this), + new tokebot(server, this) + ); /** * Max chat buffer message count @@ -67,8 +73,15 @@ class chatHandler{ * @param {Socket} socket - Socket we're receiving the request from * @param {Object} data - Event payload */ - handleChat(socket, data){ - this.commandPreprocessor.preprocess(socket, data); + async handleChat(socket, data){ + //Preprocess chat data + const preprocessedChat = await this.chatPreprocessor.preprocess(socket, data); + + //If send flag wasn't set to false + if(preprocessedChat != false){ + //Send that shit! + this.relayUserChat(socket, preprocessedChat.message, preprocessedChat.chatType, preprocessedChat.links); + } } /** diff --git a/src/app/channel/commandPreprocessor.js b/src/app/channel/commandProcessor.js similarity index 92% rename from src/app/channel/commandPreprocessor.js rename to src/app/channel/commandProcessor.js index c08c653..213ec1c 100644 --- a/src/app/channel/commandPreprocessor.js +++ b/src/app/channel/commandProcessor.js @@ -14,36 +14,10 @@ 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 validator = require('validator');//No express here, so regular validator it is! - //Local Imports -const chatPreprocessor = require('../chatPreprocessor'); -const tokebot = require('./tokebot'); -const linkUtils = require('../../utils/linkUtils'); const permissionModel = require('../../schemas/permissionSchema'); const channelModel = require('../../schemas/channel/channelSchema'); -/** - * Class containing global server-side chat/command pre-processing logic - */ -class commandPreprocessor extends chatPreprocessor{ - /** - * Instantiates a commandPreprocessor object - * @param {channelManager} server - Parent Server Object - * @param {chatHandler} chatHandler - Parent Chat Handler Object - */ - constructor(server, chatHandler){ - //Call derived constructor - super( - server, - chatHandler, - new commandProcessor(server, chatHandler), - new tokebot(server, chatHandler) - ); - } -} - /** * Class representing global server-side chat/command processing logic */ @@ -317,4 +291,4 @@ class commandProcessor{ } } -module.exports = commandPreprocessor; \ No newline at end of file +module.exports = commandProcessor; \ No newline at end of file diff --git a/src/app/chatPreprocessor.js b/src/app/chatPreprocessor.js index 43e77fc..cbd1cea 100644 --- a/src/app/chatPreprocessor.js +++ b/src/app/chatPreprocessor.js @@ -27,19 +27,13 @@ class chatPreprocessor{ /** * Instantiates a commandPreprocessor object * @param {channelManager} server - Parent Server Object - * @param {chatHandler} chatHandler - Parent Chat Handler Object */ - constructor(server, chatHandler, commandProcessor, tokebot){ + constructor(server, commandProcessor, tokebot){ /** * Parent Server Object */ this.server = server; - /** - * Parent Chat Handler Object - */ - this.chatHandler = chatHandler; - /** * Child Command Processor Object. Contains functions named after commands. */ @@ -82,8 +76,10 @@ class chatPreprocessor{ await this.prepMessage(commandObj); //Send the chat - this.sendChat(commandObj); + return commandObj; } + + return false; } /** @@ -157,15 +153,6 @@ class chatPreprocessor{ //Validate links and mark them by embed type await this.markLinks(commandObj); } - - /** - * Relays chat to channel via parent Chat Handler object - * @param {Object} commandObj - Object representing a single given command/chat request - */ - sendChat(commandObj){ - //FUCKIN' SEND IT! - this.chatHandler.relayUserChat(commandObj.socket, commandObj.message, commandObj.chatType, commandObj.links); - } } module.exports = chatPreprocessor; \ No newline at end of file diff --git a/src/schemas/tokebot/tokeCommandSchema.js b/src/schemas/tokebot/tokeCommandSchema.js index a6e9715..892b7d0 100644 --- a/src/schemas/tokebot/tokeCommandSchema.js +++ b/src/schemas/tokebot/tokeCommandSchema.js @@ -39,7 +39,7 @@ tokeCommandSchema.pre('save', async function (next){ //if the command was changed if(this.isModified("command")){ //Get server tokebot object - const tokebot = server.channelManager.chatHandler.commandPreprocessor.tokebot; + const tokebot = server.channelManager.chatHandler.chatPreprocessor.tokebot; //If tokebot is up and running if(tokebot != null && tokebot.tokeCommands != null){ @@ -58,7 +58,7 @@ tokeCommandSchema.pre('save', async function (next){ */ tokeCommandSchema.pre('deleteOne', {document: true}, async function (next){ //Get server tokebot object (isn't this a fun dot crawler? Why hasn't anyone asked me to stop writing software yet?) - const tokebot = server.channelManager.chatHandler.commandPreprocessor.tokebot; + const tokebot = server.channelManager.chatHandler.chatPreprocessor.tokebot; //Get the index of the command within tokeCommand and splice it out tokebot.tokeCommands.splice(tokebot.tokeCommands.indexOf(this.command),1); From ad3cdd38a33a7c6f2225beadce02520980a91b82 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Thu, 2 Oct 2025 04:05:13 -0400 Subject: [PATCH 18/92] More work decoupling chatPreprocessor.js from chatHandler.js --- src/app/channel/chatHandler.js | 1 - src/app/chatPreprocessor.js | 10 +++------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/app/channel/chatHandler.js b/src/app/channel/chatHandler.js index df21492..c4d22c2 100644 --- a/src/app/channel/chatHandler.js +++ b/src/app/channel/chatHandler.js @@ -45,7 +45,6 @@ class chatHandler{ * Child Command Pre-Processor Object */ this.chatPreprocessor = new chatPreprocessor( - server, new commandProcessor(server, this), new tokebot(server, this) ); diff --git a/src/app/chatPreprocessor.js b/src/app/chatPreprocessor.js index cbd1cea..23bf559 100644 --- a/src/app/chatPreprocessor.js +++ b/src/app/chatPreprocessor.js @@ -19,6 +19,7 @@ const validator = require('validator');//No express here, so regular validator i //Local Imports const linkUtils = require('../utils/linkUtils'); +const commandProcessor = require('./channel/commandProcessor'); /** * Class containing global server-side chat/command pre-processing logic @@ -26,14 +27,9 @@ const linkUtils = require('../utils/linkUtils'); class chatPreprocessor{ /** * Instantiates a commandPreprocessor object - * @param {channelManager} server - Parent Server Object + * @param {commandProcessor} - Child Command Processor Object. Contains functions named after commands. */ - constructor(server, commandProcessor, tokebot){ - /** - * Parent Server Object - */ - this.server = server; - + constructor(commandProcessor, tokebot){ /** * Child Command Processor Object. Contains functions named after commands. */ From 6f89f36fb8fb113a804d8eb0b68a0ce14c648273 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Thu, 2 Oct 2025 04:56:47 -0400 Subject: [PATCH 19/92] Integrated server-side chatPreprocessor.js with pmHandler.js --- src/app/chatPreprocessor.js | 4 +-- src/app/pm/message.js | 8 ++++- src/app/pm/pmHandler.js | 62 ++++++++++++++----------------------- 3 files changed, 32 insertions(+), 42 deletions(-) diff --git a/src/app/chatPreprocessor.js b/src/app/chatPreprocessor.js index 23bf559..be7cf26 100644 --- a/src/app/chatPreprocessor.js +++ b/src/app/chatPreprocessor.js @@ -112,10 +112,10 @@ class chatPreprocessor{ //if it isn't just an exclimation point, and we have a real command if(commandObj.argumentArray != null){ //If the command processor knows what to do with whatever the fuck the user sent us - if(this.commandProcessor[commandObj.argumentArray[0].toLowerCase()] != null){ + if(this.commandProcessor != null && this.commandProcessor[commandObj.argumentArray[0].toLowerCase()] != null){ //Process the command and use the return value to set the sendflag (true if command valid) commandObj.sendFlag = await this.commandProcessor[commandObj.argumentArray[0].toLowerCase()](commandObj, this); - }else{ + }else if(this.tokebot != null){ //Process as toke command if we didnt get a match from the standard server-side command processor commandObj.sendFlag = await this.tokebot.tokeProcessor(commandObj); } diff --git a/src/app/pm/message.js b/src/app/pm/message.js index f2c216c..1ccb88d 100644 --- a/src/app/pm/message.js +++ b/src/app/pm/message.js @@ -23,9 +23,10 @@ class message{ * @param {String} sender - Name of user who sent the message * @param {Array} recipients - Array of usernames who are supposed to receive the message * @param {String} msg - Contents of the message, with links replaced with numbered file-seperator markers + * @param {String} type - Message Type Identifier, used for client-side processing. * @param {Array} links - Array of URLs/Links included in the message. */ - constructor(sender, recipients, msg, links){ + constructor(sender, recipients, msg, type, links){ /** * Name of user who sent the message @@ -42,6 +43,11 @@ class message{ */ this.msg = msg; + /** + * Message Type Identifier, used for client-side processing. + */ + this.type = type; + /** * Array of URLs/Links included in the message. */ diff --git a/src/app/pm/pmHandler.js b/src/app/pm/pmHandler.js index 0014ec2..0e80206 100644 --- a/src/app/pm/pmHandler.js +++ b/src/app/pm/pmHandler.js @@ -18,6 +18,7 @@ along with this program. If not, see .*/ const validator = require('validator');//No express here, so regular validator it is! //local includes +const chatPreprocessor = require('../chatPreprocessor'); const loggerUtils = require("../../utils/loggerUtils"); const socketUtils = require("../../utils/socketUtils"); const message = require("./message"); @@ -41,6 +42,8 @@ class pmHandler{ */ this.namespace = io.of('/pm'); + this.chatPreprocessor = new chatPreprocessor(null, null); + //Handle connections from private messaging namespace this.namespace.on("connection", this.handleConnection.bind(this) ); } @@ -80,39 +83,25 @@ class pmHandler{ async handlePM(data, socket){ try{ - //Create empty list of recipients - let recipients = []; - - //For each requested recipient - for(let user of data.recipients){ - //If the given user is online and didn't send the message - if(this.checkPresence(user) && user != socket.user.user){ - //Add the recipient to the list - recipients.push(user); - } - } + //Check recipients + const recipients = this.checkRecipients(data.recipients, socket); //If we don't have any valid recipients if(recipients.length <= 0){ //Drop that shit - return; + return false; } - //Sanatize Message - const msg = this.sanatizeMessage(data.msg); - - //If we have an invalid message - if(msg == null){ - //Drop that shit - return; - } + //preprocess message + const preprocessedMessage = await this.chatPreprocessor.preprocess(socket, data); //Create message object and relay it off to the recipients this.relayPMObj(new message( socket.user.user, recipients, - msg, - [] + preprocessedMessage.message, + preprocessedMessage.chatType, + preprocessedMessage.links )); //If something fucked up @@ -144,26 +133,21 @@ class pmHandler{ return this.namespace.adapter.rooms.get(user) != null; } - /** - * Sanatizes and Validates a single message, Temporary until we get commandPreprocessor split up. - * @param {String} msg - message to validate/sanatize - * @returns {String} sanatized/validates message, returns null on validation failure - */ - sanatizeMessage(msg){ - //Normally I'd kill empty messages here - //But instead we're allowing them for sesh startups + checkRecipients(input, socket){ + //Create empty recipients array + let recipients = []; - //Trim and Sanatize for XSS - msg = validator.trim(validator.escape(msg)); - - //Return whether or not the shit was too long - if(validator.isLength(msg, {min: 0, max: 255})){ - //If it's valid return the message - return msg; + //For each requested recipient + for(let user of input){ + //If the given user is online and didn't send the message + if(this.checkPresence(user) && user != socket.user.user){ + //Add the recipient to the list + recipients.push(user); + } } - //if not return nothing - return null; + //return recipients + return recipients; } } From e1cdca2b96ffc0526f1172b26a52b20e21877fc0 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Thu, 2 Oct 2025 05:09:45 -0400 Subject: [PATCH 20/92] Fixed bug with sesh starting --- src/app/channel/chatHandler.js | 18 ++++++++++++------ src/app/pm/pmHandler.js | 12 ++++++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/app/channel/chatHandler.js b/src/app/channel/chatHandler.js index c4d22c2..bc1e751 100644 --- a/src/app/channel/chatHandler.js +++ b/src/app/channel/chatHandler.js @@ -73,13 +73,19 @@ class chatHandler{ * @param {Object} data - Event payload */ async handleChat(socket, data){ - //Preprocess chat data - const preprocessedChat = await this.chatPreprocessor.preprocess(socket, data); + try{ + //Preprocess chat data + const preprocessedChat = await this.chatPreprocessor.preprocess(socket, data); - //If send flag wasn't set to false - if(preprocessedChat != false){ - //Send that shit! - this.relayUserChat(socket, preprocessedChat.message, preprocessedChat.chatType, preprocessedChat.links); + //If send flag wasn't set to false + if(preprocessedChat != false){ + //Send that shit! + this.relayUserChat(socket, preprocessedChat.message, preprocessedChat.chatType, preprocessedChat.links); + } + //If something fucked up + }catch(err){ + //Bitch and moan + return loggerUtils.socketExceptionHandler(socket, err); } } diff --git a/src/app/pm/pmHandler.js b/src/app/pm/pmHandler.js index 0e80206..1414914 100644 --- a/src/app/pm/pmHandler.js +++ b/src/app/pm/pmHandler.js @@ -92,6 +92,18 @@ class pmHandler{ return false; } + //If this is a sesh starter + if(data.msg == '' || data.msg == null){ + //Skip pre-processing and send a cooked message + return this.relayPMObj(new message( + socket.user.user, + recipients, + '', + 'chat', + [] + )); + } + //preprocess message const preprocessedMessage = await this.chatPreprocessor.preprocess(socket, data); From faf72fd7a5a60a0079fc90ca8b774733905bd61a Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Thu, 2 Oct 2025 08:56:54 -0400 Subject: [PATCH 21/92] Started work on client-side private message post-processing --- www/js/channel/chat.js | 42 +------------------------- www/js/channel/chatPostprocessor.js | 46 +++++++++++++++++++++++++++-- www/js/channel/panels/pmPanel.js | 21 +++++-------- 3 files changed, 53 insertions(+), 56 deletions(-) diff --git a/www/js/channel/chat.js b/www/js/channel/chat.js index 0402a4b..b9aa843 100644 --- a/www/js/channel/chat.js +++ b/www/js/channel/chat.js @@ -213,48 +213,8 @@ class chatBox{ * @param {Object} data De-hydrated chat object from server */ displayChat(data){ - //Create chat-entry span - var chatEntry = document.createElement('span'); - chatEntry.classList.add("chat-panel-buffer","chat-entry",`chat-entry-${data.user}`); - - //Create high-level label - var highLevel = document.createElement('p'); - highLevel.classList.add("chat-panel-buffer","chat-entry-high-level","high-level"); - highLevel.textContent = utils.unescapeEntities(`${data.highLevel}`); - chatEntry.appendChild(highLevel); - - //If we're not using classic flair - if(data.flair != "classic"){ - //Use flair - var flair = `flair-${data.flair}`; - //Otherwise - }else{ - //Pull user's assigned color from the color map - var flair = this.client.userList.colorMap.get(data.user); - } - - //Create username label - var userLabel = document.createElement('p'); - userLabel.classList.add("chat-panel-buffer", "chat-entry-username", ); - - //Create color span - var flairSpan = document.createElement('span'); - flairSpan.classList.add("chat-entry-flair-span", flair); - flairSpan.innerHTML = data.user; - - //Inject flair span into user label before the colon - userLabel.innerHTML = `${flairSpan.outerHTML}: `; - - //Append user label - chatEntry.appendChild(userLabel); - - //Create chat body - var chatBody = document.createElement('p'); - chatBody.classList.add("chat-panel-buffer","chat-entry-body"); - chatEntry.appendChild(chatBody); - //Append the post-processed chat-body to the chat buffer - this.chatBuffer.appendChild(this.chatPostprocessor.postprocess(chatEntry, data)); + this.chatBuffer.appendChild(this.chatPostprocessor.postprocess(data)); //Set size to aspect on launch this.resizeAspect(); diff --git a/www/js/channel/chatPostprocessor.js b/www/js/channel/chatPostprocessor.js index 6bc03c0..80d29a7 100644 --- a/www/js/channel/chatPostprocessor.js +++ b/www/js/channel/chatPostprocessor.js @@ -35,13 +35,13 @@ class chatPostprocessor{ * @param {Object} rawData - Raw data from server * @returns {Node} Post-Processed Chat Entry */ - postprocess(chatEntry, rawData){ + postprocess(rawData){ //Create empty array to hold filter spans this.filterSpans = []; //Set raw message data this.rawData = rawData; //Set current chat nodes - this.chatEntry = chatEntry; + this.buildEntry(); this.chatBody = this.chatEntry.querySelector(".chat-entry-body"); //Split the chat message into an array of objects representing each word/chunk @@ -87,6 +87,48 @@ class chatPostprocessor{ return this.chatEntry; } + buildEntry(){ + //Create chat-entry span + this.chatEntry = document.createElement('span'); + this.chatEntry.classList.add("chat-panel-buffer","chat-entry",`chat-entry-${this.rawData.user}`); + + //Create high-level label + var highLevel = document.createElement('p'); + highLevel.classList.add("chat-panel-buffer","chat-entry-high-level","high-level"); + highLevel.textContent = utils.unescapeEntities(`${this.rawData.highLevel}`); + this.chatEntry.appendChild(highLevel); + + //If we're not using classic flair + if(this.rawData.flair != "classic"){ + //Use flair + var flair = `flair-${this.rawData.flair}`; + //Otherwise + }else{ + //Pull user's assigned color from the color map + var flair = this.client.userList.colorMap.get(this.rawData.user); + } + + //Create username label + var userLabel = document.createElement('p'); + userLabel.classList.add("chat-panel-buffer", "chat-entry-username", ); + + //Create color span + var flairSpan = document.createElement('span'); + flairSpan.classList.add("chat-entry-flair-span", flair); + flairSpan.innerHTML = this.rawData.user; + + //Inject flair span into user label before the colon + userLabel.innerHTML = `${flairSpan.outerHTML}: `; + + //Append user label + this.chatEntry.appendChild(userLabel); + + //Create chat body + var chatBody = document.createElement('p'); + chatBody.classList.add("chat-panel-buffer","chat-entry-body"); + this.chatEntry.appendChild(chatBody); + } + /** * Splits message into an array of Word Objects for further processing */ diff --git a/www/js/channel/panels/pmPanel.js b/www/js/channel/panels/pmPanel.js index 261c694..13d5349 100644 --- a/www/js/channel/panels/pmPanel.js +++ b/www/js/channel/panels/pmPanel.js @@ -223,21 +223,16 @@ class pmPanel extends panelObj{ } } + /** + * Renders message out to PM Panel Message Buffer + * @param {Object} message - Message to render + */ renderMessage(message){ - const msgSpan = document.createElement('span'); + //Run postprocessing functions on chat message + const postprocessedMessage = client.chatBox.chatPostprocessor.postprocess(message); - const msgSender = document.createElement('p'); - msgSender.innerText = utils.unescapeEntities(`${message.sender}:`); - msgSender.classList.add('pm-panel-sesh-message-sender'); - - const msgContent = document.createElement('p'); - msgContent.innerText = utils.unescapeEntities(message.msg); - msgContent.classList.add('pm-panel-sesh-message-content'); - - msgSpan.appendChild(msgSender); - msgSpan.appendChild(msgContent); - - this.seshBuffer.appendChild(msgSpan); + //Append message to buffer + this.seshBuffer.appendChild(postprocessedMessage); } } From 2feea726944aa3b95438777283769b5430443a92 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Fri, 3 Oct 2025 08:58:43 -0400 Subject: [PATCH 22/92] Finished up work on advanced formatted private messaging. --- src/app/channel/chat.js | 38 ++++--------------- src/app/chatMetadata.js | 57 +++++++++++++++++++++++++++++ src/app/pm/message.js | 32 +++++----------- src/app/pm/pmHandler.js | 40 +++++++++++++++----- src/server.js | 2 +- www/css/channel.css | 8 ++-- www/css/panel/pm.css | 43 ++++++++++++++++++---- www/css/theme/movie-night.css | 7 ++-- www/js/channel/chatPostprocessor.js | 25 +++++++------ www/js/channel/panels/pmPanel.js | 2 +- www/js/channel/pmHandler.js | 4 +- 11 files changed, 164 insertions(+), 94 deletions(-) create mode 100644 src/app/chatMetadata.js diff --git a/src/app/channel/chat.js b/src/app/channel/chat.js index b26fc17..c9c5853 100644 --- a/src/app/channel/chat.js +++ b/src/app/channel/chat.js @@ -14,49 +14,25 @@ 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 .*/ +//local imports +const chatMetadata = require("../chatMetadata"); + /** * Class representing a single chat message */ -class chat{ +class chat extends chatMetadata{ /** * Instantiates a chat message object * @param {connectedUser} user - User who sent the message - * @param {String} flair - Flair ID String for the flair used to send the message - * @param {Number} highLevel - Number representing current high level - * @param {String} msg - Contents of the message, with links replaced with numbered file-seperator markers - * @param {String} type - Message Type Identifier, used for client-side processing. - * @param {Array} links - Array of URLs/Links included in the message. */ constructor(user, flair, highLevel, msg, type, links){ + //Call derived constructor + super(flair, highLevel, msg, type, links); + /** * User who sent the message */ this.user = user; - - /** - * Flair ID String for the flair used to send the message - */ - this.flair = flair; - - /** - * Number representing current high level - */ - this.highLevel = highLevel; - - /** - * COntents of the message, with links replaced with numbered file-seperator marks - */ - this.msg = msg; - - /** - * Message Type Identifier, used for client-side processing. - */ - this.type = type; - - /** - * Array of URLs/Links included in the message. - */ - this.links = links; } } diff --git a/src/app/chatMetadata.js b/src/app/chatMetadata.js new file mode 100644 index 0000000..df5fbd6 --- /dev/null +++ b/src/app/chatMetadata.js @@ -0,0 +1,57 @@ +/*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 .*/ + +/** + * Class representing a the metadata of a single message + */ +class chatMetadata{ + /** + * Instantiates a chat metadata object + * @param {String} flair - Flair ID String for the flair used to send the message + * @param {Number} highLevel - Number representing current high level + * @param {String} msg - Contents of the message, with links replaced with numbered file-seperator markers + * @param {String} type - Message Type Identifier, used for client-side processing. + * @param {Array} links - Array of URLs/Links included in the message. + */ + constructor(flair, highLevel, msg, type, links){ + /** + * Flair ID String for the flair used to send the message + */ + this.flair = flair; + + /** + * Number representing current high level + */ + this.highLevel = highLevel; + + /** + * COntents of the message, with links replaced with numbered file-seperator marks + */ + this.msg = msg; + + /** + * Message Type Identifier, used for client-side processing. + */ + this.type = type; + + /** + * Array of URLs/Links included in the message. + */ + this.links = links; + } +} + +module.exports = chatMetadata; \ No newline at end of file diff --git a/src/app/pm/message.js b/src/app/pm/message.js index 1ccb88d..28dc46f 100644 --- a/src/app/pm/message.js +++ b/src/app/pm/message.js @@ -14,44 +14,30 @@ 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 .*/ +//localImports +const chatMetadata = require("../chatMetadata"); + /** * Class representing a single chat message */ -class message{ +class message extends chatMetadata{ /** - * Instantiates a chat message object - * @param {String} sender - Name of user who sent the message + * @param {String} user - Name of user who sent the message * @param {Array} recipients - Array of usernames who are supposed to receive the message - * @param {String} msg - Contents of the message, with links replaced with numbered file-seperator markers - * @param {String} type - Message Type Identifier, used for client-side processing. - * @param {Array} links - Array of URLs/Links included in the message. */ - constructor(sender, recipients, msg, type, links){ + constructor(user, recipients, flair, highLevel, msg, type, links){ + //Call derived constructor + super(flair, highLevel, msg, type, links); /** * Name of user who sent the message */ - this.sender = sender; + this.user = user; /** * Array of usernames who are supposed to receive the message */ this.recipients = recipients; - - /** - * Contenst of the messages, with links replaced with numbered file-seperator markers - */ - this.msg = msg; - - /** - * Message Type Identifier, used for client-side processing. - */ - this.type = type; - - /** - * Array of URLs/Links included in the message. - */ - this.links = links; } } diff --git a/src/app/pm/pmHandler.js b/src/app/pm/pmHandler.js index 1414914..b783be8 100644 --- a/src/app/pm/pmHandler.js +++ b/src/app/pm/pmHandler.js @@ -30,13 +30,19 @@ class pmHandler{ /** * Instantiates object containing global server-side private message relay logic * @param {Socket.io} io - Socket.io server instanced passed down from server.js + * @param {channelManager} chanServer - Sister channel management server object */ - constructor(io){ + constructor(io, chanServer){ /** * Socket.io server instance passed down from server.js */ this.io = io; + /** + * Sister channel management server object + */ + this.chanServer = chanServer; + /** * Socket.io server namespace for handling messaging */ @@ -107,14 +113,28 @@ class pmHandler{ //preprocess message const preprocessedMessage = await this.chatPreprocessor.preprocess(socket, data); - //Create message object and relay it off to the recipients - this.relayPMObj(new message( - socket.user.user, - recipients, - preprocessedMessage.message, - preprocessedMessage.chatType, - preprocessedMessage.links - )); + //If the send flag wasnt thrown false + if(preprocessedMessage != false){ + //Pull an active user profile from the first channel that gives it in the chan server + const senderProfile = this.chanServer.activeUsers.get(socket.user.user); + + //If user isn't actively connected to a channel + if(senderProfile == null || senderProfile.length == 0){ + //They don't get to send shit lol + return; + } + + //Create message object and relay it off to the recipients + this.relayPMObj(new message( + socket.user.user, + recipients, + senderProfile[0].flair, + senderProfile[0].highLevel, + preprocessedMessage.message, + preprocessedMessage.chatType, + preprocessedMessage.links + )); + } //If something fucked up }catch(err){ @@ -131,7 +151,7 @@ class pmHandler{ } //Acknowledge the sent message - this.namespace.to(msg.sender).emit("sent", msg); + this.namespace.to(msg.user).emit("sent", msg); } /** diff --git a/src/server.js b/src/server.js index 6005d30..091bbe0 100644 --- a/src/server.js +++ b/src/server.js @@ -197,7 +197,7 @@ scheduler.kickoff(); //Hand over general-namespace socket.io connections to the channel manager module.exports.channelManager = new channelManager(io) -module.exports.pmHandler = new pmHandler(io) +module.exports.pmHandler = new pmHandler(io, module.exports.channelManager); //Listen Function webServer.listen(port, () => { diff --git a/www/css/channel.css b/www/css/channel.css index e7644c4..a5fb710 100644 --- a/www/css/channel.css +++ b/www/css/channel.css @@ -162,18 +162,18 @@ p.panel-head-element{ height: 1.5em; } -.chat-entry{ +.chat-entry, .pm-panel-sesh-entry{ display: flex; align-content: center; font-size: 10pt; } -.chat-entry-username{ +.chat-entry-username, .pm-panel-sesh-entry-username{ margin: auto 0.2em auto 0; text-wrap: nowrap; } -.chat-entry-body{ +.chat-entry-body, .pm-panel-sesh-entry-body{ margin: 0.2em; align-content: center; } @@ -182,7 +182,7 @@ p.panel-head-element{ margin: auto; } -.chat-entry-high-level{ +.chat-entry-high-level, .pm-panel-sesh-entry-high-level{ margin: auto 0 auto 0.2em; } diff --git a/www/css/panel/pm.css b/www/css/panel/pm.css index 2e6663c..54a5e9c 100644 --- a/www/css/panel/pm.css +++ b/www/css/panel/pm.css @@ -17,13 +17,14 @@ along with this program. If not, see .*/ display: flex; flex-direction: horizontal; height: 100%; + overflow: hidden; } #pm-panel-sesh-list-container{ flex: 1; max-width: 10em; - width: calc(100% - 1.25em); margin-left: 0.25em; + overflow-y: auto; } #pm-panel-sesh-container{ @@ -32,6 +33,7 @@ along with this program. If not, see .*/ flex: 1; height: calc(100% - 0.25em); margin-top: 0; + margin-right: 0.25em; } #pm-panel-start-sesh{ @@ -49,6 +51,7 @@ along with this program. If not, see .*/ #pm-panel-sesh-buffer{ flex: 1; + overflow-y: auto; } #pm-panel-sesh-control-div{ @@ -72,12 +75,6 @@ div.pm-panel-sesh-list-entry, div.pm-panel-sesh-list-entry p{ text-align: center; } -#pm-panel-sesh-buffer span{ - display: flex; - flex-direction: row; - margin: 0; -} - .pm-panel-sesh-message-sender, .pm-panel-sesh-message-content{ margin: 0; font-size: 10pt; @@ -89,4 +86,36 @@ div.pm-panel-sesh-list-entry, div.pm-panel-sesh-list-entry p{ justify-content: center; text-align: center; height: 100%; +} + +.pm-panel-sesh-entry{ + display: flex; + align-content: center; + font-size: 10pt; +} + +.pm-panel-sesh-entry-username{ + margin: auto 0.2em auto 0; + text-wrap: nowrap; +} + +.pm-panel-sesh-entry-body{ + margin: 0.2em; + align-content: center; +} + +.pm-panel-sesh-entry-high-level{ + margin: auto 0 auto 0.2em; +} + +.high-level{ + z-index: 2; + background-image: url("/img/sweet_leaf_simple.png"); + background-size: 1.3em; + background-repeat: no-repeat; + background-position-x: center; + background-position-y: top; + width: 1.5em; + text-align: center; + flex-shrink: 0; } \ No newline at end of file diff --git a/www/css/theme/movie-night.css b/www/css/theme/movie-night.css index fa3d836..5ce7db6 100644 --- a/www/css/theme/movie-night.css +++ b/www/css/theme/movie-night.css @@ -623,13 +623,12 @@ div.archived p{ border-left: 1px solid var(--accent0); } -#pm-panel-start-sesh{ +#pm-panel-start-sesh, div.pm-panel-sesh-list-entry{ border-bottom: 1px solid var(--accent0); } - -div.pm-panel-sesh-list-entry{ - border-bottom: 1px solid var(--accent0); +span.pm-panel-sesh-entry{ + border-bottom: 1px solid var(--accent1-alt1); } /* altcha theming*/ diff --git a/www/js/channel/chatPostprocessor.js b/www/js/channel/chatPostprocessor.js index 80d29a7..e6daf7b 100644 --- a/www/js/channel/chatPostprocessor.js +++ b/www/js/channel/chatPostprocessor.js @@ -35,14 +35,13 @@ class chatPostprocessor{ * @param {Object} rawData - Raw data from server * @returns {Node} Post-Processed Chat Entry */ - postprocess(rawData){ + postprocess(rawData, pm = false){ //Create empty array to hold filter spans this.filterSpans = []; //Set raw message data this.rawData = rawData; //Set current chat nodes - this.buildEntry(); - this.chatBody = this.chatEntry.querySelector(".chat-entry-body"); + this.buildEntry(pm); //Split the chat message into an array of objects representing each word/chunk this.splitMessage(); @@ -87,14 +86,16 @@ class chatPostprocessor{ return this.chatEntry; } - buildEntry(){ + buildEntry(pm){ + const classSuffix = pm ? 'pm-panel-sesh' : 'chat'; + const classSuffixAlt = pm ? classSuffix : 'chat-panel'; //Create chat-entry span this.chatEntry = document.createElement('span'); - this.chatEntry.classList.add("chat-panel-buffer","chat-entry",`chat-entry-${this.rawData.user}`); + this.chatEntry.classList.add(`${classSuffixAlt}-buffer`,`${classSuffix}-entry`,`${classSuffix}-entry-${this.rawData.user}`); //Create high-level label var highLevel = document.createElement('p'); - highLevel.classList.add("chat-panel-buffer","chat-entry-high-level","high-level"); + highLevel.classList.add(`${classSuffixAlt}-buffer`,`${classSuffix}-entry-high-level`,"high-level"); highLevel.textContent = utils.unescapeEntities(`${this.rawData.highLevel}`); this.chatEntry.appendChild(highLevel); @@ -110,11 +111,11 @@ class chatPostprocessor{ //Create username label var userLabel = document.createElement('p'); - userLabel.classList.add("chat-panel-buffer", "chat-entry-username", ); + userLabel.classList.add(`${classSuffixAlt}-buffer`, `${classSuffix}-entry-username`, ); //Create color span var flairSpan = document.createElement('span'); - flairSpan.classList.add("chat-entry-flair-span", flair); + flairSpan.classList.add(`${classSuffix}-entry-flair-span`, flair); flairSpan.innerHTML = this.rawData.user; //Inject flair span into user label before the colon @@ -124,9 +125,11 @@ class chatPostprocessor{ this.chatEntry.appendChild(userLabel); //Create chat body - var chatBody = document.createElement('p'); - chatBody.classList.add("chat-panel-buffer","chat-entry-body"); - this.chatEntry.appendChild(chatBody); + this.chatBody = document.createElement('p'); + this.chatBody.classList.add(`${classSuffixAlt}-buffer`,`${classSuffix}-entry-body`); + + //Append chat body to chat entry + this.chatEntry.appendChild(this.chatBody); } /** diff --git a/www/js/channel/panels/pmPanel.js b/www/js/channel/panels/pmPanel.js index 13d5349..4af334f 100644 --- a/www/js/channel/panels/pmPanel.js +++ b/www/js/channel/panels/pmPanel.js @@ -229,7 +229,7 @@ class pmPanel extends panelObj{ */ renderMessage(message){ //Run postprocessing functions on chat message - const postprocessedMessage = client.chatBox.chatPostprocessor.postprocess(message); + const postprocessedMessage = client.chatBox.chatPostprocessor.postprocess(message, true); //Append message to buffer this.seshBuffer.appendChild(postprocessedMessage); diff --git a/www/js/channel/pmHandler.js b/www/js/channel/pmHandler.js index c3562f5..ac5417b 100644 --- a/www/js/channel/pmHandler.js +++ b/www/js/channel/pmHandler.js @@ -112,9 +112,9 @@ class pmHandler{ } //If this wasn't our message - if(message.sender != client.user.user){ + if(message.user != client.user.user){ //Push sender onto members list - recipients.push(message.sender); + recipients.push(message.user); } //Sort recipients From 64f9d713daf0f7b5c87eb3ae61335bb023c62612 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Mon, 6 Oct 2025 04:48:17 -0400 Subject: [PATCH 23/92] PMHandler now tracks unread messages and lights pm icon to notify user. --- www/css/theme/movie-night.css | 1 + www/js/channel/panels/pmPanel.js | 31 ++++++++----- www/js/channel/pmHandler.js | 75 ++++++++++++++++++++++++++++++-- 3 files changed, 92 insertions(+), 15 deletions(-) diff --git a/www/css/theme/movie-night.css b/www/css/theme/movie-night.css index 5ce7db6..439002d 100644 --- a/www/css/theme/movie-night.css +++ b/www/css/theme/movie-night.css @@ -168,6 +168,7 @@ textarea{ .positive-low{ color: var(--focus0); + text-shadow: var(--focus-glow0-alt0); } .inactive{ diff --git a/www/js/channel/panels/pmPanel.js b/www/js/channel/panels/pmPanel.js index 4af334f..b908858 100644 --- a/www/js/channel/panels/pmPanel.js +++ b/www/js/channel/panels/pmPanel.js @@ -32,10 +32,23 @@ class pmPanel extends panelObj{ */ this.activeSesh = ""; + /** + * Random UUID to identify the panel with the pmHandler + */ + this.uuid = crypto.randomUUID(); + + //Tell PMHandler to start tracking this panel + this.client.pmHandler.panelList.set(this.uuid, null); + this.defineListeners(); } closer(){ + //Tell PMHandler to start tracking this panel + this.client.pmHandler.panelList.delete(this.uuid); + + //Run derived closer + super.closer(); } docSwitch(){ @@ -146,6 +159,9 @@ class pmPanel extends panelObj{ //Set the first one as active this.activeSesh = seshList[0][1].id; + + //Tell PMHandler what sesh we have open for notification reasons + this.client.pmHandler.readSesh(this.uuid, this.activeSesh); } //For each session tracked by the pmHandler @@ -202,6 +218,9 @@ class pmPanel extends panelObj{ //Set new sesh as active sesh event.target.classList.add('positive'); + //Tell PMHandler what sesh we have open for notification reasons + this.client.pmHandler.readSesh(this.uuid, this.activeSesh); + //Re-render message buffer this.renderMessages(); } @@ -296,18 +315,6 @@ class startSeshPopup{ startSesh(event){ //If we clicked or hit enter if(event.key == null || event.key == "Enter"){ - /* - //Cook a new sesh from - const newSesh = new pmSesh({ - //Split usernames by space - sender: this.client.user.user, - recipients: this.usernamePrompt.value.split(" ") - }); - - //Pop new sesh into pmHandler - this.client.pmHandler.seshList.set(newSesh.id, newSesh); - */ - //Send message out to server this.client.pmSocket.emit("pm", { recipients: this.usernamePrompt.value.split(" "), diff --git a/www/js/channel/pmHandler.js b/www/js/channel/pmHandler.js index ac5417b..6553edb 100644 --- a/www/js/channel/pmHandler.js +++ b/www/js/channel/pmHandler.js @@ -38,6 +38,11 @@ class pmHandler{ */ this.seshList = new Map(); + /** + * List of open PM Panels + */ + this.panelList = new Map(); + this.defineListeners(); this.setupInput(); } @@ -66,10 +71,11 @@ class pmHandler{ //Store whether or not current message has been consumed by an existing sesh let consumed = false; - const nameObj = pmHandler.genSeshName(data); + //Store whether or not we have this sesh open in an existing PMPanel + let displayed = false; - //Create members array from scratch to avoid changing the input data for further processing - const members = nameObj.recipients; + //Pull session name from static generation method + const nameObj = pmHandler.genSeshName(data); //For each existing sesh for(const seshEntry of this.seshList){ @@ -81,6 +87,24 @@ class pmHandler{ //Dump collected message into the matching session sesh.messages.push(data); + //Set sesh to unread + sesh.unread = true; + + //For each open PM Panel + for(const panel of this.panelList){ + //If sesh ID matches an open sesh in an existing panel + if(sesh.id == panel[1]){ + //Set sesh to read + sesh.unread = false; + } + } + + //If the message was unread + if(sesh.unread){ + //Notify user of new message/sesh + this.handlePing(); + } + //Add sesh to sesh map this.seshList.set(sesh.id, sesh); @@ -94,11 +118,51 @@ class pmHandler{ //Generate a new sesh const sesh = new pmSesh(data, client); + //Notify user of new message/sesh + this.handlePing(); + //Add it to the sesh list this.seshList.set(sesh.id, sesh); } } + handlePing(){ + //Light up the icon + this.pmIcon.classList.add('positive-low'); + } + + //Handles UI updates after reading all messages + checkAllRead(){ + //For each sesh + for(const sesh of this.seshList){ + //If a sesh is unread + if(sesh.unread){ + //LOOK OUT BOYS, THIS ONE'S BEEN READ! CHEESE IT! + return; + } + } + + //Unlight the icon + this.pmIcon.classList.remove('positive-low'); + } + + readSesh(panelID, seshID){ + //Set current sesh for panel + this.panelList.set(panelID, seshID); + + //Get requested session + const sesh = this.seshList.get(seshID); + + //Set it to unread + sesh.unread = false; + + //Commit sesh back to sesh list + this.seshList.set(sesh.id, sesh); + + //Check if all messages are read and handle em' if they are + this.checkAllRead(); + } + static genSeshName(message){ const recipients = []; @@ -153,6 +217,11 @@ class pmSesh{ */ this.id = nameObj.name; + /** + * Whether or not we have unread messages within the session + */ + this.unread = true; + /** * Array containing all session messages */ From 53fc46adc38cffe1d075edf4ae52da2f9aec8451 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Mon, 6 Oct 2025 05:00:35 -0400 Subject: [PATCH 24/92] Started seperating non-free images/audio from mian codebase. --- .gitignore | 4 +++- src/schemas/channel/channelSchema.js | 2 +- src/schemas/user/userSchema.js | 4 ++-- www/img/johnny.png | Bin 15906 -> 0 bytes 4 files changed, 6 insertions(+), 4 deletions(-) delete mode 100644 www/img/johnny.png diff --git a/.gitignore b/.gitignore index bd8bfad..6bb63d2 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,6 @@ config.json.old state.json chatexamples.txt server.cert -server.key \ No newline at end of file +server.key +www/nonfree/* +www/nonfree/README.md \ No newline at end of file diff --git a/src/schemas/channel/channelSchema.js b/src/schemas/channel/channelSchema.js index c3055ca..bbf72c5 100644 --- a/src/schemas/channel/channelSchema.js +++ b/src/schemas/channel/channelSchema.js @@ -60,7 +60,7 @@ const channelSchema = new mongoose.Schema({ thumbnail: { type: mongoose.SchemaTypes.String, required: true, - default: "/img/johnny.png" + default: "/nonfree/johnny.png" }, settings: { hidden: { diff --git a/src/schemas/user/userSchema.js b/src/schemas/user/userSchema.js index 434447c..d456127 100644 --- a/src/schemas/user/userSchema.js +++ b/src/schemas/user/userSchema.js @@ -79,7 +79,7 @@ const userSchema = new mongoose.Schema({ img: { type: mongoose.SchemaTypes.String, required: true, - default: "/img/johnny.png" + default: "/nonfree/johnny.png" }, bio: { type: mongoose.SchemaTypes.String, @@ -318,7 +318,7 @@ userSchema.statics.findProfile = async function(user, includeEmail = false){ date: (await statModel.getStats()).firstLaunch, tokes: await statModel.getTokeCommandCounts(), tokeCount: await statModel.getTokeCount(), - img: "/img/johnny.png", + img: "/nonfree/johnny.png", signature: "!TOKE", bio: "!TOKE OR DIE!" }; diff --git a/www/img/johnny.png b/www/img/johnny.png deleted file mode 100644 index b1335553295db0583e1a1d15b3f411ba62bf0871..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15906 zcmV+-KHb5IP)ndQ*IRq< zSv?*DHpW|kpnyX{K#2kgW>X@N0x|p%5=Dt3Wg$`&MI=&`%`DjiA&!t7L{LJ2phPw{ zUI3f1J)ZH*^h|fZetYe8_a)zVZjG6FuexW(p0Nj|Mpyl&UcIXOJHKqL+g;y2*>MqF>jwS(*7m-w4c z;`$J-PvE)_R|D5Bt~cR&Gp^69E^fwet*%q~|KzcS>!0BIZ7RT*>7uZI2G<{XH8uBc z*H_~4y(?|2zQWgST>lK0`G;M&U0gr4!p!P*c8zeo=d}>-myEez|E=FHWu8hHMpBi9 zl$I^7;fd$E@{#}ZKj?`5Ij--1rSEGPrq~YN%k{oltq5mzqN1= z#XWw1=F!LXeq8R?z56?V&TuSoEk}Z0PpYhvQVMt`quz#B_;}468U*{J_~&1}TtNn% zu6WIsSg_JCT8M2K(r&fIZ#KoTEvf8=RBj+8HxSoyHj>53k@yW?f^Js|Xo_RI(r&jU zD>IporxGoflBO9pB#|_nL*UuHZsR|F;x|4ZZ~L}C|8uXF3|@8Ne)=OHd^i62moLNH zi{I)=yEl|emoH0U7!v0R1QAMkT8dScu!tN2T1e?N#kLEvt6Ut{gAy5dE|Auxi{eA6;AjF#%(mEzqKn(zX{At zC5sRPKoc!MLKG#y1`5z1NGM)XFpllOG30z11Zx3vMUr$OmS>Au0h1y4tVq@D4J@Sx zc&Ks+8h;DviUlqAn+SLNu=bL(#Rzx>+Izfb=7d*1sc z3D|fQWcxk5nR^o2olDZ&fp3GBh4Z;AM>8oQDc3R~Bxt0WtG_6UQW|hxhDqE7M8KIi zFzdj?bG8ZRLf!=C1Q>AO>?rNHjK#sm;kiP#MN+Izya_V+MNJ*NkRk5t!)f{Q$8r7Bb-$p~xpwVg2q2fZNMtb^OFWxMk(6RUb!=#1Uao!yD9 zR|Vmf6@JE@7u3yVPFS7N>*Fuzhi~CPe6Hd#nC4Z#^<@>pCGJko}iS&*AUEG`T&a#P&O6|iFn+kry^JFxJxX-g9(-UJP^ z4PYoV*s>uUnyFQa2Q8NjODOAwq_a>8ToS@MprryhXh66=;KIU&8u-_+E7e}RbO9}a zZdw|h$gn?<0pRN{k4MQQ{`W8Z%-ddT%>4_v{^MF07;MOJb62s|f)^@*af|UpmRM*` z6NXUfRMHi}Z$pqa2|u+9w?gMe$AnLwKw z>3K@IDuc*Jxs||4A~6;WQ1!qrxaUaA zpZhd4JU+Y3x31{spL;E+7~j}A`{aeoS0I2UxF!T?aZ#D1a9qhe1Ya%Hq1a_E9)#oh zjx=EMLDPq~Hl^kJG6)*d_JF&fQPkdM1HTVi(rPtj*z15c4M62QU@ZO?_{@m3ggVZ` zrL;UpIt@qm!9b>ADrOc+3Yf5(o~g;g@bDAw{{FxG6R)Ol-@Dqm7bjx3FG$00km#sjmoXp! zJd%u{V$=ko8cKjfdMyaCp8ygU|2d5Z-Vs$!A zaN_{~PkQBu11-$rQZ@Aik(P@M6EK5r^Hi1)$%CJ<(hoyOjSqnf};Mqd^pCRZI@L$>=J*=hyy(9Q+9@6rWnwPE@& z`ED~1zhO%oxanh~{6<4s%_aoxDb7M|MP0n_*WfvWxdN|CK+nznTk@&Te-8idA!vXz z#A`^==(NZN=4mL0F!%8|l)a;o+&nsw2-aj$WecWdxb=pB4I^TNw)wOi;I9dK7j(f! zI?&*ct&_==38|{8e(g)u+<&n?IM~Immm2G**!?($w}x}^Y!7%_ND9F=l(}K{Zv)Pz(1c98;mZch@FKL>fv{a>zzRlX&?)lUPM|@9#=L%;-UnBv&za!Q zSD=I*0*8kXPFKpFD}5>7BLk*}5ORF&KA9{+`Mt-Vl*jOz{n1!L_<;~_6GEaCusU1f zdQP8;^)v`?Q^}^eY+rdu4sSlWE{*!Xfa4VKFMhGY{bpQ$`s|Yf2*XTr<*Q`%W|k}6 zV#rEwo>Ob-=wR0OA&_`vcSqiS|24U?yDKpv!^mZ;-;+UW18~t5AKqV}z3OR_sAIUs|kb$jRtw;hmu3dz~ zH&6EES#a;0r>Al_jiJc~?#I;EQQNJo(y*HhBuSQF5^d>hU6R?+?X`Xg0{$=ry!XYx z!vDqfM_-gDV7S@sV3%{45FA;Y!3>v(kl=|;PouB|*xQCze)z&AdB+3y%e(HoE*Cck znipVr+6JVw8jj`ce zn;RWzcWV4zA~5mV+cHI{oM7|TJpMg|`$Ml(bHDDaVEO34doSXNblYuVRv=Gm>SF;T z4aAj3T7kp*vfF6`Uw7n@s~05bx{^T-0kpLR81O;G46BfWcnW;3G8=x$)C`#8L4d|8 z-nHvof(n&9m;#5B1cBApN{FC=*bGY$&DWN-hhlVws4XxVJ_DcO3mSM$V+)ot!=^TB zLm338a%1mU9>0Ac5ne-XP=Uhbi&$cAEPgid+4Mrdy>1J<(~)QbE}KTu0(RPPUBulAi}#^u>o)sF*FtIjcX9~F9;D*l4ALEusSGb6r=kr)3S_lQu;>ENCr$N-F@huthd zx16CPDLmWl{)y}@z;u?-RHu?ASWOnkGDpN_U}MUpkle!hrh78Hbf4@$_35*s`Oa4& z+`o1{(Txbnsv{f8vB%(s4jftw_?s5Q*$~R+$Ob(8wXK0%XtZVMLp!nPm<$T9Xr3M2|^p1(Q6fCJ3VL4%z#HycD0cY53 zyb$r+_TTmp&)j1hyF45tT=2`%@< zYLiVcQG~!j1XzyQa*;t2wgx}cS_8kg03(Ds-5f$cU@KnCCFl&-$?OMSj^@4**R>ZH5y1sP z!i48a4a5LGMMl!$#j^>so*7K}RSu(vzd?$-0d=*8k z#nk!aT#EM_UXE~o@az-J9WcFG!5nD%#3ClSNrRRNv?9&*%?3de0=Hy1Xh^TuR5Rj@ zIS^#SV%gf&{9eNShIN55-=>RjFvkZm z+th0N__xFw>xw})Kz!wxjOlbNg1jkF7J_Iykb$kvfdQnC8+IB2IK3xLCy=h&gX61eZ7P|> z0Vb26v#5xjpN4Q8i>W%tc)5^xK86q}Pzp2G;w2N}wdtp|26A^1aHz>=r9vwCt_l;_ zX+W#29)cTiY?Fy`;wrSf!p}Bd*8t48&^~7BT(A`fo5aMDOTj`h0-ISnPae8{O)m5S zN!0R^m16@NW!0r5h)k7><$Ggo4ECmbIkZR5MH*xYpjs}R1yl2NJA|qVj&&mME zWm#FMsNKyvDq{kN`3GjKpq(aQi&-<_1uZvO=ww|{-@gQWySPUP2#$_D62?l)83d8b zHb4VgaHI_=&7)T@$~&)JlOfhoWtk>+G#bZdo7gbhS*^ob%Ll&wUYPr)b;4yv-(+YD zt+Q|gc||Ici4Pu2H|k)AEiAMJjkqxEAbNwT!Lw;n++x+W)RZ81yHhl^5Xk^5MtEs5 z(rv<7CUelPLUX~LjUfc&*6_fUnRV@~8c);|O{UeM(I)Q23|e&*M`|~V)Va}-8HJ^I zEI#0eIb6~_y8+=w*taR(FIEQ~1AaWb_Xf1y2ShXhArD;Kl26{)liR>xVuwj)$MD{R zW5o?uprY3LlJFnBmvG;H{;5J~81FeriKCuo@3mM+oMe(5g6;Z&T)MC!TL>OpXipP_ z-=Vf9nZAx1-XCm8*K5IvOeHER%}GRXh<3=$s1sfmOf?xFi<^WRG#Jn-6MR{csZ^wE z(Bum6Lujh-9_1qB%CSkznBjiRtTswQNRNRELuN#5QK-2Gn~D0eEfzxVLP8CX6TF9s z_iEth2_UcsZ0>>2J_qMk0?$EzbdN1f@_6C}9Y6#A#thFoHS*o742A8N2czXADprd`61^Uiq4MnoVX96yC33PB#bC!U91LH?X0;aV!xzJ|esn zFv4oJCN^m`_>TqEv`o&5j7{9By0KL^a=>Y+A~aaqX(%tIgN))ixNr{2=V~o&|73zN zHPzQyafIK;b;F2Oe=V@HMlC2{J)=DaGZko+0lAelVzNi3-(|kcmiBxB zP?$>>wq$eAMV#KmZu`n-xw{2E)gTinZJ7g?ecbdMS~`Ok5?&H$QV0wLA(A2JQw&o- zLJ+ah>tmx@itQd?quXvk%L-7oxn|J}z{GNVs>MS)XiJ+_Vlo5>-LdK%13PU@IGtP_ z7J?6ICw@PjFW}5#nZuc-2$`+i(0UzBJ|V2+Oho{$us6*?OId^$RZ86qFd*JXUb4|D z)lF`|S%#pGW%(i%eh3!7eO9=C^1N^@jV|MeH&f`1vxCJ3-jWIIZ1-ib1C8zavPo@% zS?A0bK%*^?ejBFGl;4Qmj7Kw>LzAZALM9_PvIYBaU8O$;DGxYp0e%}Coywr!k&SL! zHo-gT6mx7;ir3ii=1XkII7EDp_wC?!K0ad#7C|TG`@ng-RF)QjXU`T(O`_9h%&>-8 znBw_FQX5Vh7HEJkbF(7cp9!Z(%ohleT|sZBbgp;y)4&HbU zOd^)Ho3`AVAIhy;2Xb(7C=;-Rn7LYL45PVzuc!6KlUKH-ELEQ zum}$eWiDbiT}lXt2Mj>a(olhnrc>?ZC42S}9X3JhLI~q@93%D!r3r3Ijih6uf#+JB z6zzu0!dQFQTv%1ZX=;)vu0joU8Eg<1*^XMS(jDyHp+l@-L5peu%b>EY<-6?{3imzd zQ;ep@&#qbLPRYD7QV}-D!GSQx(gnv1+@?B^h$R+i05@Z_n8W;*+6Q}c|5y%>j%CRv z5p_OrLU^+oO!0U;h9-xy0Df0mlLIU9YZ`ZOIFYBIHUTtyNG%4Y2cUcPA`NwXGemdO z@mwLu(FEQu6;7!s3(n0Or>csT|Hg`q-M zokTLVOivM$kx;QXO0Ym!o-v_Qu61g~3x)gcbIuGq!l;ZPXqnZ8K2t;gN}U9&ya8i= zw#hTUfWW|p#w?Z-^~7u^IyoK7$-#+s+BC7aw5;jeEDoVTrFJl0-rCU6bv|Cm44BBM ziD9+@4I@QfV4)||nVevwHoAQXxhuh-t;v}PxE+Uy+_`fIN9M@p&QMMk;Nq37_}m0^ zmv0pHB@#t9!#$TF7-H=S~$5tSQv5byVe7H+8p98-T#yVz8=~~YG zCrda^12`0kgPXs6^%7o#g#ju;T>Xv-fj%VHuU?bOJ6EOM3qY|7Sxn|~^X6@N{F%?o zt-S-~!t7lbgYPc!IR(^aijCh zG7n)*Azm}n@HObPHQepN83y<_K)~Q)Gj@kvjno<*lm{l;VD}{WF^jJ(t|m~LuuR2= z)%i{!v6a*n={1!c`|cDB^q)CUU>OHEQYJf?3}F(71w3XJpgGz>NJoRvmTgSTgw270 zgUzArT)ZID*-|tFFDo7XAg$Z%_vPZortEI-fVOqTZ8MSr#hxyt56v{OK@{9_HU|@c z00teI0CG4r#0*;#3uc;wjKINwihJkUO_-%O1=-V^RbJ-f&VlmqI!)_YFFb znAXBZlBR47JMzdw_en4uN{sgeP|FR##UcFr)dLac4I1HgwXql06ano}}u5bgrrdn~u_ z+yS;lkcFpt)Vy-!nZrYQ2G5^{b^q1?Shz)8#X=sqvIFhi(9b1yHgQkvnb;a^NYEc5 z+JgB5SKk1kfAYptvUhw0=*jUnE!k}cn!w3fpCg52ha)Q!WvK~rG8z_Ht%ap2(-O*b z1Sg!`I+7U`z+&yp(5gLeK{4~fNa82>Q@WeJ^?(a=aW+-2;|;$F~U3up&)tWXW=yMb0+b8OIaxAx`W z;6QKCZ}ya*8u03arYSwpp#Yl^Bjla#pU8ILOZUEqG?kbGDwKYh*+P|$(E85G4yDtC?#Kq3u@ZXKX0kuR8ZEs80rSjI#th72L0= zGh>F{LaA-_XYlGwR=Bec!L~T1P<3O{ztsbJIsg{ZYyD5)cyANS6 zC=)9g0OhYPQ%wo77RMZSIG&0RZMpB_t_-&~WC;$u0f)&FeH&qMqq*wJVTTTN1pI7g zuq7TotBHtiI2@>!@+Cf~;wH5G^5u)_U>R+7Sz5}M^2x_OD02h7ycj_{g2;3nbZm?q=| z4jb$Os6!hX#b#ut{)|SQRAG`shxCABsX_&45Q~=(;^j-b+Jk#?GS>PSL)!u9Tn1;C zz}vI55+nM$1P1Zwqi>SUt!+7&oT`)T0uFozABe)3Xi*Z%ERZ&WfO8^h8V-BX?zgmG zZ~xX!nI#LQRfG0G9((p#@!&Au@bK5joA2KSt?S5J-r9skfZ06u`_h1rDw;l^kmdgb zj!OGb?79nwX-x-KYr4Kf40xDMWn`J(VLMHMHQ3 zncE!#7n|h4ETx+y_O^DkP{UnpP7Dq@FA(Cgs!Obdwso*Lwt03yca9E52v6q{f)&t# zdRqLnWdPp0xjE43oEbj5i3RPpdm78fcx?~Sl*fh=K#gYHc~fL9a4^>{ZmD+Vm8m6h zw)Hm=>ABEoX$11YPkc)L!!P~1T-e-{ue!b?U-j^VGH4BC16) zU&L7!IN5;0_bs#nU1=vy!BWur%$P=KH_i*U)aK!!<7(SHX%rhGvTWCt=jT)-3xsf#*w6}SsQ zu*k@&LyI*mP0P8O7hvL}1!1}D)ny@K8R`T&Z@z*CgxLKusAPNdf@)aC8)iomlkj${ zzFAPf5TyU$7<4UN>cV_rZUi7qU=g0M4Byevo1NjDYG=_gfI_%nC(M#DRBbb4hBF(d zb9wvMJ}Q?kU63c9y(LcpCJs-IYiY@Q1%Hk963O6f$-#lWoi$usALOUyL1^JI<~+=qFx(T=$SX2|$f9hi2@XMPnJ zMZ7gRS77K2uW6C!JF4AGHr-{D%83%l_%1+B&SDaB&f>VnU95r5jFAb)1x(@q zo2&D>oOG8euu?G|#`8F?M_zb9!-AcC3=l|Tuyb8dB2pT1Mw){WfgRujNx7EnY%DzK z!4x%I#!WDXT1-K;5cL@F&IFX>lS3IHV(V>ez_~lJoSSudtE^Lkgo6@}jd@!NY&k=~ zfxw~73ITYj14gh7JPtN{Y`3f%qP3sbs-X(pnYheIkc?!A=xuZBf*hZY<%y@ClL`1M zov8x~$9h`|!L!Qo!1e{Xd|_Lh7Q*YWl>?(<5^NKk+* z))G_Qhj#99^n4KU`!5u(ioL8$&rHm)8mrkccCAoDNPeq2q0$K)9Ts7@N$SJF5V8O! zY#hY*y)JIZrjvk^V*iXX*A{JXaN>5mcd}C8pr+xcBgxQA$F$EPfp-a7mBrW zXg1GZu-L`HHYb=lJe3A0yqMWy(wynPm~$9#p1n>NLTpICH-zOiImxq zwBW7!2tppba-Zy89LO|YD2#x3NHLkp5&Olo5L?wFvCvwls)tn!b(n=#RPHhseCK)L zo;yZL$2u!!eq3zH#^Elccf8vC*V4$I-a2DF7lb^*;JY4==anqp`i zu-Gb9Ff^eA&w1DgCQV#8J7(EDI2}e{mS3snCu}2S9|OpK1K7@aHL~m&0PfSnXveu* zI{*$zH#qszlLj>Mcs!G2lVQ27gVjv#u)nR}c@y9$g!N3YsiBtZ+2U%zA{N+iRiz%6FP^13mZ!g5tkFl%<=IEL;pCuP`B(t=<+4IWbA>R?D7 zrCKT>o^hg`L)SJ0WpoW0BGhG8ZGUtGA;i*Ivhq5u8$#NiHjfl=h)EvPwZS1x#bSpL za62!T5&h8y$7w(Po2jdu86lAv_Ge(S5Ob%h#<^A3WB zGLq$;+cLj(0Otv7DZrOGJ#?IEUia|iSo^GyZl}^=v0ei)eHD?BV z_<7;pTt~;4-_&{Bs!*-X)ibmDyBctYQ>nO{6-*S*(L^XxY8)5Eh>RzH%sK3gAcBQJ zR(#F0?pavs(Ez0~i>8uym?vluk4w3E^QIE`t&J@?I?l0}7{3dlm5KO44HE`7WVx7# z)o`^!d;IWLnL;{A|`YAoXT=t%YsjJAzLKcGF1U`_Tc;uCMVK>)(!_)&)AzMIot0a+?II#R4xv;#O&9(T*m()%sgeON(cEeM6gNS(~y%9!TSR~ zvJxANK*FL0;&l-$C}H4W)paxnfff743^%q}+yIR`78-e7r zt%s#=M^o&!wz9YIp)FDpaKZ{2Sa1Xio6zy<1drVvfV19-~}7;MNzH;{)X4}lAhwKleU^^$CE3}pstwFl?J96^>XWeH~(a)?^H z(tw$xxwM;I>9K@_&q&#gzDn}52ZQCAoToYE{5jE9Qua73VKp>0RyxR%LN=-MNG(`L z$_ALco;V`zQt-?WvP!a^DAaS(FBI;0-P|=zn$D!c4Kc64)_aOLGk61uT;$1dc-`qd zl1m!_f(~EL^kN4~yXDo1ckt8{_H%^jheb;!RjyryU=cio@f=hr0XACV!6fr}q5~sK zgvTye$Jf5&8)Y#W%Xo}1ctIB5Kwz>hVRj@DD3p==_}pFD?k8G~BJ{lFjb9_1pl^CK zi*{y0IH9S6A*ZGPt@NZ4Pgj{%ECto)?G%8Jl+D^YUt(+D0~1yrr+|2d1CV8!=cdxx8mU<(&62@+;N=dqaS6x3dD)U26=u(RR}5@Q;QiS#lK`JN>!3IV zQ=CDw7Hl1|c|27g8rZ?+l=952ThP2jvD-HzVP`%2Y?<-T2=pAa3Wm~bH4%_C^-!P@ z%j2U;E?>DKm#|@r!>OFiMl!?uHE}<0y#Knq;p#=%>IZ;=Ofy_LqWmyoZX?lCo2YSV z7OiF}N@X-nc0-ozynz(~YRJ!-LderZ{Y+^YUpO6#xoPHKTpl17opWfP0#lfs74EqN z4hx0!&dsW!Q3=nY(H3~J2zK8vp9#TnD7vz9)iyS~u{n)5vn+E3{%N$(x*5}S9Qmhq zjbRO@S&#bX?2_ez6B2m{o5#2`>+IPA+G)ZG23rID%sWp$1q!#6M;>}Wt>m;nmgcxE zX2a3`-~nJLXv2cQ8WJ{E(;Y#(4IWa&lc6HuElW8?O)J@XS(2;kRFMx~0BUK@6?UR1 z*9I0IDMJ=SC(be!tKSJI8&k?_9>2Y=`FS7LQ>)3znlZ5H0?xw0v-5cimx;h9aCjPH}4@%%GE(}b+x$e3^24`gPW4C5{y~8w!CwA zx6bkpy->J(Bw5{n!CtY1%?~+aep62ZvDuJ-5OV<{A2ir2mcjg&z|XdNUuy4y-7h>{ z2@WbpEWV7E!0Nf=fgvdZcMh`TdiD$*ODdh9BVN%|C~{nOdYiaJbHoLl*-pO$inT4x z(oxLUW;u0G&@Q~bVgiU`5{RJKv_WduU;{NUs_qrT2ErPBrzIAh8el+=V*$>_*-Ysd z$62|>mTTyQkg1gjLwi3Msp|Y3+0^=^LV7pho|{@@Nh1kMt)=NfChX{j&nY~TdC(L% zq9p{}0l#!YLuRLQXw?-xcOl~WFf3g11aD;OutcDcX#(2?BgkaBK-}MV z)ua+E{A7MC$*pIliRj9O$#3;GWovU&h8+YD(7?ce)0`a22!dZMSY%vC7ubH~@-=8w zQ}&LJWyEfCY^n+VOX3}I&Iq4LRb$?Uk(tek1k9I$D`EC6C_@`o-~po}gtz&eL5(3k zC`N@)sv4D4t9US zA{|@$Nl(&PMiXEXwtJ{FiWxJGi@JKyuYpCd1j9T!53^h#G<|yijy#D7j7gjAW?SNm zkd==RJuG-Knf}b@o(2SYa(lLr34)DF+ilt2zAVG7ZOQOHccv4G zKs%T!b)6hqT4QC#BsWu*@CWPxE230poRQEIOml7DVs2r^Ly!$l7MY68^(iInJgVCX z*ay+3cCU^~l&vRsx=PYn>Ekg39uFc$ zu%gZ5jfOUcoSr5!orSU!E@b2Cu517g5!*G7&)!YAjq|BT&<77|L&h*Cvk8qLCBludWpoKsn=V5sk3{)0C*B{ve!tv*`I0>G zna|4T=omtXv?-4H(SqkAnC0_H4jG?}5#G+_WWpBDnPNYy2<^t6T-fZ$MsI-MO{H_X zCs)9K+pVF}q-X(bpR$JyYv8~P8wN?aIM)H@VtS@d?{LhN7@MTIo;p39&UF~P$Sa-K zs3`>;8P9@Lcz}|ahRzS)Ew8XnxY0S`*5@6m*7C)yvgjF%aLLImkx?tf%|_BRebA~5 z&T6Vn8$Q^7IpirBjPpq$kpCY?drVy z=T<;Fn!rh()_ezn!ro{irzcYxO_y>9JoIpUDo@`$mHmTL>1}RF2cI#5U@l*{B4)d# zjAjY#ow2>X;OsF16!6_*RT@f|3oRPy>Udy}E2{K7^z3$0u^VjYz81kkjx~_UKp3rU8~xCWI`h}e zp?&|0HMc%!gSpF!y9YAMnN#oYtfiM$p=Cz1MjQA3Rr);H77YBdQ?*=U6;< zuVPQFsqIE{&WCa81~+{XmpM9>F|>LgTHFCVcnB&iuax82f%K-HY$B9(3{NA#I1l9} zLR@x>d%cEqx3}chjoXrs#!7cmb{qoxT0v8~aAph$d?tB#@)A21VOFJ6$<+rh%GK*v zRj4Px|M7UKl&=eo@Ai5SNTE&KIf8&s7y+{BdYLPOpz3KAywX!= zlOI&Wi-xAc1CShp9&%lgeB!ljddtE#v^61Hku`Tn8Ms zrnBHl&#eABp4PzT(4;(N|87HqRvR>}rCmup*q!ZCbdC+4QwA0?M*y-&>gH-rnV*2B z?IDmjhNe$7IaKMHVH0+bgU&6$R*0LO;Q-LXBMe(wH4gAu?QTygRSbc)U}c=;NFYe& zJ7Pc#eZ^=p2kVG{rIlLBG>$co%|t#el}UCcn9}v6idg&OFXvn_9*Ftjv(UhdJC9~% zuNX@kfde5x+eY(+%sQ?NIB1-$xGzX}w{2>8$k#9f#X?{Uu|C`Qr--Why)8|=moO)GhiR9(p)}xrrvd_znSSpiF41WkHcmjfJdz|@36oJdsU`W-Ym_u{ehl6=CGv*i{s#l zZTWi01*4n!Vxs4RZS{Axfh5U7J)%lyy^Ks-QM1(0%v+;r=)hi8F&EH)bFu2tQ+@{o zD452w9+woiA(eU&emdDy+%}HxxG-Ytpz?sQ$IW5U_7iWyw zeaTt5C-?VZO$%5R+qEh=4pYq;Xke>3icHg?cd3>ATL||3uk;ksn=4iS*|YF4!v`_? z>gqH*Od2BNS*Op9LutwlG0Ip51DJf-WYItcQmh85dFBkeA=&U6hSPdry9_{muy7_& z$VFX4dMy{UiQXRqYwCd){f#g7aN>WxqGjK7 z_7@ShbPrhDT%Gl!g-6yl#tSU$hFk%5ZEaoqZ!355)+=x9c>DCfO67Be!0#>YIA5 zGx}0aex~;R;<|Hhs7=$Vegp0_t5H1R`^Fq$@txc1aJCkGxj5))FK=ch+JwR5OqifC z%uIU}OrE%u*R?HIPaUoF*unzp)@gS%Y^~d%eC>p@!H*qHo|jrVVD5s4A!|L2^A?R( zeTovlj)sjJfUwDUrme8Fs2HYy=iorblacalPEh8hBd4=50*LGj-{TeOdhYsFQa{^w z8z1;mkJWyBb$;$otaa;oV)zwUEL_+31jI3aCwAXIQue!5X;sM=&| zk)?9Z<{=84f&NWkNt{`ZL9fO$1{ibk;18N`xWv>$z{A1mL_F@IxyhyD#;I7x9Xg#REB@8CYzq4I;nMm0sUh`o$^85YWJu_scsMz(WI2sY=_7 zIIzfL3e&>SUIw0H!@gV3Z`GMGPSQZgnORT4q-m(-bAE}3PIeY`G)abVa&jQg+}hJ} zyQx)#T#pmbS!pKQ@jm;ACO>~`U&h6%vMcu#8^6c$yZf$x=L=z361^%1!v8y5{~g!+ z?sb&Ee*YjS1oOJg53nLT0a;%j!ZCzR+2{|Y-DESg1t(J=)SLlN18pNJq0KqF9Kn-4 zc7!l32q769)1S>`v1FB^i>L8^>_=dgX>~j|>tsxKlBO|-z@FkX;aKWiH^8wPIxxaR z-`Fw9;AMdzRy^g5_5@ycdFf+23OK#eRLvJ62l!mX~_rg;rA@?Dxbg>-K&Z zUb)>t7|WCD5Ki_W$Om>Ws+kwjT$9pG;NYOy(H1JE_A;YBE0@D8OcO2^OYN57DIRQt z;<-2TFjMNqw6Vd~sG^3Q=l1q>7+Ulsl1k57U`Nmd{PweZhjPLbOINUVkH_DEQ2)WY zU))P`ztk^2`V+YRA+E2z=MF+R*E((41PvQD>a0EqK7~cOZ9;RI0AlMtzXp+~nR2|8 zn8;FgeVnSTCw1^!ri91+8TFAXqZI-* zAIX$`zgo++wXZ@mU!3-QX`!;;>n}mDzjCh^z2?HTR#fbu{@xzKMf0Wpt_z_(I6y?l zgW8L_O4|Y_-hj~g9TC)G2WHH3#VWf_Xlvz_f?^(lSuP5eUreX%p^WT&o$@)J%mE4e zatcCi%*jOl8Q!qJ%qPW>>tqn)ox7VF;b)pb&{h6pBRQ!n4-;WA}6BQIz4%OzCi1unmu3wS*X_xT4M9>0_Q zcV8o+{;I4wbosRwt|70#2VwkAt+H@D*l70^{{80(g{wN?2UooW$FJ4R-b+JYkHS6s zcwkkHj#j?&U90Ama^-_=ulln_t84k?yt#V`_x}kn0Bt5Q5b=5-b^rhX07*qoM6N<$ Ef^d>MqW}N^ From 976e157cf1db177cf13e135e9aea67fa2167806d Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Mon, 6 Oct 2025 19:06:47 -0400 Subject: [PATCH 25/92] Added settings for PM-related audio pings. --- .gitignore | 3 +- src/views/partial/panels/settings.ejs | 21 ++++++++++ www/js/channel/channel.js | 6 ++- www/js/channel/panels/settingsPanel.js | 56 ++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 6bb63d2..928c465 100644 --- a/.gitignore +++ b/.gitignore @@ -11,5 +11,4 @@ state.json chatexamples.txt server.cert server.key -www/nonfree/* -www/nonfree/README.md \ No newline at end of file +www/nonfree/* \ No newline at end of file diff --git a/src/views/partial/panels/settings.ejs b/src/views/partial/panels/settings.ejs index ac3cb93..5fd29d7 100644 --- a/src/views/partial/panels/settings.ejs +++ b/src/views/partial/panels/settings.ejs @@ -45,5 +45,26 @@ along with this program. If not, see . %>

Aspect-Ratio Lock Chat Width Minimum:

+

Notification Settings

+ +

Play Sound for received PMs:

+ +
+ +

Play sound for sent PMs:

+ +
+ +

Play sound on new PM sesh:

+ +
+ +

Play sound on PM sesh end:

+ +
\ No newline at end of file diff --git a/www/js/channel/channel.js b/www/js/channel/channel.js index 523cf6b..9198ee1 100644 --- a/www/js/channel/channel.js +++ b/www/js/channel/channel.js @@ -313,7 +313,11 @@ class channel{ ["syncDelta", 6], ["chatWidthMin", 20], ["userlistHidden", false], - ["cinemaMode", false] + ["cinemaMode", false], + ["rxPMSound", 'unread'], + ["txPMSound", false], + ["newSeshSound", true], + ["endSeshSound", true] ]); } diff --git a/www/js/channel/panels/settingsPanel.js b/www/js/channel/panels/settingsPanel.js index 976afee..d3949ef 100644 --- a/www/js/channel/panels/settingsPanel.js +++ b/www/js/channel/panels/settingsPanel.js @@ -62,6 +62,26 @@ class settingsPanel extends panelObj{ */ this.chatWidthMinimum = this.panelDocument.querySelector("#settings-panel-min-chat-width input"); + /** + * Audible Ping on PM Recieved + */ + this.rxPMSound = this.panelDocument.querySelector("#settings-panel-ping-on-pm-rx select"); + + /** + * Audible Ping on PM Transmit + */ + this.txPMSound = this.panelDocument.querySelector("#settings-panel-ping-on-pm-tx input"); + + /** + * Audible Ping on new PM sesh + */ + this.newSeshSound = this.panelDocument.querySelector("#settings-panel-ping-on-new-sesh input"); + + /** + * Audible Ping on old PM sesh + */ + this.endSeshSound = this.panelDocument.querySelector("#settings-panel-ping-on-end-sesh input"); + this.renderSettings(); this.setupInput(); @@ -79,6 +99,10 @@ class settingsPanel extends panelObj{ this.liveSyncTolerance.addEventListener('change', this.updateLiveSyncTolerance.bind(this)); this.syncDelta.addEventListener('change', this.updateSyncDelta.bind(this)); this.chatWidthMinimum.addEventListener('change', this.updateChatWidthMinimum.bind(this)); + this.rxPMSound.addEventListener('change', this.updateRXPMSound.bind(this)); + this.txPMSound.addEventListener('change', this.updateTXPMSound.bind(this)); + this.newSeshSound.addEventListener('change', this.updateNewPMSeshSound.bind(this)); + this.endSeshSound.addEventListener('change', this.updateEndPMSeshSound.bind(this)); } /** @@ -91,6 +115,10 @@ class settingsPanel extends panelObj{ this.liveSyncTolerance.value = localStorage.getItem("liveSyncTolerance"); this.syncDelta.value = localStorage.getItem("syncDelta"); this.chatWidthMinimum.value = localStorage.getItem("chatWidthMin"); + this.rxPMSound.value = localStorage.getItem('rxPMSound'); + this.txPMSound.checked = localStorage.getItem('txPMSound') == 'true'; + this.newSeshSound.checked = localStorage.getItem('newSeshSound') == 'true'; + this.endSeshSound.checked = localStorage.getItem('endSeshSound') == 'true'; } /** @@ -189,4 +217,32 @@ class settingsPanel extends panelObj{ localStorage.setItem("chatWidthMin", this.chatWidthMinimum.value); client.processConfig("chatWidthMin", this.chatWidthMinimum.value); } + + /** + * Handles changes to RX PM Sound setting + */ + updateRXPMSound(){ + localStorage.setItem('rxPMSound', this.rxPMSound.value); + } + + /** + * Handles changes to TX PM Sound setting + */ + updateTXPMSound(){ + localStorage.setItem('txPMSound', this.txPMSound.checked); + } + + /** + * Handles changes to New PM Sesh Sound setting + */ + updateNewPMSeshSound(){ + localStorage.setItem('newSeshSound', this.newSeshSound.checked); + } + + /** + * Handles changes to Old PM Sesh Sound setting + */ + updateEndPMSeshSound(){ + localStorage.setItem('endSeshSound', this.endSeshSound.checked); + } } \ No newline at end of file From e85fb18ce507d64abcaa5c56a175af216a2af251 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Mon, 6 Oct 2025 21:21:54 -0400 Subject: [PATCH 26/92] Messages now play notification sounds. --- www/js/channel/panels/pmPanel.js | 16 ++++++++++++++++ www/js/channel/pmHandler.js | 32 ++++++++++++++++++++++++++++++-- www/js/utils.js | 5 +++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/www/js/channel/panels/pmPanel.js b/www/js/channel/panels/pmPanel.js index b908858..041ec5c 100644 --- a/www/js/channel/panels/pmPanel.js +++ b/www/js/channel/panels/pmPanel.js @@ -37,6 +37,11 @@ class pmPanel extends panelObj{ */ this.uuid = crypto.randomUUID(); + /** + * PM TX Sound + */ + this.txSound = '/nonfree/imsend.ogg'; + //Tell PMHandler to start tracking this panel this.client.pmHandler.panelList.set(this.uuid, null); @@ -131,6 +136,10 @@ class pmPanel extends panelObj{ //Send message out to server this.client.pmSocket.emit("pm", preprocessedMessage); + + if(localStorage.getItem('txPMSound') == 'true'){ + utils.ux.playSound(this.txSound); + } } //Clear our prompt @@ -247,6 +256,13 @@ class pmPanel extends panelObj{ * @param {Object} message - Message to render */ renderMessage(message){ + //If we have an empty message + console.log(message); + if(message.msg == null || message.msg == ''){ + //BAIL!! + return; + } + //Run postprocessing functions on chat message const postprocessedMessage = client.chatBox.chatPostprocessor.postprocess(message, true); diff --git a/www/js/channel/pmHandler.js b/www/js/channel/pmHandler.js index 6553edb..aaa887d 100644 --- a/www/js/channel/pmHandler.js +++ b/www/js/channel/pmHandler.js @@ -43,6 +43,21 @@ class pmHandler{ */ this.panelList = new Map(); + /** + * PM RX Sound + */ + this.rxSound = '/nonfree/imrecv.ogg'; + + /** + * Open Sesh Sound + */ + this.openSeshSound = '/nonfree/opensesh.ogg'; + + /** + * End Sesh Sound + */ + this.endSeshSound = '/nonfree/closesesh.ogg'; + this.defineListeners(); this.setupInput(); } @@ -118,17 +133,30 @@ class pmHandler{ //Generate a new sesh const sesh = new pmSesh(data, client); + //Notify user of new message/sesh - this.handlePing(); + this.handlePing((data.msg == '' || data.msg == null)); //Add it to the sesh list this.seshList.set(sesh.id, sesh); } + + //If this isn't an empty message (sesh-starter), and PM's always make noise, and we didn't send the message + if(data.msg != '' && data.msg != null && localStorage.getItem('rxPMSound') == 'all' && data.user != this.client.user.user){ + //make sum noize! + utils.ux.playSound(this.rxSound); + } } - handlePing(){ + handlePing(newSesh = false){ //Light up the icon this.pmIcon.classList.add('positive-low'); + + if(newSesh && (localStorage.getItem('newSeshSound') == 'true')){ + utils.ux.playSound(this.openSeshSound); + }else if(localStorage.getItem('rxPMSound') == 'unread'){ + utils.ux.playSound(this.rxSound); + } } //Handles UI updates after reading all messages diff --git a/www/js/utils.js b/www/js/utils.js index c8d7508..9f652b1 100644 --- a/www/js/utils.js +++ b/www/js/utils.js @@ -297,6 +297,11 @@ class canopyUXUtils{ } + playSound(url){ + const audio = new Audio(url); + audio.play(); + } + newTableRow(cellContent){ //Create an empty table row to hold the cells const entryRow = document.createElement('tr'); From 64348b848605edde1b015d3d3f0e4966f4f7378d Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Tue, 7 Oct 2025 01:00:13 -0400 Subject: [PATCH 27/92] Sesh titles now glow while unread, fixed chat icon un-lighting pre-emptively. --- www/css/theme/movie-night.css | 8 ++++++++ www/js/channel/panels/pmPanel.js | 17 ++++++++--------- www/js/channel/pmHandler.js | 6 +++--- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/www/css/theme/movie-night.css b/www/css/theme/movie-night.css index 439002d..0f517b3 100644 --- a/www/css/theme/movie-night.css +++ b/www/css/theme/movie-night.css @@ -166,8 +166,16 @@ textarea{ text-shadow: var(--focus-glow0); } +.positive-inverse{ + color: var(--focus0); + text-shadow: var(--focus-glow0-alt0); +} + .positive-low{ color: var(--focus0); +} + +.positive-afterglow{ text-shadow: var(--focus-glow0-alt0); } diff --git a/www/js/channel/panels/pmPanel.js b/www/js/channel/panels/pmPanel.js index 041ec5c..92e44ef 100644 --- a/www/js/channel/panels/pmPanel.js +++ b/www/js/channel/panels/pmPanel.js @@ -106,14 +106,8 @@ class pmPanel extends panelObj{ //Render out the newest message this.renderMessage(data); }else{ - //pull current session entry if it exists - const curEntry = this.panelDocument.querySelector(`[data-id="${nameObj.name}"]`); - - //If it doesn't exist - if(curEntry == null){ - //Re-render out the sesh list - this.renderSeshList(); - } + //Re-render out the sesh list + this.renderSeshList(); } } @@ -194,6 +188,9 @@ class pmPanel extends panelObj{ if(sesh.id == this.activeSesh){ //mark it as such entryDiv.classList.add('positive'); + //If it contains something unread + }else if(sesh.unread){ + entryDiv.classList.add('positive-afterglow'); } //Create sesh label @@ -232,6 +229,9 @@ class pmPanel extends panelObj{ //Re-render message buffer this.renderMessages(); + + //Re-Render Sesh List + this.renderSeshList(); } renderMessages(){ @@ -257,7 +257,6 @@ class pmPanel extends panelObj{ */ renderMessage(message){ //If we have an empty message - console.log(message); if(message.msg == null || message.msg == ''){ //BAIL!! return; diff --git a/www/js/channel/pmHandler.js b/www/js/channel/pmHandler.js index aaa887d..3ff3c5b 100644 --- a/www/js/channel/pmHandler.js +++ b/www/js/channel/pmHandler.js @@ -150,7 +150,7 @@ class pmHandler{ handlePing(newSesh = false){ //Light up the icon - this.pmIcon.classList.add('positive-low'); + this.pmIcon.classList.add('positive-inverse'); if(newSesh && (localStorage.getItem('newSeshSound') == 'true')){ utils.ux.playSound(this.openSeshSound); @@ -164,14 +164,14 @@ class pmHandler{ //For each sesh for(const sesh of this.seshList){ //If a sesh is unread - if(sesh.unread){ + if(sesh[1].unread){ //LOOK OUT BOYS, THIS ONE'S BEEN READ! CHEESE IT! return; } } //Unlight the icon - this.pmIcon.classList.remove('positive-low'); + this.pmIcon.classList.remove('positive-inverse'); } readSesh(panelID, seshID){ From 3d2b40b3c8334377850d180cb66b1eccbce1c191 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Tue, 7 Oct 2025 01:14:08 -0400 Subject: [PATCH 28/92] Upgraded IP-Hashing Algorithm to SHA-512 --- src/utils/hashUtils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/hashUtils.js b/src/utils/hashUtils.js index 52616c6..082cd73 100644 --- a/src/utils/hashUtils.js +++ b/src/utils/hashUtils.js @@ -52,7 +52,7 @@ module.exports.comparePassword = function(pass, hash){ */ module.exports.hashIP = function(ip){ //Create hash object - const hashObj = crypto.createHash('md5'); + const hashObj = crypto.createHash('sha512'); //add IP and salt to the hash hashObj.update(`${ip}${config.ipSecret}`); From 4698ba4122c3cc2e1e6592b8a295e7f9bc7b696f Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Tue, 7 Oct 2025 03:17:16 -0400 Subject: [PATCH 29/92] Added auto-scrolling to private message panel. Fixed auto-scrolling w/ laggy assets in chat and PM. --- www/js/channel/chat.js | 10 +++ www/js/channel/panels/pmPanel.js | 118 +++++++++++++++++++++++++++++-- 2 files changed, 122 insertions(+), 6 deletions(-) diff --git a/www/js/channel/chat.js b/www/js/channel/chat.js index b9aa843..e733bdf 100644 --- a/www/js/channel/chat.js +++ b/www/js/channel/chat.js @@ -145,6 +145,12 @@ class chatBox{ */ this.showChatIcon = document.querySelector("#media-panel-show-chat-icon"); + /** + * re-occuring auto-scroll call + * cope for the fact that postprocessedMessage objects in renderMessage aren't throwing load events + */ + this.scrollInterval = setInterval(this.handleAutoScroll.bind(this), 200); + //Setup functions this.setupInput(); this.defineListeners(); @@ -567,11 +573,15 @@ class chatBox{ const bufferHeight = Math.round(bufferRect.height); const bufferWidth = Math.round(bufferRect.width); + //If last height was unset if(this.lastHeight == 0){ + //Set it based on buffer Height this.lastHeight = bufferHeight; } + //if last width is unset if(this.lastWidth == 0){ + //Set it based on buffer width this.lastWidth = bufferWidth; } diff --git a/www/js/channel/panels/pmPanel.js b/www/js/channel/panels/pmPanel.js index 92e44ef..7751d59 100644 --- a/www/js/channel/panels/pmPanel.js +++ b/www/js/channel/panels/pmPanel.js @@ -42,6 +42,32 @@ class pmPanel extends panelObj{ */ this.txSound = '/nonfree/imsend.ogg'; + /** + * Message Buffer Scroll Top on last scroll + */ + this.lastPos = 0; + + /** + * Height of Message Buffer on last scroll + */ + this.lastHeight = 0; + + /** + * Width of Message Buffer on last scroll + */ + this.lastWidth = 0; + + /** + * Whether or not auto-scroll is enabled + */ + this.autoScroll = true; + + /** + * re-occuring auto-scroll call + * cope for the fact that postprocessedMessage objects in renderMessage aren't throwing load events + */ + this.scrollInterval = setInterval(this.handleAutoScroll.bind(this), 200); + //Tell PMHandler to start tracking this panel this.client.pmHandler.panelList.set(this.uuid, null); @@ -49,20 +75,29 @@ class pmPanel extends panelObj{ } closer(){ - //Tell PMHandler to start tracking this panel + //Tell PMHandler to stop tracking this panel this.client.pmHandler.panelList.delete(this.uuid); + //Clear the scroll interval + clearInterval(this.scrollInterval); + //Run derived closer super.closer(); } - docSwitch(){ + async docSwitch(){ + //Call derived method + super.docSwitch(); + this.startSeshButton = this.panelDocument.querySelector('#pm-panel-start-sesh'); this.seshList = this.panelDocument.querySelector('#pm-panel-sesh-list'); this.seshBuffer = this.panelDocument.querySelector('#pm-panel-sesh-buffer'); this.seshPrompt = this.panelDocument.querySelector('#pm-panel-message-prompt'); this.seshSendButton = this.panelDocument.querySelector('#pm-panel-send-button'); + //reset auto-scroll + this.autoScroll = true; + this.setupInput(); this.renderSeshList(); @@ -72,9 +107,6 @@ class pmPanel extends panelObj{ //Render messages this.renderMessages(); } - - //Call derived method - super.docSwitch(); } /** @@ -92,6 +124,9 @@ class pmPanel extends panelObj{ this.startSeshButton.addEventListener('click', this.startSesh.bind(this)); this.seshPrompt.addEventListener("keydown", this.send.bind(this)); this.seshSendButton.addEventListener("click", this.send.bind(this)); + this.seshBuffer.addEventListener('scroll', this.scrollHandler.bind(this)); + this.ownerDoc.defaultView.addEventListener('resize', this.handleAutoScroll.bind(this)); + } startSesh(event){ @@ -227,6 +262,9 @@ class pmPanel extends panelObj{ //Tell PMHandler what sesh we have open for notification reasons this.client.pmHandler.readSesh(this.uuid, this.activeSesh); + //Reset auto scroll to scroll newly selected sesh down to the bottom + this.autoScroll = true; + //Re-render message buffer this.renderMessages(); @@ -255,7 +293,7 @@ class pmPanel extends panelObj{ * Renders message out to PM Panel Message Buffer * @param {Object} message - Message to render */ - renderMessage(message){ + async renderMessage(message){ //If we have an empty message if(message.msg == null || message.msg == ''){ //BAIL!! @@ -267,6 +305,74 @@ class pmPanel extends panelObj{ //Append message to buffer this.seshBuffer.appendChild(postprocessedMessage); + + //Auto-scroll buffer on content load + this.handleAutoScroll(); + } + + /** + * Handles scrolling within the message buffer + * @param {Event} event - Event passed down from Event Handler + */ + scrollHandler(event){ + //If we're just starting out + if(this.lastPos == 0){ + //Set last pos for the first time + this.lastPos = this.seshBuffer.scrollTop; + } + + //Calculate scroll delta + const deltaY = this.seshBuffer.scrollTop - this.lastPos; + + //Grab visible bounding rect so we don't have to do it again (can't use offset because someone might zoom in :P) + const bufferRect = this.seshBuffer.getBoundingClientRect(); + const bufferHeight = Math.round(bufferRect.height); + const bufferWidth = Math.round(bufferRect.width); + + //If last height was unset + if(this.lastHeight == 0){ + //Set it based on buffer Height + this.lastHeight = bufferHeight; + } + + //if last width is unset + if(this.lastWidth == 0){ + //Set it based on buffer width + this.lastWidth = bufferWidth; + } + + //If we're scrolling up + if(deltaY < 0){ + //If we have room to scroll, and we didn't resize + if(this.seshBuffer.scrollHeight > bufferHeight && (this.lastWidth == bufferWidth && this.lastHeight == bufferHeight)){ + //Disable auto scrolling + this.autoScroll = false; + //We probably resized + }else{ + this.handleAutoScroll(); + } + //Otherwise if the difference between the message buffers scroll height and offset height is equal to the scroll top + //(Because it is scrolled all the way down) + }else if((this.seshBuffer.scrollHeight - bufferHeight) == this.seshBuffer.scrollTop){ + this.autoScroll = true; + } + + //Set last post/size for next the run + this.lastPos = this.seshBuffer.scrollTop; + this.lastHeight = bufferHeight; + this.lastWidth = bufferWidth; + } + + /** + // * Auto-scrolls sesh chat buffer when new chats are entered. + */ + handleAutoScroll(){ + //If autoscroll is enabled + if(this.autoScroll){ + console.log("SCROLLME"); + //Set seshBuffer scrollTop to the difference between scrollHeight and buffer height (scroll to the bottom) + this.seshBuffer.scrollTop = this.seshBuffer.scrollHeight - Math.round(this.seshBuffer.getBoundingClientRect().height); + } } } From 9fda308306178860b0d224a5d58de31f9351a568 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Thu, 9 Oct 2025 03:50:05 -0400 Subject: [PATCH 30/92] Started work on legacy account migration. --- .gitignore | 3 +- config.example.json | 1 + config.example.jsonc | 4 + src/schemas/user/migrationSchema.js | 190 ++++++++++++++++++++++++++++ src/server.js | 4 + 5 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 src/schemas/user/migrationSchema.js diff --git a/.gitignore b/.gitignore index 928c465..9868f22 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ state.json chatexamples.txt server.cert server.key -www/nonfree/* \ No newline at end of file +www/nonfree/* +migration/* \ No newline at end of file diff --git a/config.example.json b/config.example.json index 22c388c..372a5ac 100644 --- a/config.example.json +++ b/config.example.json @@ -9,6 +9,7 @@ "sessionSecret": "CHANGE_ME", "altchaSecret": "CHANGE_ME", "ipSecret": "CHANGE_ME", + "migrate": false, "ssl":{ "cert": "./server.cert", "key": "./server.key" diff --git a/config.example.jsonc b/config.example.jsonc index 544e906..fea8c16 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -24,6 +24,10 @@ "altchaSecret": "CHANGE_ME", //IP Secret used to salt IP Hashes "ipSecret": "CHANGE_ME", + //Enable to migrate legacy DB and toke files dumped into the ./migration/ directory + //WARNING: The migration folder is cleared after server boot, whether or not a migration took place or this option is enabled. + //Keep your backups in a safe place, preferably a machine that DOESN'T have open inbound ports exposed to the internet/a publically accessible reverse proxy! + "migrate": false, //SSL cert and key locations "ssl":{ "cert": "./server.cert", diff --git a/src/schemas/user/migrationSchema.js b/src/schemas/user/migrationSchema.js new file mode 100644 index 0000000..6c4bad9 --- /dev/null +++ b/src/schemas/user/migrationSchema.js @@ -0,0 +1,190 @@ +/*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 .*/ + +//Node Imports +const fs = require('node:fs/promises'); + +//NPM Imports +const {mongoose} = require('mongoose'); + +//local imports +const config = require('../../../config.json'); +const {userModel} = require('../user/userSchema'); +const permissionModel = require('../permissionSchema'); + + +/** + * DB Schema for documents representing legacy fore.st migration data for a single user account + */ +const migrationSchema = new mongoose.Schema({ + user:{ + type: mongoose.SchemaTypes.String, + unique: true, + required: true + }, + pass: { + type: mongoose.SchemaTypes.String, + required: true + }, + rank: { + type: mongoose.SchemaTypes.Number, + required: true + }, + email: { + type: mongoose.SchemaTypes.String, + default: '' + }, + bio: { + type: mongoose.SchemaTypes.String, + default: 'Bio not set!' + }, + image: { + type: mongoose.SchemaTypes.String, + default: "/nonfree/johnny.png" + }, + date: { + type: mongoose.SchemaTypes.Date, + required: true + }, + tokes: { + type: mongoose.SchemaTypes.Map, + default: new Map(), + required: true + }, +}); + +//statics +/** + * Static method for ingesting data dump from legacy cytube/fore.st server + */ +migrationSchema.statics.ingestLegacyDump = async function(){ + //If migration is disabled + if(!config.migrate){ + //BAIL! + return; + } + + //Crash directory + const dir = "./migration/" + const userDump = `${dir}users.sql` + + //Double check migration files + try{ + //Pull dump stats + await fs.stat(userDump); + //If we caught an error (most likely it's missing) + }catch(err){ + //BAIL! + return; + } + + //Pull raw dump from file + const rawDump = await fs.readFile(userDump, 'binary'); + + //Split dump by line + const splitDump = rawDump.split('\n'); + + //For each line in the user dump + for(const line of splitDump){ + //Ingest the legacy user profile + this.ingestLegacyUser(line); + } +} + +/** + * Ingests a single line containing a single profile out of an .sql data dump from a legacy cytube/fore.st server + * @param {String} rawProfile - Line of text contianing raw profile dump + */ +migrationSchema.statics.ingestLegacyUser = async function(rawProfile){ + //If migration is disabled + if(!config.migrate){ + //BAIL! + return; + } + + //Filter out the entry from any extra guff on the line + const profileMatches = rawProfile.match(/^\((.*?(?=,),){9}.*?(?=\))\)/g); + + //If we have an invalid line + if(profileMatches <= 0){ + //BAIL! + return; + } + + //Set filtered profile to the match we found + let filteredProfile = profileMatches[0]; + + //cook the filtered profile in order to trick the JSON interpreter into thinking it's an array + filteredProfile = `[${filteredProfile.substring(1, filteredProfile.length - 1)}]`; + + //Replace single qoutes with double to match JSON strings + filteredProfile = filteredProfile.replaceAll(",'",',"'); + filteredProfile = filteredProfile.replaceAll("',",'",'); + + //Make sure single qoutes are escaped + filteredProfile = filteredProfile.replaceAll("\'",'\\\''); + + + //Dupe the JSON interpreter like the rube that it is + const profileArray = JSON.parse(filteredProfile); + + //If profile array is the wrong length + if(profileArray.length != 10){ + //BAIL! + return; + } + + //Look for user in migration table + const foundMigration = await this.findOne({user:profileArray[1]}); + const foundUser = await userModel.findOne({user: profileArray[1]}); + + //If we found the user in the database + if(foundMigration != null || foundUser != null){ + //Scream + console.log(`Found legacy user ${profileArray[1]} in database, skipping migration!`); + //BAIL! + return; + } + + + //Create migration profile object from scraped info + const migrationProfile = new this({ + user: profileArray[1], + pass: profileArray[2], + //Clamp rank to 0 and the max setting allowed by the rank enum + rank: Math.min(Math.max(0, profileArray[3]), permissionModel.rankEnum.length - 1), + email: profileArray[4], + date: profileArray[7], + }) + + //If our profile array isn't empty + if(profileArray[5] != ''){ + //Make sure single qoutes are escaped, and parse bio JSON + const bioObject = JSON.parse(profileArray[5].replaceAll("\'",'\\\'')); + + //Inject bio information into migration profile, only if they're present; + migrationProfile.bio = bioObject.text == '' ? undefined : bioObject.text; + migrationProfile.image = bioObject.image == '' ? undefined : bioObject.image; + } + + //Build DB Doc from migration Profile hashtable and dump it into the DB + await this.create(migrationProfile); + + //Let the world know of our triumph! + console.log(`Legacy user profile ${migrationProfile.user} migrated successfully!`); +} + +module.exports = mongoose.model("migration", migrationSchema); \ No newline at end of file diff --git a/src/server.js b/src/server.js index 091bbe0..8a13955 100644 --- a/src/server.js +++ b/src/server.js @@ -43,6 +43,7 @@ const statModel = require('./schemas/statSchema'); const flairModel = require('./schemas/flairSchema'); const emoteModel = require('./schemas/emoteSchema'); const tokeCommandModel = require('./schemas/tokebot/tokeCommandSchema'); +const migrationModel = require('./schemas/user/migrationSchema'); //Controller const fileNotFoundController = require('./controllers/404Controller'); //Router @@ -192,6 +193,9 @@ emoteModel.loadDefaults(); //Load default toke commands tokeCommandModel.loadDefaults(); +//Run legacy migration +migrationModel.ingestLegacyDump(); + //Kick off scheduled-jobs scheduler.kickoff(); From ad0dd6bdbba66cac23075bdafde11fb1e001c385 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Thu, 9 Oct 2025 18:02:04 -0400 Subject: [PATCH 31/92] Added better error checking to migration schema statics. --- src/schemas/user/migrationSchema.js | 223 +++++++++++++++------------- 1 file changed, 120 insertions(+), 103 deletions(-) diff --git a/src/schemas/user/migrationSchema.js b/src/schemas/user/migrationSchema.js index 6c4bad9..aab54bb 100644 --- a/src/schemas/user/migrationSchema.js +++ b/src/schemas/user/migrationSchema.js @@ -24,6 +24,7 @@ const {mongoose} = require('mongoose'); const config = require('../../../config.json'); const {userModel} = require('../user/userSchema'); const permissionModel = require('../permissionSchema'); +const loggerUtils = require('../../utils/loggerUtils'); /** @@ -66,41 +67,49 @@ const migrationSchema = new mongoose.Schema({ }, }); +//TODO: before next commit, add error checking to the ingestLegacy statics down below +//Also add a warning for the fail condition in ingestLegacyDump that bails out when missing files + //statics /** * Static method for ingesting data dump from legacy cytube/fore.st server */ migrationSchema.statics.ingestLegacyDump = async function(){ - //If migration is disabled - if(!config.migrate){ - //BAIL! - return; - } - - //Crash directory - const dir = "./migration/" - const userDump = `${dir}users.sql` - - //Double check migration files try{ - //Pull dump stats - await fs.stat(userDump); - //If we caught an error (most likely it's missing) + //If migration is disabled + if(!config.migrate){ + //BAIL! + return; + } + + //Crash directory + const dir = "./migration/" + const userDump = `${dir}users.sql` + + //Double check migration files + try{ + //Pull dump stats + await fs.stat(userDump); + //If we caught an error (most likely it's missing) + }catch(err){ + loggerUtils.consoleWarn("No migration files detected! Pleas provide legacy migration files or disable migration from config.json!"); + //BAIL! + return; + } + + //Pull raw dump from file + const rawDump = await fs.readFile(userDump, 'binary'); + + //Split dump by line + const splitDump = rawDump.split('\n'); + + //For each line in the user dump + for(const line of splitDump){ + //Ingest the legacy user profile + this.ingestLegacyUser(line); + } }catch(err){ - //BAIL! - return; - } - - //Pull raw dump from file - const rawDump = await fs.readFile(userDump, 'binary'); - - //Split dump by line - const splitDump = rawDump.split('\n'); - - //For each line in the user dump - for(const line of splitDump){ - //Ingest the legacy user profile - this.ingestLegacyUser(line); + return loggerUtils.localExceptionHandler(err); } } @@ -109,82 +118,90 @@ migrationSchema.statics.ingestLegacyDump = async function(){ * @param {String} rawProfile - Line of text contianing raw profile dump */ migrationSchema.statics.ingestLegacyUser = async function(rawProfile){ - //If migration is disabled - if(!config.migrate){ - //BAIL! - return; + try{ + //If migration is disabled + if(!config.migrate){ + //BAIL! + return; + } + + //Filter out the entry from any extra guff on the line + const profileMatches = rawProfile.match(/^\((.*?(?=,),){9}.*?(?=\))\)/g); + + //If we have an invalid line + if(profileMatches <= 0){ + loggerUtils.consoleWarn('Bad profile detected in legacy dump:'); + loggerUtils.consoleWarn(rawProfile); + //BAIL! + return; + } + + //Set filtered profile to the match we found + let filteredProfile = profileMatches[0]; + + //cook the filtered profile in order to trick the JSON interpreter into thinking it's an array + filteredProfile = `[${filteredProfile.substring(1, filteredProfile.length - 1)}]`; + + //Replace single qoutes with double to match JSON strings + filteredProfile = filteredProfile.replaceAll(",'",',"'); + filteredProfile = filteredProfile.replaceAll("',",'",'); + + //Make sure single qoutes are escaped + filteredProfile = filteredProfile.replaceAll("\'",'\\\''); + + + //Dupe the JSON interpreter like the rube that it is + const profileArray = JSON.parse(filteredProfile); + + //If profile array is the wrong length + if(profileArray.length != 10){ + loggerUtils.consoleWarn('Bad profile detected in legacy dump:'); + loggerUtils.consoleWarn(profileArray); + //BAIL! + return; + } + + //Look for user in migration table + const foundMigration = await this.findOne({user:profileArray[1]}); + const foundUser = await userModel.findOne({user: profileArray[1]}); + + //If we found the user in the database + if(foundMigration != null || foundUser != null){ + //Scream + loggerUtils.consoleWarn(`Found legacy user ${profileArray[1]} in database, skipping migration!`); + //BAIL! + return; + } + + + //Create migration profile object from scraped info + const migrationProfile = new this({ + user: profileArray[1], + pass: profileArray[2], + //Clamp rank to 0 and the max setting allowed by the rank enum + rank: Math.min(Math.max(0, profileArray[3]), permissionModel.rankEnum.length - 1), + email: profileArray[4], + date: profileArray[7], + }) + + //If our profile array isn't empty + if(profileArray[5] != ''){ + //Make sure single qoutes are escaped, and parse bio JSON + const bioObject = JSON.parse(profileArray[5].replaceAll("\'",'\\\'')); + + //Inject bio information into migration profile, only if they're present; + migrationProfile.bio = bioObject.text == '' ? undefined : bioObject.text; + migrationProfile.image = bioObject.image == '' ? undefined : bioObject.image; + } + + //Build DB Doc from migration Profile hashtable and dump it into the DB + await this.create(migrationProfile); + + //Let the world know of our triumph! + console.log(`Legacy user profile ${migrationProfile.user} migrated successfully!`); + }catch(err){ + return loggerUtils.localExceptionHandler(err); } - - //Filter out the entry from any extra guff on the line - const profileMatches = rawProfile.match(/^\((.*?(?=,),){9}.*?(?=\))\)/g); - - //If we have an invalid line - if(profileMatches <= 0){ - //BAIL! - return; - } - - //Set filtered profile to the match we found - let filteredProfile = profileMatches[0]; - - //cook the filtered profile in order to trick the JSON interpreter into thinking it's an array - filteredProfile = `[${filteredProfile.substring(1, filteredProfile.length - 1)}]`; - - //Replace single qoutes with double to match JSON strings - filteredProfile = filteredProfile.replaceAll(",'",',"'); - filteredProfile = filteredProfile.replaceAll("',",'",'); - - //Make sure single qoutes are escaped - filteredProfile = filteredProfile.replaceAll("\'",'\\\''); - - - //Dupe the JSON interpreter like the rube that it is - const profileArray = JSON.parse(filteredProfile); - - //If profile array is the wrong length - if(profileArray.length != 10){ - //BAIL! - return; - } - - //Look for user in migration table - const foundMigration = await this.findOne({user:profileArray[1]}); - const foundUser = await userModel.findOne({user: profileArray[1]}); - - //If we found the user in the database - if(foundMigration != null || foundUser != null){ - //Scream - console.log(`Found legacy user ${profileArray[1]} in database, skipping migration!`); - //BAIL! - return; - } - - - //Create migration profile object from scraped info - const migrationProfile = new this({ - user: profileArray[1], - pass: profileArray[2], - //Clamp rank to 0 and the max setting allowed by the rank enum - rank: Math.min(Math.max(0, profileArray[3]), permissionModel.rankEnum.length - 1), - email: profileArray[4], - date: profileArray[7], - }) - - //If our profile array isn't empty - if(profileArray[5] != ''){ - //Make sure single qoutes are escaped, and parse bio JSON - const bioObject = JSON.parse(profileArray[5].replaceAll("\'",'\\\'')); - - //Inject bio information into migration profile, only if they're present; - migrationProfile.bio = bioObject.text == '' ? undefined : bioObject.text; - migrationProfile.image = bioObject.image == '' ? undefined : bioObject.image; - } - - //Build DB Doc from migration Profile hashtable and dump it into the DB - await this.create(migrationProfile); - - //Let the world know of our triumph! - console.log(`Legacy user profile ${migrationProfile.user} migrated successfully!`); } module.exports = mongoose.model("migration", migrationSchema); \ No newline at end of file From bb2a1369a3c41c7d3e42f135b010c07b09a763fe Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Fri, 10 Oct 2025 08:42:02 -0400 Subject: [PATCH 32/92] Added profile toke count ingestion to migration schema. --- src/schemas/user/migrationSchema.js | 97 ++++++++++++++++++++++++++--- 1 file changed, 89 insertions(+), 8 deletions(-) diff --git a/src/schemas/user/migrationSchema.js b/src/schemas/user/migrationSchema.js index aab54bb..c32b46d 100644 --- a/src/schemas/user/migrationSchema.js +++ b/src/schemas/user/migrationSchema.js @@ -61,10 +61,9 @@ const migrationSchema = new mongoose.Schema({ required: true }, tokes: { - type: mongoose.SchemaTypes.Map, - default: new Map(), - required: true - }, + type: mongoose.SchemaTypes.Number, + default: 0, + } }); //TODO: before next commit, add error checking to the ingestLegacy statics down below @@ -82,20 +81,28 @@ migrationSchema.statics.ingestLegacyDump = async function(){ return; } - //Crash directory + //Migration directories/file const dir = "./migration/" const userDump = `${dir}users.sql` + const tokeDir = `./migration/tokebot/` + + //Create array to hold list of toke dump files + let tokeDumps = []; //Double check migration files try{ //Pull dump stats await fs.stat(userDump); + + //Pull toke related files + tokeDumps = await fs.readdir(tokeDir) + //If we caught an error (most likely it's missing) }catch(err){ loggerUtils.consoleWarn("No migration files detected! Pleas provide legacy migration files or disable migration from config.json!"); //BAIL! return; - } + } //Pull raw dump from file const rawDump = await fs.readFile(userDump, 'binary'); @@ -106,8 +113,34 @@ migrationSchema.statics.ingestLegacyDump = async function(){ //For each line in the user dump for(const line of splitDump){ //Ingest the legacy user profile - this.ingestLegacyUser(line); + //Waiting on this is a lot less effecient... + //But I'm too lazy to write a while loop that waits on every promise to return gracefully to make something that will run like once preform better. + await this.ingestLegacyUser(line); } + + + //Create arrays to hold toke dumps contents + const tokeMaps = []; + const tokeLogs = []; + + //For every toke related file + for(const file of tokeDumps){ + //Read toke related file + const rawContents = await fs.readFile(`${tokeDir}${file}`, 'binary'); + + //If its a toke file containing a list of toke counts per profile + if(file.match(/\_tokefile/) != null){ + //Push raw toke map into toke maps array + tokeMaps.push(rawContents); + //If its a toke log containing a list of tokes + }else if(file.match(/\_toke\.log/)){ + //Push file contents into toke log array + tokeLogs.push(rawContents); + } + } + + //Ingest toke maps + this.ingestTokeMaps(tokeMaps); }catch(err){ return loggerUtils.localExceptionHandler(err); } @@ -168,7 +201,7 @@ migrationSchema.statics.ingestLegacyUser = async function(rawProfile){ //If we found the user in the database if(foundMigration != null || foundUser != null){ //Scream - loggerUtils.consoleWarn(`Found legacy user ${profileArray[1]} in database, skipping migration!`); + //loggerUtils.consoleWarn(`Found legacy user ${profileArray[1]} in database, skipping migration!`); //BAIL! return; } @@ -204,4 +237,52 @@ migrationSchema.statics.ingestLegacyUser = async function(rawProfile){ } } +/** + * Ingests array of raw toke map data ripped from the migrations folder and injects it on-top of the existing migration profile collection in the DB + * @param {Array} rawTokeMaps - List of raw content ripped from legacy cytube/fore.st toke files + */ +migrationSchema.statics.ingestTokeMaps = async function(rawTokeMaps){ + try{ + //If server migration is disabled + if(!config.migrate){ + //BAIL!! + return; + } + + //Create new map to hold total toke count + const tokeMap = new Map(); + + //For each raw map handed to us by the main ingestion method + for(const rawMap of rawTokeMaps){ + //Parse map into dehydrated map array + const dehydratedMap = JSON.parse(rawMap); + + //We don't need to re-hydrate a map we're just going to fucking iterate through like an array... + for(const curCount of dehydratedMap.value){ + //Get current toke count for user + const total = tokeMap.get(curCount[0]); + + //If this user isn't counted + if(total == null || total == 0){ + //Set users toke count to parsed count + tokeMap.set(curCount[0], curCount[1]); + //Otherwise + }else{ + //Add parsed count to users total + tokeMap.set(curCount[0], curCount[1] + total); + } + } + } + + //For each toking user + for(const toker of tokeMap){ + //Update migration profile to include total tokes + await this.updateOne({user: toker[0]},{$set:{tokes: toker[1]}}); + console.log(`${toker[1]} tokes injected into user profile ${toker[0]}!`); + } + }catch(err){ + return loggerUtils.localExceptionHandler(err); + } +} + module.exports = mongoose.model("migration", migrationSchema); \ No newline at end of file From a231c8fc4c8b2848f196235a397e389fe6e63d0e Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Sat, 11 Oct 2025 08:19:53 -0400 Subject: [PATCH 33/92] Started server-wide legacy cytube/fore.st toke count ingestion. --- config.example.json | 1 + config.example.jsonc | 4 + src/schemas/statSchema.js | 98 ++++++++++++++++++++++++- src/schemas/user/migrationSchema.js | 9 ++- src/views/partial/profile/tokeCount.ejs | 2 +- 5 files changed, 110 insertions(+), 4 deletions(-) diff --git a/config.example.json b/config.example.json index 372a5ac..5e3eb01 100644 --- a/config.example.json +++ b/config.example.json @@ -10,6 +10,7 @@ "altchaSecret": "CHANGE_ME", "ipSecret": "CHANGE_ME", "migrate": false, + "dropLegacyTokes": false, "ssl":{ "cert": "./server.cert", "key": "./server.key" diff --git a/config.example.jsonc b/config.example.jsonc index fea8c16..01eeab1 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -28,6 +28,10 @@ //WARNING: The migration folder is cleared after server boot, whether or not a migration took place or this option is enabled. //Keep your backups in a safe place, preferably a machine that DOESN'T have open inbound ports exposed to the internet/a publically accessible reverse proxy! "migrate": false, + //Drops all legacy tokes out of the statistics file since doing so manually from mongosh is a lot of typing out obnoxious parentha-glyphics. + //Requires migration to be disabled before it takes effect. + //WARNING: this does NOT affect user toke counts, migrated or otherwise. Use carefully! + "dropLegacyTokes": false, //SSL cert and key locations "ssl":{ "cert": "./server.cert", diff --git a/src/schemas/statSchema.js b/src/schemas/statSchema.js index f0a2c3f..f615703 100644 --- a/src/schemas/statSchema.js +++ b/src/schemas/statSchema.js @@ -187,7 +187,7 @@ statSchema.statics.getTokeCommandCounts = async function(){ count.set(command, 1); }else{ //Set it to ++curCount - count.set(command, ++curCount); + count.set(command, curCount + 1); } }); }); @@ -196,4 +196,100 @@ statSchema.statics.getTokeCommandCounts = async function(){ return count; } +/** + * Ingests legacy tokes handed over by the migration model + * @param {Array} rawLegacyTokes - List of strings containing contents of legacy cytube/fore.st toke logs + */ +statSchema.statics.ingestLegacyTokes = async function(rawLegacyTokes){ + //If migration is disabled + if(!config.migrate){ + //BAIL! + return; + } + + try{ + const statDB = await this.getStats(); + + //For each toke log + for(const tokeLog of rawLegacyTokes){ + //Split and iterate toke log by new line + for(const tokeLine of tokeLog.split('\n')){ + //Ensure line is a valid toke log line (this will break if your tokes take place after 12:46:40PM on Nov 20th 2286... Or before 21:46:40 Sep 08 2001) + //You'll probably want to have migrated from cytube/fore.st to canopy by then :) + //Also splits tokers array off for easier processing + const splitToke = tokeLine.match(/^\[.+\]|,[0-9]{1,4},|[0-9]{13}$/g) + if(splitToke != null){ + + //Create empty tokers map + const toke = new Map(); + + //Add qoutes around strings in the tokers line + let tokersLine = splitToke[0].replaceAll('[', '["'); + tokersLine = tokersLine.replaceAll(']','"]'); + tokersLine = tokersLine.replaceAll(',','","'); + + //Force feed doctored line into the JSON parser, and iterate by the array it shits out + for(const toker of JSON.parse(tokersLine)){ + toke.set(toker,"Legacy Tokes"); + } + + const date = new Date(Number(splitToke[2])); + + //Push toke on to statDB + statDB.tokes.push({ + toke, + date + }); + + console.log(`Adding legacy toke: ${tokersLine} from: ${date.toLocaleString()}`); + } + } + } + + //Save toke to file + await statDB.save(); + + console.log("Legacy tokes commited to server-wide database statistics file!"); + }catch(err){ + return loggerutils.localexceptionhandler(err); + } +} + +statSchema.statics.dropLegacyTokes = async function(){ + try{ + //If legacy toke dropping is disabled or migration is enabled + if(!config.dropLegacyTokes || config.migrate){ + //return + return; + } + + //pull stat doc + const statDB = await this.getStats(); + + //Create temporary toke array + const tokes = []; + + //Iterate through server toke history + for(const toke of statDB.tokes){ + //If it's not a legacy toke + if(Array.from(toke.toke)[0][1] != "Legacy Tokes"){ + //Add it to the temp array + tokes.push(toke); + } + } + + //Replace the server-wide toke log with our newly doctored one + statDB.tokes = tokes; + + //Save the stat document + statDB.save(); + + //Tell of our success + console.log("Removed migration tokes!"); + }catch(err){ + return loggerutils.localexceptionhandler(err); + } + +} + module.exports = mongoose.model("statistics", statSchema); \ No newline at end of file diff --git a/src/schemas/user/migrationSchema.js b/src/schemas/user/migrationSchema.js index c32b46d..1be7b73 100644 --- a/src/schemas/user/migrationSchema.js +++ b/src/schemas/user/migrationSchema.js @@ -24,6 +24,7 @@ const {mongoose} = require('mongoose'); const config = require('../../../config.json'); const {userModel} = require('../user/userSchema'); const permissionModel = require('../permissionSchema'); +const statModel = require('../statSchema'); const loggerUtils = require('../../utils/loggerUtils'); @@ -77,6 +78,7 @@ migrationSchema.statics.ingestLegacyDump = async function(){ try{ //If migration is disabled if(!config.migrate){ + statModel.dropLegacyTokes(); //BAIL! return; } @@ -140,7 +142,10 @@ migrationSchema.statics.ingestLegacyDump = async function(){ } //Ingest toke maps - this.ingestTokeMaps(tokeMaps); + await this.ingestTokeMaps(tokeMaps); + + //Pass toke logs over to the stat model for further ingestion + await statModel.ingestLegacyTokes(tokeLogs); }catch(err){ return loggerUtils.localExceptionHandler(err); } @@ -281,7 +286,7 @@ migrationSchema.statics.ingestTokeMaps = async function(rawTokeMaps){ console.log(`${toker[1]} tokes injected into user profile ${toker[0]}!`); } }catch(err){ - return loggerUtils.localExceptionHandler(err); + return loggerutils.localexceptionhandler(err); } } diff --git a/src/views/partial/profile/tokeCount.ejs b/src/views/partial/profile/tokeCount.ejs index d9a2094..9c1ab81 100644 --- a/src/views/partial/profile/tokeCount.ejs +++ b/src/views/partial/profile/tokeCount.ejs @@ -19,6 +19,6 @@ along with this program. If not, see . %>
<% profile.tokes.forEach((count, toke) => { %> -

!<%- toke %>: <%- count %>

+

<%- toke == "Legacy Tokes" ? '
' : '!' %><%- toke %>: <%- count %>

<% }); %>
\ No newline at end of file From e9b95394778552f01e772507ec4c1c0afbfa07a1 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Sat, 11 Oct 2025 09:38:44 -0400 Subject: [PATCH 34/92] Added completion time to migration procedure. --- src/schemas/user/migrationSchema.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/schemas/user/migrationSchema.js b/src/schemas/user/migrationSchema.js index 1be7b73..8ae54f7 100644 --- a/src/schemas/user/migrationSchema.js +++ b/src/schemas/user/migrationSchema.js @@ -146,6 +146,8 @@ migrationSchema.statics.ingestLegacyDump = async function(){ //Pass toke logs over to the stat model for further ingestion await statModel.ingestLegacyTokes(tokeLogs); + + loggerUtils.consoleWarn(`Legacy Server Migration Completed at: ${new Date().toLocaleString()}`); }catch(err){ return loggerUtils.localExceptionHandler(err); } From 42ad17072f81641180e8b541e78b7ed3e68c43d1 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Sat, 11 Oct 2025 09:43:26 -0400 Subject: [PATCH 35/92] Refactored userSchema.statics.findProfile()'s tokebot profile generation procedure. --- src/schemas/statSchema.js | 78 ++++++++++++++-------------------- src/schemas/user/userSchema.js | 9 ++-- www/nonfree | 1 + 3 files changed, 40 insertions(+), 48 deletions(-) create mode 160000 www/nonfree diff --git a/src/schemas/statSchema.js b/src/schemas/statSchema.js index f615703..ab737a0 100644 --- a/src/schemas/statSchema.js +++ b/src/schemas/statSchema.js @@ -152,50 +152,6 @@ statSchema.statics.tattooToke = async function(toke){ await stats.save(); } -/** - * Gets toke count from statistics document - * @returns {Number} Number of tokes across the entire site - */ -statSchema.statics.getTokeCount = async function(){ - //get stats doc - const stats = await this.getStats(); - - //return toke count - return stats.tokes.length; -} - -/** - * Gets toke counts for each individual callout in a map - * @returns {Map} Map of toke counts for each individual callout registered to the server - */ -statSchema.statics.getTokeCommandCounts = async function(){ - //get stats doc - const stats = await this.getStats() - //Create empty map to hold toke command counts - const count = new Map(); - - //for each toke - stats.tokes.forEach((toke) => { - //For each toke command called in the current toke - toke.toke.forEach((command) => { - //Get the current count for the current command - var curCount = count.get(command); - - //if the current count is null - if(curCount == null){ - //Set it to one - count.set(command, 1); - }else{ - //Set it to ++curCount - count.set(command, curCount + 1); - } - }); - }); - - //return the toke command count - return count; -} - /** * Ingests legacy tokes handed over by the migration model * @param {Array} rawLegacyTokes - List of strings containing contents of legacy cytube/fore.st toke logs @@ -282,7 +238,7 @@ statSchema.statics.dropLegacyTokes = async function(){ statDB.tokes = tokes; //Save the stat document - statDB.save(); + await statDB.save(); //Tell of our success console.log("Removed migration tokes!"); @@ -292,4 +248,36 @@ statSchema.statics.dropLegacyTokes = async function(){ } +//Methods + +/** + * Gets toke counts for each individual callout in a map + * @returns {Map} Map of toke counts for each individual callout registered to the server + */ +statSchema.methods.calculateTokeCommandCounts = async function(){ + //Create empty map to hold toke command counts + const count = new Map(); + + //for each toke + this.tokes.forEach((toke) => { + //For each toke command called in the current toke + toke.toke.forEach((command) => { + //Get the current count for the current command + var curCount = count.get(command); + + //if the current count is null + if(curCount == null){ + //Set it to one + count.set(command, 1); + }else{ + //Set it to ++curCount + count.set(command, curCount + 1); + } + }); + }); + + //return the toke command count + return count; +} + module.exports = mongoose.model("statistics", statSchema); \ No newline at end of file diff --git a/src/schemas/user/userSchema.js b/src/schemas/user/userSchema.js index d456127..bf7af99 100644 --- a/src/schemas/user/userSchema.js +++ b/src/schemas/user/userSchema.js @@ -311,13 +311,16 @@ userSchema.statics.findProfile = async function(user, includeEmail = false){ return null; //If someone's looking for tokebot }else if(user.user.toLowerCase() == "tokebot"){ + //Pull statistics document from the database + const statDB = await statModel.getStats(); + //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(), + date: statDB.firstLaunch, + tokes: await statDB.calculateTokeCommandCounts(), + tokeCount: statDB.tokes.length, img: "/nonfree/johnny.png", signature: "!TOKE", bio: "!TOKE OR DIE!" diff --git a/www/nonfree b/www/nonfree new file mode 160000 index 0000000..8f3f78b --- /dev/null +++ b/www/nonfree @@ -0,0 +1 @@ +Subproject commit 8f3f78be454a156aa7b6a9a811cd656cf4bd80b2 From a1b602efdb32f22d827130cac208d933d6d3ad66 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Sun, 12 Oct 2025 23:48:45 -0400 Subject: [PATCH 36/92] Moved toke log to it's own DB collection w/ cached statistics. Tokebot statistics page-load time decreased by up to 20-30x --- src/app/channel/tokebot.js | 6 +- src/schemas/statSchema.js | 168 ++---------------- src/schemas/tokebot/tokeSchema.js | 219 ++++++++++++++++++++++++ src/schemas/user/migrationSchema.js | 6 +- src/schemas/user/userSchema.js | 11 +- src/server.js | 53 ++++-- src/views/partial/profile/tokeCount.ejs | 7 +- 7 files changed, 281 insertions(+), 189 deletions(-) create mode 100644 src/schemas/tokebot/tokeSchema.js diff --git a/src/app/channel/tokebot.js b/src/app/channel/tokebot.js index 7d1f005..6e41471 100644 --- a/src/app/channel/tokebot.js +++ b/src/app/channel/tokebot.js @@ -16,8 +16,8 @@ along with this program. If not, see .*/ //Local Imports const tokeCommandModel = require('../../schemas/tokebot/tokeCommandSchema'); +const tokeModel = require('../../schemas/tokebot/tokeSchema'); const {userModel} = require('../../schemas/user/userSchema'); -const statSchema = require('../../schemas/statSchema'); /** @@ -190,8 +190,8 @@ class tokebot{ //Asynchronously tattoo the toke into the users documents within the database so that tokebot doesn't have to wait or worry about DB transactions userModel.tattooToke(this.tokers); - //Do the same for the global stat schema - statSchema.tattooToke(this.tokers); + //Do the same for the global toke statistics collection + tokeModel.tattooToke(this.tokers); //Set the toke cooldown this.cooldownCounter = this.cooldownTime; diff --git a/src/schemas/statSchema.js b/src/schemas/statSchema.js index ab737a0..d1af85e 100644 --- a/src/schemas/statSchema.js +++ b/src/schemas/statSchema.js @@ -44,22 +44,16 @@ const statSchema = new mongoose.Schema({ type: mongoose.SchemaTypes.Date, required: true, default: new Date() - }, - tokes: [{ - toke: { - type: mongoose.SchemaTypes.Map, - required: true, - default: new Map() - }, - date: { - type: mongoose.SchemaTypes.Date, - required: true, - default: new Date() - } - }] + } }); //statics + +/** + * Set placeholder variable to hold cached firstLaunch date from stat document + */ +statSchema.statics.firstLaunch = null; + /** * Get's servers sole stat document from the DB * @returns {Mongoose.Document} Server's sole statistics document @@ -67,7 +61,7 @@ const statSchema = new mongoose.Schema({ statSchema.statics.getStats = async function(){ //Get the first document we find var stats = await this.findOne({}); - + if(stats){ //If we found something then the statistics document exist and this is it, //So long as no one else has fucked with the database it should be the only one. (is this forshadowing for a future bug?) @@ -96,6 +90,9 @@ statSchema.statics.incrementLaunchCount = async function(){ stats.launchCount++; stats.save(); + //Cache first launch + this.firstLaunch = stats.firstLaunch; + //print bootup message to console. console.log(`${config.instanceName}(Powered by Canopy) initialized. This server has booted ${stats.launchCount} time${stats.launchCount == 1 ? '' : 's'}.`) console.log(`First booted on ${stats.firstLaunch}.`); @@ -137,147 +134,4 @@ statSchema.statics.incrementChannelCount = async function(){ return oldCount; } -/** - * Tattoo's toke to the server statistics document - * @param {Map} toke - Tokemap handed down from Tokebot - */ -statSchema.statics.tattooToke = async function(toke){ - //Get the statistics document - const stats = await this.getStats(); - - //Add the toke to the stat document - stats.tokes.push({toke}); - - //Save the stat document - await stats.save(); -} - -/** - * Ingests legacy tokes handed over by the migration model - * @param {Array} rawLegacyTokes - List of strings containing contents of legacy cytube/fore.st toke logs - */ -statSchema.statics.ingestLegacyTokes = async function(rawLegacyTokes){ - //If migration is disabled - if(!config.migrate){ - //BAIL! - return; - } - - try{ - const statDB = await this.getStats(); - - //For each toke log - for(const tokeLog of rawLegacyTokes){ - //Split and iterate toke log by new line - for(const tokeLine of tokeLog.split('\n')){ - //Ensure line is a valid toke log line (this will break if your tokes take place after 12:46:40PM on Nov 20th 2286... Or before 21:46:40 Sep 08 2001) - //You'll probably want to have migrated from cytube/fore.st to canopy by then :) - //Also splits tokers array off for easier processing - const splitToke = tokeLine.match(/^\[.+\]|,[0-9]{1,4},|[0-9]{13}$/g) - if(splitToke != null){ - - //Create empty tokers map - const toke = new Map(); - - //Add qoutes around strings in the tokers line - let tokersLine = splitToke[0].replaceAll('[', '["'); - tokersLine = tokersLine.replaceAll(']','"]'); - tokersLine = tokersLine.replaceAll(',','","'); - - //Force feed doctored line into the JSON parser, and iterate by the array it shits out - for(const toker of JSON.parse(tokersLine)){ - toke.set(toker,"Legacy Tokes"); - } - - const date = new Date(Number(splitToke[2])); - - //Push toke on to statDB - statDB.tokes.push({ - toke, - date - }); - - console.log(`Adding legacy toke: ${tokersLine} from: ${date.toLocaleString()}`); - } - } - } - - //Save toke to file - await statDB.save(); - - console.log("Legacy tokes commited to server-wide database statistics file!"); - }catch(err){ - return loggerutils.localexceptionhandler(err); - } -} - -statSchema.statics.dropLegacyTokes = async function(){ - try{ - //If legacy toke dropping is disabled or migration is enabled - if(!config.dropLegacyTokes || config.migrate){ - //return - return; - } - - //pull stat doc - const statDB = await this.getStats(); - - //Create temporary toke array - const tokes = []; - - //Iterate through server toke history - for(const toke of statDB.tokes){ - //If it's not a legacy toke - if(Array.from(toke.toke)[0][1] != "Legacy Tokes"){ - //Add it to the temp array - tokes.push(toke); - } - } - - //Replace the server-wide toke log with our newly doctored one - statDB.tokes = tokes; - - //Save the stat document - await statDB.save(); - - //Tell of our success - console.log("Removed migration tokes!"); - }catch(err){ - return loggerutils.localexceptionhandler(err); - } - -} - -//Methods - -/** - * Gets toke counts for each individual callout in a map - * @returns {Map} Map of toke counts for each individual callout registered to the server - */ -statSchema.methods.calculateTokeCommandCounts = async function(){ - //Create empty map to hold toke command counts - const count = new Map(); - - //for each toke - this.tokes.forEach((toke) => { - //For each toke command called in the current toke - toke.toke.forEach((command) => { - //Get the current count for the current command - var curCount = count.get(command); - - //if the current count is null - if(curCount == null){ - //Set it to one - count.set(command, 1); - }else{ - //Set it to ++curCount - count.set(command, curCount + 1); - } - }); - }); - - //return the toke command count - return count; -} - module.exports = mongoose.model("statistics", statSchema); \ No newline at end of file diff --git a/src/schemas/tokebot/tokeSchema.js b/src/schemas/tokebot/tokeSchema.js new file mode 100644 index 0000000..ca186af --- /dev/null +++ b/src/schemas/tokebot/tokeSchema.js @@ -0,0 +1,219 @@ +/*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 +const config = require('./../../../config.json'); +const loggerUtils = require('../../utils/loggerUtils'); + +/** + * DB Schema for single document for keeping track of a single toke + */ +const tokeSchema = new mongoose.Schema({ + toke: { + type: mongoose.SchemaTypes.Map, + required: true, + default: new Map() + }, + date: { + type: mongoose.SchemaTypes.Date, + required: true, + default: new Date() + } +}); + +//statics +/** + * Cached map containing counts of individual toke commands + */ +tokeSchema.statics.tokeMap = new Map(); + +/** + * Cached number of total tokes + */ +tokeSchema.statics.count = 0; + +/** + * Cached number of times a user has successfully ran a '!toke' command + * Not to be confused with tokeSchema.statics.count, which counts total amount of tokes called out + */ +tokeSchema.statics.commandCount = 0; + +/** + * Calculates cached toke map from existing + */ +tokeSchema.statics.calculateTokeMap = async function(){ + //Pull full toke collection + const tokes = await this.find(); + + //Drop existing toke map + this.tokeMap = new Map(); + + //Iterate through DB of tokes + for(const toke of tokes){ + //Increment toke count + this.count++; + + //For each command callout + for(const command of toke.toke){ + //Increment Command Count + this.commandCount++; + + //Pull current count of respective toke command + let curCount = this.tokeMap.get(command[1]); + + //If this is an unset toke command + if(curCount == null){ + //Set it to one + this.tokeMap.set(command[1], 1); + //Otherwise + }else{ + //Increment the existing count + this.tokeMap.set(command[1], ++curCount); + } + } + } + + //Display calculated toke sats for funsies + if(config.verbose){ + console.log(`Processed ${this.commandCount} toke command callouts accross ${await this.estimatedDocumentCount()} tokes.`); + } +} + +/** + * Tattoos toke into toke DB colleciton + * + * We use this instead of a pre-save function as we need to fuck w/ statics + */ +tokeSchema.statics.tattooToke = async function(toke){ + //Write toke to DB + await this.create({toke}); + + //Increment RAM-backed toke count + this.count++; + + //Iterate through tokers + for(const curToke of toke){ + //Pull current toke count + let curCount = this.tokeMap.get(curToke[1]); + + //If this command hasn't been counted + if(curCount == null){ + //Set new command count to one + this.tokeMap.set(curToke[1], 1); + }else{ + //Increment current toke count and commit it to the RAM-backed tokeMap + this.tokeMap.set(curToke[1], ++curCount); + } + + //Increment RAM-Backed command count + this.commandCount++; + } +} + +/** + * Ingests legacy tokes handed over by the migration model + * @param {Array} rawLegacyTokes - List of strings containing contents of legacy cytube/fore.st toke logs + */ +tokeSchema.statics.ingestLegacyTokes = async function(rawLegacyTokes){ + //If migration is disabled + if(!config.migrate){ + //BAIL! + return; + } + + try{ + //For each toke log + for(const tokeLog of rawLegacyTokes){ + //Split and iterate toke log by new line + for(const tokeLine of tokeLog.split('\n')){ + //Ensure line is a valid toke log line (this will break if your tokes take place after 12:46:40PM on Nov 20th 2286... Or before 21:46:40 Sep 08 2001) + //You'll probably want to have migrated from cytube/fore.st to canopy by then :) + //Also splits tokers array off for easier processing + const splitToke = tokeLine.match(/^\[.+\]|,[0-9]{1,4},|[0-9]{13}$/g) + if(splitToke != null){ + + //Create empty tokers map + const toke = new Map(); + + //Add qoutes around strings in the tokers line + let tokersLine = splitToke[0].replaceAll('[', '["'); + tokersLine = tokersLine.replaceAll(']','"]'); + tokersLine = tokersLine.replaceAll(',','","'); + + //Force feed doctored line into the JSON parser, and iterate by the array it shits out + for(const toker of JSON.parse(tokersLine)){ + toke.set(toker,"Legacy Tokes"); + } + + const date = new Date(Number(splitToke[2])); + + //Push toke on to statDB + this.create({ + toke, + date + }); + + console.log(`Adding legacy toke: ${tokersLine} from: ${date.toLocaleString()}`); + } + } + } + + console.log("Legacy tokes commited to server-wide database statistics file!"); + }catch(err){ + return loggerUtils.localExceptionHandler(err); + } +} + +tokeSchema.statics.dropLegacyTokes = async function(){ + try{ + //If legacy toke dropping is disabled or migration is enabled + if(!config.dropLegacyTokes || config.migrate){ + //return + return; + } + //Pull tokes from DB + const oldTokes = await this.find(); + + //Create temporary toke array + const tokes = []; + + //Nuke the toke collection + await this.deleteMany({}); + + //Iterate through server toke history + for(const toke of oldTokes){ + //If it's not a legacy toke or a dupe + if(Array.from(toke.toke)[0][1] != "Legacy Tokes"){ + //Re-add it to the database, scraping out the old ID + this.create({ + toke: toke.toke, + date: toke.date + }); + } + } + + //Tell of our success + console.log("Removed migration tokes!"); + }catch(err){ + return loggerUtils.localExceptionHandler(err); + } + +} + +module.exports = mongoose.model("toke", tokeSchema); \ No newline at end of file diff --git a/src/schemas/user/migrationSchema.js b/src/schemas/user/migrationSchema.js index 8ae54f7..b03b1dc 100644 --- a/src/schemas/user/migrationSchema.js +++ b/src/schemas/user/migrationSchema.js @@ -24,7 +24,7 @@ const {mongoose} = require('mongoose'); const config = require('../../../config.json'); const {userModel} = require('../user/userSchema'); const permissionModel = require('../permissionSchema'); -const statModel = require('../statSchema'); +const tokeModel = require('../tokebot/tokeSchema'); const loggerUtils = require('../../utils/loggerUtils'); @@ -78,7 +78,7 @@ migrationSchema.statics.ingestLegacyDump = async function(){ try{ //If migration is disabled if(!config.migrate){ - statModel.dropLegacyTokes(); + await tokeModel.dropLegacyTokes(); //BAIL! return; } @@ -145,7 +145,7 @@ migrationSchema.statics.ingestLegacyDump = async function(){ await this.ingestTokeMaps(tokeMaps); //Pass toke logs over to the stat model for further ingestion - await statModel.ingestLegacyTokes(tokeLogs); + await tokeModel.ingestLegacyTokes(tokeLogs); loggerUtils.consoleWarn(`Legacy Server Migration Completed at: ${new Date().toLocaleString()}`); }catch(err){ diff --git a/src/schemas/user/userSchema.js b/src/schemas/user/userSchema.js index bf7af99..dd13b24 100644 --- a/src/schemas/user/userSchema.js +++ b/src/schemas/user/userSchema.js @@ -22,6 +22,7 @@ const {mongoose} = require('mongoose'); const server = require('../../server'); //DB Models const statModel = require('../statSchema'); +const tokeModel = require('../tokebot/tokeSchema'); const flairModel = require('../flairSchema'); const permissionModel = require('../permissionSchema'); const emoteModel = require('../emoteSchema'); @@ -311,16 +312,14 @@ userSchema.statics.findProfile = async function(user, includeEmail = false){ return null; //If someone's looking for tokebot }else if(user.user.toLowerCase() == "tokebot"){ - //Pull statistics document from the database - const statDB = await statModel.getStats(); - //fake a profile hashtable for tokebot const profile = { id: -420, user: "Tokebot", - date: statDB.firstLaunch, - tokes: await statDB.calculateTokeCommandCounts(), - tokeCount: statDB.tokes.length, + //Look ma, no DB calls! + date: statModel.firstLaunch, + tokes: tokeModel.tokeMap, + tokeCount: tokeModel.count, img: "/nonfree/johnny.png", signature: "!TOKE", bio: "!TOKE OR DIE!" diff --git a/src/server.js b/src/server.js index 8a13955..c1b2e2f 100644 --- a/src/server.js +++ b/src/server.js @@ -42,6 +42,7 @@ const {errorMiddleware} = require('./utils/loggerUtils'); const statModel = require('./schemas/statSchema'); const flairModel = require('./schemas/flairSchema'); const emoteModel = require('./schemas/emoteSchema'); +const tokeModel = require('./schemas/tokebot/tokeSchema'); const tokeCommandModel = require('./schemas/tokebot/tokeCommandSchema'); const migrationModel = require('./schemas/user/migrationSchema'); //Controller @@ -181,29 +182,43 @@ app.use(errorMiddleware); //Basic 404 handler app.use(fileNotFoundController); -//Increment launch counter -statModel.incrementLaunchCount(); +asyncKickStart(); -//Load default flairs -flairModel.loadDefaults(); +/*Asyncronous Kickstarter function +Allows us to force server startup to wait on the DB to be ready. +Might be better if she kicked off everything at once, and ran a while loop to check when they where all done. +This runs once at server startup, and most startups will run fairly quickly so... Not worth it?*/ +async function asyncKickStart(){ + //Lettum fuckin' know wassup + console.log(`${config.instanceName}(Powered by Canopy) is booting up!`); -//Load default emotes -emoteModel.loadDefaults(); + //Run legacy migration + await migrationModel.ingestLegacyDump(); -//Load default toke commands -tokeCommandModel.loadDefaults(); + //Calculate Toke Map + await tokeModel.calculateTokeMap(); -//Run legacy migration -migrationModel.ingestLegacyDump(); + //Load default toke commands + await tokeCommandModel.loadDefaults(); -//Kick off scheduled-jobs -scheduler.kickoff(); + //Load default flairs + await flairModel.loadDefaults(); -//Hand over general-namespace socket.io connections to the channel manager -module.exports.channelManager = new channelManager(io) -module.exports.pmHandler = new pmHandler(io, module.exports.channelManager); + //Load default emotes + await emoteModel.loadDefaults(); -//Listen Function -webServer.listen(port, () => { - console.log(`Opening port ${port}`); -}); \ No newline at end of file + //Kick off scheduled-jobs + scheduler.kickoff(); + + //Increment launch counter + await statModel.incrementLaunchCount(); + + //Hand over general-namespace socket.io connections to the channel manager + module.exports.channelManager = new channelManager(io) + module.exports.pmHandler = new pmHandler(io, module.exports.channelManager); + + //Listen Function + webServer.listen(port, () => { + console.log(`Tokes up on port ${port}!`); + }); +} \ No newline at end of file diff --git a/src/views/partial/profile/tokeCount.ejs b/src/views/partial/profile/tokeCount.ejs index 9c1ab81..ccf9ac5 100644 --- a/src/views/partial/profile/tokeCount.ejs +++ b/src/views/partial/profile/tokeCount.ejs @@ -19,6 +19,11 @@ along with this program. If not, see . %>
<% profile.tokes.forEach((count, toke) => { %> -

<%- toke == "Legacy Tokes" ? '
' : '!' %><%- toke %>: <%- count %>

+ <% if(toke != "Legacy Tokes"){ %> +

!<%- toke %>: <%- count %>

+ <% } %> <% }); %> + <% if(profile.tokes.get("Legacy Tokes") != null){ %> +


Legacy Tokes: <%- profile.tokes.get("Legacy Tokes") %>

+ <% } %>
\ No newline at end of file From da9428205f2a0fdd2e21a7e81a14dc62c0380255 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Tue, 14 Oct 2025 02:10:34 -0400 Subject: [PATCH 37/92] Prevented users from signing up with an email/username which was already present in the migration database. --- .../account/emailChangeRequestController.js | 17 +++++++-- src/schemas/user/migrationSchema.js | 30 ++++++++++++++-- src/schemas/user/userSchema.js | 36 +++++++++++++++++-- src/server.js | 3 ++ 4 files changed, 79 insertions(+), 7 deletions(-) diff --git a/src/controllers/api/account/emailChangeRequestController.js b/src/controllers/api/account/emailChangeRequestController.js index 1b82d74..4791ba2 100644 --- a/src/controllers/api/account/emailChangeRequestController.js +++ b/src/controllers/api/account/emailChangeRequestController.js @@ -43,7 +43,7 @@ module.exports.post = async function(req, res){ //Check to make sure the user is logged in if(req.session.user == null){ - errorHandler(res, "Invalid user!"); + return errorHandler(res, "Invalid user!"); } //Authenticate and find user model from DB @@ -51,11 +51,22 @@ module.exports.post = async function(req, res){ //If we have an invalid user if(userDB == null){ - errorHandler(res, "Invalid user!"); + return errorHandler(res, "Invalid user!"); } if(userDB.email == email){ - errorHandler(res, "Cannot set current email!"); + return errorHandler(res, "Cannot set current email!"); + } + + + //Look through DB and migration cache for existing email + const existingDB = await userModel.findOne({email: new RegExp(email, 'i')}); + const needsMigration = userModel.migrationCache.emails.includes(email.toLowerCase()); + + //If the email is in use + if(existingDB != null || needsMigration){ + //Complain + return errorHandler(res, "Email already in use!"); } //Generate the password reset link diff --git a/src/schemas/user/migrationSchema.js b/src/schemas/user/migrationSchema.js index b03b1dc..2856094 100644 --- a/src/schemas/user/migrationSchema.js +++ b/src/schemas/user/migrationSchema.js @@ -27,7 +27,6 @@ const permissionModel = require('../permissionSchema'); const tokeModel = require('../tokebot/tokeSchema'); const loggerUtils = require('../../utils/loggerUtils'); - /** * DB Schema for documents representing legacy fore.st migration data for a single user account */ @@ -208,7 +207,7 @@ migrationSchema.statics.ingestLegacyUser = async function(rawProfile){ //If we found the user in the database if(foundMigration != null || foundUser != null){ //Scream - //loggerUtils.consoleWarn(`Found legacy user ${profileArray[1]} in database, skipping migration!`); + loggerUtils.consoleWarn(`Found legacy user ${profileArray[1]} in database, skipping migration!`); //BAIL! return; } @@ -292,4 +291,31 @@ migrationSchema.statics.ingestTokeMaps = async function(rawTokeMaps){ } } +migrationSchema.statics.buildMigrationCache = async function(){ + //Pull all profiles from the Legacy Profile Migration DB collection + const legacyProfiles = await this.find(); + + //For each profile in the migration collection + for(const profile of legacyProfiles){ + //Push the username into the migration cache + userModel.migrationCache.users.push(profile.user.toLowerCase()); + //If the profile has an email address set + if(profile.email != null && profile.email != ''){ + //Add the email to the migration cache + userModel.migrationCache.emails.push(profile.email.toLowerCase()); + } + } +} + +//Methods +/** + * Consumes a migration profile and creates a new, modern canopy profile from the original. + * @param {String} oldPass - Original password to authenticate migration against + * @param {String} newPass - New password to re-hash with modern hashing algo + * @param {String} confirmPass - Confirmation for the new pass + */ +migrationSchema.methods.consume = async function(oldPass, newPass, confirmPass){ + +} + module.exports = mongoose.model("migration", migrationSchema); \ No newline at end of file diff --git a/src/schemas/user/userSchema.js b/src/schemas/user/userSchema.js index dd13b24..003f2e6 100644 --- a/src/schemas/user/userSchema.js +++ b/src/schemas/user/userSchema.js @@ -226,6 +226,18 @@ userSchema.post('deleteOne', {document: true}, async function (){ }); //statics +/** + * Holds cache of usernames of profiles stored in the Legacy Profile Migration collection + * + * We can't directly reference migrationSchema, as it would cause a circular reference + * To deal with this, migration schema caches it's regestered users into this array on startup. + * Bonus pts for improved performance on registration calls + */ +userSchema.statics.migrationCache = { + users: [], + emails: [] +}; + /** * Registers a new user account with given information * @param {Object} userObj - Object representing user to register, generated by the client @@ -237,11 +249,31 @@ userSchema.statics.register = async function(userObj, ip){ //Check password confirmation matches if(pass == passConfirm){ + //Setup user query + let userQuery = {user: new RegExp(user, 'i')}; + + //If we have an email + if(email != null && email != ""){ + userQuery = {$or: [ + userQuery, + {email: new RegExp(email, 'i')} + ]}; + } + //Look for a user (case insensitive) - var userDB = await this.findOne({user: new RegExp(user, 'i')}); + var userDB = await this.findOne(userQuery); + + //Look for a legacy profile + let needsMigration = this.migrationCache.users.includes(user.toLowerCase()); + + //If the email isn't null and we didnt hit a migration username + if(email != null && !needsMigration){ + //Check for migration email + needsMigration = this.migrationCache.emails.includes(email.toLowerCase()); + } //If the user is found or someones trying to impersonate tokeboi - if(userDB || user.toLowerCase() == "tokebot"){ + if(userDB || needsMigration || 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 diff --git a/src/server.js b/src/server.js index c1b2e2f..333dfc3 100644 --- a/src/server.js +++ b/src/server.js @@ -195,6 +195,9 @@ async function asyncKickStart(){ //Run legacy migration await migrationModel.ingestLegacyDump(); + //Build migration cache + await migrationModel.buildMigrationCache(); + //Calculate Toke Map await tokeModel.calculateTokeMap(); From 6cbb72676488533a530086991dfd9c61594c7c30 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Thu, 16 Oct 2025 05:25:08 -0400 Subject: [PATCH 38/92] Implemented Profile Migration Backend --- .../api/account/migrationController.js | 96 +++++++++++++++++++ src/routers/api/accountRouter.js | 23 ++++- src/schemas/user/migrationSchema.js | 55 ++++++++++- src/schemas/user/userSchema.js | 2 +- src/utils/csrfUtils.js | 3 - src/utils/hashUtils.js | 12 ++- src/utils/mailUtils.js | 36 +++++-- www/js/utils.js | 18 ++++ 8 files changed, 228 insertions(+), 17 deletions(-) create mode 100644 src/controllers/api/account/migrationController.js diff --git a/src/controllers/api/account/migrationController.js b/src/controllers/api/account/migrationController.js new file mode 100644 index 0000000..61762ed --- /dev/null +++ b/src/controllers/api/account/migrationController.js @@ -0,0 +1,96 @@ +/*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 .*/ + +//Config +const config = require('../../../../config.json'); + +//NPM Imports +const {validationResult, matchedData} = require('express-validator'); + +//local imports +const userBanModel = require('../../../schemas/user/userBanSchema'); +const altchaUtils = require('../../../utils/altchaUtils'); +const migrationModel = require('../../../schemas/user/migrationSchema'); +const {exceptionHandler, errorHandler} = require('../../../utils/loggerUtils'); + +module.exports.post = async function(req, res){ + try{ + //Check for validation errors + const validResult = validationResult(req); + + //If there are none + if(validResult.isEmpty()){ + //Get sanatized/validated data + const migration = matchedData(req); + //Verify Altcha Payload + const verified = await altchaUtils.verify(req.body.verification); + + //If altcha verification failed + if(!verified){ + return errorHandler(res, 'Altcha verification failed, Please refresh the page!', 'unauthorized'); + } + + //If we're proxied use passthrough IP + const ip = config.proxied ? req.headers['x-forwarded-for'] : req.ip; + + //Look for ban by IP + const ipBanDB = await userBanModel.checkBanByIP(ip); + + //If this ip is randy bobandy + if(ipBanDB != null){ + //Make the number a little prettier despite the lack of precision since we're not doing calculations here :P + const expiration = ipBanDB.getDaysUntilExpiration() < 1 ? 0 : ipBanDB.getDaysUntilExpiration(); + let banMsg = []; + + //If the ban is permanent + if(ipBanDB.permanent){ + //tell it to fuck off + //Make the code and message look pretty (kinda) at the same time + banMsg = [ + 'The IP address you are trying to migrate an account from has been permanently banned.', + 'Your cleartext IP has been saved to the database.', + `Any accounts associated will be nuked in ${expiration} day(s).`, + 'If you beleive this to be an error feel free to reach out to your server administrator.', + 'Otherwise, fuck off :)' + ]; + }else{ + //tell it to fuck off + //Make the code and message look pretty (kinda) at the same time + banMsg = [ + 'The IP address you are trying to migrate an account from has been temporarily banned.', + `Your cleartext IP has been saved to the database until the ban expires in ${expiration} day(s).`, + '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'); + } + + //Find and consume migration document + await migrationModel.consumeByUsername(ip, migration); + + //tell of our success + return res.sendStatus(200); + }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/api/accountRouter.js b/src/routers/api/accountRouter.js index 95aafd5..5975453 100644 --- a/src/routers/api/accountRouter.js +++ b/src/routers/api/accountRouter.js @@ -22,6 +22,7 @@ const accountValidator = require("../../validators/accountValidator"); const loginController = require("../../controllers/api/account/loginController"); const logoutController = require("../../controllers/api/account/logoutController"); const registerController = require("../../controllers/api/account/registerController"); +const migrationController = require("../../controllers/api/account/migrationController"); const updateController = require("../../controllers/api/account/updateController"); const rankEnumController = require("../../controllers/api/account/rankEnumController"); const passwordResetRequestController = require("../../controllers/api/account/passwordResetRequestController"); @@ -38,18 +39,32 @@ router.post('/login', accountValidator.user(), accountValidator.pass(), loginCon //logout router.post('/logout', logoutController.post); //register -router.post('/register', accountValidator.user(), +router.post('/register', + accountValidator.user(), accountValidator.securePass(), accountValidator.pass('passConfirm'), - accountValidator.email(), registerController.post); + accountValidator.email(), + registerController.post); + +//migrate legacy profile +router.post('/migrate', + accountValidator.user(), + accountValidator.pass('oldPass'), + accountValidator.securePass('newPass'), + accountValidator.pass('passConfirm'), + migrationController.post); + //update profile -router.post('/update', accountValidator.img(), +router.post('/update', + accountValidator.img(), accountValidator.bio(), accountValidator.signature(), accountValidator.pronouns(), accountValidator.pass('passChange.oldPass'), accountValidator.securePass('passChange.newPass'), - accountValidator.pass('passChange.confirmPass'), updateController.post); + accountValidator.pass('passChange.confirmPass'), + updateController.post); + //rankEnum //This might seem silly, but it allows us to cleanly get the current rank list to compare against, without storing it in multiple places router.get('/rankEnum', rankEnumController.get); diff --git a/src/schemas/user/migrationSchema.js b/src/schemas/user/migrationSchema.js index 2856094..1cc3b1a 100644 --- a/src/schemas/user/migrationSchema.js +++ b/src/schemas/user/migrationSchema.js @@ -25,7 +25,11 @@ const config = require('../../../config.json'); const {userModel} = require('../user/userSchema'); const permissionModel = require('../permissionSchema'); const tokeModel = require('../tokebot/tokeSchema'); +const statModel = require('../statSchema'); +const emailChangeModel = require('../user/emailChangeSchema'); const loggerUtils = require('../../utils/loggerUtils'); +const hashUtils = require('../../utils/hashUtils'); +const mailUtils = require('../../utils/mailUtils'); /** * DB Schema for documents representing legacy fore.st migration data for a single user account @@ -307,15 +311,62 @@ migrationSchema.statics.buildMigrationCache = async function(){ } } +migrationSchema.statics.consumeByUsername = async function(ip, migration){ + //Pull migration doc by case-insensitive username + const migrationDB = await this.findOne({user: new RegExp(migration.user, 'i')}); + + //Wait on the miration DB token to be consumed + await migrationDB.consume(ip, migration); +} + //Methods /** * Consumes a migration profile and creates a new, modern canopy profile from the original. * @param {String} oldPass - Original password to authenticate migration against * @param {String} newPass - New password to re-hash with modern hashing algo - * @param {String} confirmPass - Confirmation for the new pass + * @param {String} passConfirm - Confirmation for the new pass */ -migrationSchema.methods.consume = async function(oldPass, newPass, confirmPass){ +migrationSchema.methods.consume = async function(ip, migration){ + //If we where handed a bad password + if(!hashUtils.compareLegacyPassword(migration.oldPass, this.pass)){ + //Complain + throw loggerUtils.exceptionSmith("Incorrect username/password.", "migration"); + } + //If we where handed a mismatched confirmation password + if(migration.newPass != migration.passConfirm){ + //Complain + throw loggerUtils.exceptionSmith("New password does not match confirmation password.", "migration"); + } + + //Increment user count + const id = await statModel.incrementUserCount(); + + //Create new user from profile info + const newUser = await userModel.create({ + id, + user: this.user, + pass: migration.newPass, + rank: permissionModel.rankEnum[this.rank], + bio: this.bio, + img: this.image, + date: this.date, + tokes: new Map([["Legacy Tokes", this.tokes]]) + }); + + //Tattoo hashed IP use to migrate to the new user account + await newUser.tattooIPRecord(ip); + + //if we submitted an email + if(this.email != null && this.email != ''){ + //Generate new request + const requestDB = await emailChangeModel.create({user: newUser._id, newEmail: this.email, ipHash: ip}); + + //Send confirmation email + mailUtils.sendAddressVerification(requestDB, newUser, this.email, false, true); + } + + await this.deleteOne(); } module.exports = mongoose.model("migration", migrationSchema); \ No newline at end of file diff --git a/src/schemas/user/userSchema.js b/src/schemas/user/userSchema.js index 003f2e6..bac2434 100644 --- a/src/schemas/user/userSchema.js +++ b/src/schemas/user/userSchema.js @@ -289,7 +289,7 @@ userSchema.statics.register = async function(userObj, ip){ if(email != null){ const requestDB = await emailChangeModel.create({user: newUser._id, newEmail: email, ipHash: ip}); - await mailUtil.sendAddressVerification(requestDB, newUser, email) + await mailUtil.sendAddressVerification(requestDB, newUser, email, true); } } }else{ diff --git a/src/utils/csrfUtils.js b/src/utils/csrfUtils.js index 5a898b3..457e48b 100644 --- a/src/utils/csrfUtils.js +++ b/src/utils/csrfUtils.js @@ -17,9 +17,6 @@ along with this program. If not, see .*/ //NPM Imports const { csrfSync } = require('csrf-sync'); -//Local Imports -const {errorHandler} = require('./loggerUtils'); - //Pull needed methods from csrfSync const {generateToken, revokeToken, csrfSynchronisedProtection, isRequestValid} = csrfSync(); diff --git a/src/utils/hashUtils.js b/src/utils/hashUtils.js index 082cd73..95aaf71 100644 --- a/src/utils/hashUtils.js +++ b/src/utils/hashUtils.js @@ -34,7 +34,7 @@ module.exports.hashPassword = function(pass){ } /** - * Sitewide password for authenticating/comparing passwords agianst hashes + * Sitewide method for authenticating/comparing passwords agianst hashes * @param {String} pass - Plaintext Password * @param {String} hash - Salty Hash * @returns {Boolean} True if authentication success @@ -43,6 +43,16 @@ module.exports.comparePassword = function(pass, hash){ return bcrypt.compareSync(pass, hash); } +/** + * Sitewide method for authenticating/comparing passwords agianst hashes for legacy profiles + * @param {String} pass - Plaintext Password + * @param {String} hash - Salty Hash + * @returns {Boolean} True if authentication success + */ +module.exports.compareLegacyPassword = function(pass, hash){ + return bcrypt.compareSync(pass, hash); +} + /** * Site-wide IP hashing/salting function * diff --git a/src/utils/mailUtils.js b/src/utils/mailUtils.js index 3df123b..ec825a1 100644 --- a/src/utils/mailUtils.js +++ b/src/utils/mailUtils.js @@ -72,16 +72,40 @@ module.exports.mailem = async function(to, subject, body, htmlBody = false){ * @param {Mongoose.Document} requestDB - DB Document Object for the current email change request token * @param {Mongoose.Document} userDB - DB Document Object for the user we're verifying email against * @param {String} newEmail - New email address to send to + * @param {Boolean} newUser - Denotes an email going out to a new account + * @param {Boolean} migration - Denotes an email going out to an account which was just mirated */ -module.exports.sendAddressVerification = async function(requestDB, userDB, newEmail){ +module.exports.sendAddressVerification = async function(requestDB, userDB, newEmail, newUser = false, migration = false,){ + let subject = `Email Change Request - ${userDB.user}`; + let content = `

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`; + + if(newUser){ + subject = `New User Email Confirmation - ${userDB.user}`; + + content = `

New user email confirmation

+

a new ${config.instanceName} account '${userDB.user}' was created with this email address.
+ click here to confirm this change.

+ if you received this email without request, feel free to ignore and delete it! -tokebot`; + } + + if(migration){ + subject = `User Migration Email Confirmation - ${userDB.user}`; + + content = `

User migration email confirmation

+

The ${config.instanceName} account '${userDB.user}' was successfully migrated to our fancy new codebase.
+ click here to confirm this change.

+ if you received this email without request, reach out to an admin, as your old account might be getting jacked! -tokebot`; + } + + //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`, + subject, + content, true ); diff --git a/www/js/utils.js b/www/js/utils.js index 9f652b1..a2c0d8b 100644 --- a/www/js/utils.js +++ b/www/js/utils.js @@ -737,6 +737,24 @@ class canopyAjaxUtils{ } } + async migrate(user, oldPass, newPass, passConfirm, verification){ + var response = await fetch(`/api/account/migrate`,{ + method: "POST", + headers: { + "Content-Type": "application/json", + //It's either this or find and bind all event listeners :P + "x-csrf-token": utils.ajax.getCSRFToken() + }, + body: JSON.stringify({user, oldPass, newPass, passConfirm, verification}) + }); + + if(response.ok){ + location = "/"; + }else{ + utils.ux.displayResponseError(await response.json()); + } + } + async login(user, pass, verification){ var response = await fetch(`/api/account/login`,{ method: "POST", From 66ec2fabc5851cd1f80769fd8d4b0cb655b9e1f9 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Thu, 16 Oct 2025 05:48:12 -0400 Subject: [PATCH 39/92] Added basic profile sanatization for legacy migration data. --- src/schemas/user/migrationSchema.js | 12 +++++++----- src/schemas/user/userSchema.js | 4 +++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/schemas/user/migrationSchema.js b/src/schemas/user/migrationSchema.js index 1cc3b1a..3cdad49 100644 --- a/src/schemas/user/migrationSchema.js +++ b/src/schemas/user/migrationSchema.js @@ -19,6 +19,7 @@ const fs = require('node:fs/promises'); //NPM Imports const {mongoose} = require('mongoose'); +const validator = require('validator'); //local imports const config = require('../../../config.json'); @@ -223,7 +224,7 @@ migrationSchema.statics.ingestLegacyUser = async function(rawProfile){ pass: profileArray[2], //Clamp rank to 0 and the max setting allowed by the rank enum rank: Math.min(Math.max(0, profileArray[3]), permissionModel.rankEnum.length - 1), - email: profileArray[4], + email: validator.normalizeEmail(profileArray[4]), date: profileArray[7], }) @@ -233,8 +234,8 @@ migrationSchema.statics.ingestLegacyUser = async function(rawProfile){ const bioObject = JSON.parse(profileArray[5].replaceAll("\'",'\\\'')); //Inject bio information into migration profile, only if they're present; - migrationProfile.bio = bioObject.text == '' ? undefined : bioObject.text; - migrationProfile.image = bioObject.image == '' ? undefined : bioObject.image; + migrationProfile.bio = bioObject.text == '' ? undefined : validator.escape(bioObject.text); + migrationProfile.image = bioObject.image == '' ? undefined : validator.escape(bioObject.image); } //Build DB Doc from migration Profile hashtable and dump it into the DB @@ -359,13 +360,14 @@ migrationSchema.methods.consume = async function(ip, migration){ //if we submitted an email if(this.email != null && this.email != ''){ - //Generate new request + //Generate new email change request const requestDB = await emailChangeModel.create({user: newUser._id, newEmail: this.email, ipHash: ip}); - //Send confirmation email + //Send tokenized confirmation email mailUtils.sendAddressVerification(requestDB, newUser, this.email, false, true); } + //Nuke out miration entry await this.deleteOne(); } diff --git a/src/schemas/user/userSchema.js b/src/schemas/user/userSchema.js index bac2434..209312e 100644 --- a/src/schemas/user/userSchema.js +++ b/src/schemas/user/userSchema.js @@ -287,9 +287,11 @@ userSchema.statics.register = async function(userObj, ip){ //if we submitted an email if(email != null){ + //Generate email request token const requestDB = await emailChangeModel.create({user: newUser._id, newEmail: email, ipHash: ip}); - await mailUtil.sendAddressVerification(requestDB, newUser, email, true); + //Send tokenized confirmation link to users email address + mailUtil.sendAddressVerification(requestDB, newUser, email, true); } } }else{ From 6ae652b47c39f32f3c1aefa3aa09fbdfa61b007d Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Thu, 16 Oct 2025 06:55:36 -0400 Subject: [PATCH 40/92] Updated login API to throw 301 when an un-migrated user attempts to login. --- .../api/account/loginController.js | 33 ++++++++++++++----- src/utils/sessionUtils.js | 2 +- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/controllers/api/account/loginController.js b/src/controllers/api/account/loginController.js index 00d62c4..fd926b0 100644 --- a/src/controllers/api/account/loginController.js +++ b/src/controllers/api/account/loginController.js @@ -21,10 +21,10 @@ const config = require('../../../../config.json'); const {validationResult, matchedData} = require('express-validator'); //local imports +const migrationModel = require('../../../schemas/user/migrationSchema.js'); const sessionUtils = require('../../../utils/sessionUtils'); +const hashUtils = require('../../../utils/hashUtils.js'); const {exceptionHandler, errorHandler} = require('../../../utils/loggerUtils'); -const altchaUtils = require('../../../utils/altchaUtils'); -const session = require('express-session'); //api account functions module.exports.post = async function(req, res){ @@ -51,20 +51,35 @@ module.exports.post = async function(req, res){ //if we don't have errors if(validResult.isEmpty()){ //Get login attempts for current user - const {user} = matchedData(req); - const attempts = sessionUtils.getLoginAttempts(user) + const {user, pass} = matchedData(req); - //if we've gone over max attempts and - if(attempts.count > sessionUtils.throttleAttempts){ - //tell client it needs a captcha - return res.sendStatus(429); + //Look for the username in the migration DB + const migrationDB = await migrationModel.findOne({user}); + + //If this isn't a migration + if(migrationDB == null){ + //Get login attempts + const attempts = sessionUtils.getLoginAttempts(user) + + //if we've gone over max attempts + if(attempts.count > sessionUtils.throttleAttempts){ + //tell client it needs a captcha + return res.sendStatus(429); + } + //otherwise + }else{ + //If the user has a good password + if(hashUtils.compareLegacyPassword(pass, migrationDB.pass)){ + //Redirect to migrate + return res.sendStatus(301); + } } }else{ res.status(400); return res.send({errors: validResult.array()}) } - // + //Scream about any un-caught errors return exceptionHandler(res, err); } diff --git a/src/utils/sessionUtils.js b/src/utils/sessionUtils.js index 6aeae3b..b1b15cd 100644 --- a/src/utils/sessionUtils.js +++ b/src/utils/sessionUtils.js @@ -17,7 +17,7 @@ along with this program. If not, see .*/ //Local Imports const config = require('../../config.json'); const {userModel} = require('../schemas/user/userSchema.js'); -const userBanModel = require('../schemas/user/userBanSchema.js') +const userBanModel = require('../schemas/user/userBanSchema.js'); const altchaUtils = require('../utils/altchaUtils.js'); const loggerUtils = require('../utils/loggerUtils.js'); From cb3fc9bb9137dd0d3b4b856479a3345605a721fc Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Thu, 16 Oct 2025 07:36:32 -0400 Subject: [PATCH 41/92] Beautified launch printout --- src/schemas/statSchema.js | 4 ++-- src/server.js | 2 +- src/utils/loggerUtils.js | 23 +++++++++++++++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/schemas/statSchema.js b/src/schemas/statSchema.js index d1af85e..e7da67a 100644 --- a/src/schemas/statSchema.js +++ b/src/schemas/statSchema.js @@ -19,6 +19,7 @@ const {mongoose} = require('mongoose'); //Local Imports const config = require('./../../config.json'); +const loggerUtils = require('./../utils/loggerUtils'); /** * DB Schema for single document for keeping track of server stats @@ -94,8 +95,7 @@ statSchema.statics.incrementLaunchCount = async function(){ this.firstLaunch = stats.firstLaunch; //print bootup message to console. - console.log(`${config.instanceName}(Powered by Canopy) initialized. This server has booted ${stats.launchCount} time${stats.launchCount == 1 ? '' : 's'}.`) - console.log(`First booted on ${stats.firstLaunch}.`); + loggerUtils.welcomeWagon(stats.launchCount, stats.firstLaunch); } /** diff --git a/src/server.js b/src/server.js index 333dfc3..1799b24 100644 --- a/src/server.js +++ b/src/server.js @@ -222,6 +222,6 @@ async function asyncKickStart(){ //Listen Function webServer.listen(port, () => { - console.log(`Tokes up on port ${port}!`); + console.log(`Tokes up on port \x1b[4m\x1b[35m${port}\x1b[0m!`); }); } \ No newline at end of file diff --git a/src/utils/loggerUtils.js b/src/utils/loggerUtils.js index a3100e8..2ae399e 100644 --- a/src/utils/loggerUtils.js +++ b/src/utils/loggerUtils.js @@ -209,4 +209,27 @@ module.exports.dumpError = async function(err, date = new Date()){ //Dump the error we had saving that error to file to console module.exports.consoleWarn(doubleErr); } +} + +module.exports.welcomeWagon = function(count, date){ + //Inject values into ascii art + const art = ` +\x1b[32m ! \x1b[0m +\x1b[32m 420 \x1b[0m \x1b[32m\x1b[40m${config.instanceName}\x1b[0m\x1b[2m, Powered By:\x1b[0m +\x1b[32m 420 \x1b[0m +\x1b[32m WEEED \x1b[0m CCCC AAA NN N OOO PPPP Y Y +\x1b[32m! WEEED !\x1b[0m C A A NN N O O P P Y Y +\x1b[32mWEE EEEEE EED\x1b[0m C A A N N N O O P P Y Y +\x1b[32m WEE EEEEE EED\x1b[0m C AAAAA N N N O O PPPP Y +\x1b[32m WEE EEE EED\x1b[0m C A A N N N O O P Y +\x1b[32m WEE EEE EED\x1b[0m C A A N NN O O P Y +\x1b[32m WEEEEED\x1b[0m CCCC A A N NN OOO P Y +\x1b[32m WEEE ! EEED\x1b[0m +\x1b[32m !\x1b[0m \x1b[34mInitialization Complete!\x1b[0m This server has booted \x1b[4m${count}\x1b[0m time${count == 1 ? '' : 's'}. +\x1b[32m !\x1b[0m This server was first booted on \x1b[4m${date}\x1b[0m.` + + //Dump art to console + console.log(art); + //Add some extra padding for the port printout from server.js + process.stdout.write(' '); } \ No newline at end of file From bddbd9cd36fd9bc88ad86056e05606724ce0e8db Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Thu, 16 Oct 2025 07:41:13 -0400 Subject: [PATCH 42/92] More welcome wagon beautification. --- src/schemas/statSchema.js | 3 ++- src/server.js | 2 +- src/utils/loggerUtils.js | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/schemas/statSchema.js b/src/schemas/statSchema.js index e7da67a..8c680c3 100644 --- a/src/schemas/statSchema.js +++ b/src/schemas/statSchema.js @@ -19,6 +19,7 @@ const {mongoose} = require('mongoose'); //Local Imports const config = require('./../../config.json'); +const tokeSchema = require('./tokebot/tokeSchema'); const loggerUtils = require('./../utils/loggerUtils'); /** @@ -95,7 +96,7 @@ statSchema.statics.incrementLaunchCount = async function(){ this.firstLaunch = stats.firstLaunch; //print bootup message to console. - loggerUtils.welcomeWagon(stats.launchCount, stats.firstLaunch); + loggerUtils.welcomeWagon(stats.launchCount, stats.firstLaunch, tokeSchema.count); } /** diff --git a/src/server.js b/src/server.js index 1799b24..43a1f57 100644 --- a/src/server.js +++ b/src/server.js @@ -222,6 +222,6 @@ async function asyncKickStart(){ //Listen Function webServer.listen(port, () => { - console.log(`Tokes up on port \x1b[4m\x1b[35m${port}\x1b[0m!`); + console.log(`Tokes up on port \x1b[4m\x1b[35m${port}\x1b[0m!\n`); }); } \ No newline at end of file diff --git a/src/utils/loggerUtils.js b/src/utils/loggerUtils.js index 2ae399e..eb51977 100644 --- a/src/utils/loggerUtils.js +++ b/src/utils/loggerUtils.js @@ -211,7 +211,7 @@ module.exports.dumpError = async function(err, date = new Date()){ } } -module.exports.welcomeWagon = function(count, date){ +module.exports.welcomeWagon = function(count, date, tokes){ //Inject values into ascii art const art = ` \x1b[32m ! \x1b[0m @@ -225,7 +225,7 @@ module.exports.welcomeWagon = function(count, date){ \x1b[32m WEE EEE EED\x1b[0m C A A N NN O O P Y \x1b[32m WEEEEED\x1b[0m CCCC A A N NN OOO P Y \x1b[32m WEEE ! EEED\x1b[0m -\x1b[32m !\x1b[0m \x1b[34mInitialization Complete!\x1b[0m This server has booted \x1b[4m${count}\x1b[0m time${count == 1 ? '' : 's'}. +\x1b[32m !\x1b[0m \x1b[34mInitialization Complete!\x1b[0m This server has booted \x1b[4m${count}\x1b[0m time${count == 1 ? '' : 's'} and taken ${tokes} \x1b[4mtoke${tokes == 1 ? '' : 's'}\x1b[0m. \x1b[32m !\x1b[0m This server was first booted on \x1b[4m${date}\x1b[0m.` //Dump art to console From eb48b925512ec54075a5c4d119f1247023a11e2a Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Thu, 16 Oct 2025 08:25:13 -0400 Subject: [PATCH 43/92] Started work on migration UI. Improved email handling. --- src/controllers/migrateController.js | 31 +++++++++++++ src/routers/migrateRouter.js | 30 ++++++++++++ src/schemas/user/migrationSchema.js | 8 +++- src/server.js | 2 + src/utils/loggerUtils.js | 4 +- src/utils/mailUtils.js | 55 ++++++++++++++-------- src/views/migrate.ejs | 46 +++++++++++++++++++ www/css/migrate.css | 31 +++++++++++++ www/js/migrate.js | 68 ++++++++++++++++++++++++++++ 9 files changed, 252 insertions(+), 23 deletions(-) create mode 100644 src/controllers/migrateController.js create mode 100644 src/routers/migrateRouter.js create mode 100644 src/views/migrate.ejs create mode 100644 www/css/migrate.css create mode 100644 www/js/migrate.js diff --git a/src/controllers/migrateController.js b/src/controllers/migrateController.js new file mode 100644 index 0000000..ea82e5e --- /dev/null +++ b/src/controllers/migrateController.js @@ -0,0 +1,31 @@ +/*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 .*/ + +//Config +const config = require('../../config.json'); + +//Local Imports +const altchaUtils = require('../utils/altchaUtils'); +const csrfUtils = require('../utils/csrfUtils'); + +//register page functions +module.exports.get = async function(req, res){ + //Generate captcha + const challenge = await altchaUtils.genCaptcha(); + + //Render page + return res.render('migrate', {instance: config.instanceName, user: req.session.user, challenge, csrfToken: csrfUtils.generateToken(req)}); +} \ No newline at end of file diff --git a/src/routers/migrateRouter.js b/src/routers/migrateRouter.js new file mode 100644 index 0000000..8ee67be --- /dev/null +++ b/src/routers/migrateRouter.js @@ -0,0 +1,30 @@ +/*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 { Router } = require('express'); + + +//local imports +const migrateController = require("../controllers/migrateController"); + +//globals +const router = Router(); + +//routing functions +router.get('/', migrateController.get); + +module.exports = router; diff --git a/src/schemas/user/migrationSchema.js b/src/schemas/user/migrationSchema.js index 3cdad49..c17ed51 100644 --- a/src/schemas/user/migrationSchema.js +++ b/src/schemas/user/migrationSchema.js @@ -316,6 +316,12 @@ migrationSchema.statics.consumeByUsername = async function(ip, migration){ //Pull migration doc by case-insensitive username const migrationDB = await this.findOne({user: new RegExp(migration.user, 'i')}); + //If we have no migration document + if(migrationDB == null){ + //Bitch and moan + throw loggerUtils.exceptionSmith("Incorrect username/password.", "migration"); + } + //Wait on the miration DB token to be consumed await migrationDB.consume(ip, migration); } @@ -359,7 +365,7 @@ migrationSchema.methods.consume = async function(ip, migration){ await newUser.tattooIPRecord(ip); //if we submitted an email - if(this.email != null && this.email != ''){ + if(this.email != null && validator.isEmail(this.email)){ //Generate new email change request const requestDB = await emailChangeModel.create({user: newUser._id, newEmail: this.email, ipHash: ip}); diff --git a/src/server.js b/src/server.js index 43a1f57..336071c 100644 --- a/src/server.js +++ b/src/server.js @@ -58,6 +58,7 @@ const channelRouter = require('./routers/channelRouter'); const newChannelRouter = require('./routers/newChannelRouter'); const passwordResetRouter = require('./routers/passwordResetRouter'); const emailChangeRouter = require('./routers/emailChangeController'); +const migrateRouter = require('./routers/migrateRouter'); //Panel const panelRouter = require('./routers/panelRouter'); //Popup @@ -161,6 +162,7 @@ app.use('/c', channelRouter); app.use('/newChannel', newChannelRouter); app.use('/passwordReset', passwordResetRouter); app.use('/emailChange', emailChangeRouter); +app.use('/migrate', migrateRouter); //Panel app.use('/panel', panelRouter); //tooltip diff --git a/src/utils/loggerUtils.js b/src/utils/loggerUtils.js index eb51977..12e5ad8 100644 --- a/src/utils/loggerUtils.js +++ b/src/utils/loggerUtils.js @@ -173,10 +173,10 @@ module.exports.errorMiddleware = function(err, req, res, next){ * @param {Error} err - error to dump to file * @param {Date} date - Date of error, defaults to now */ -module.exports.dumpError = async function(err, date = new Date()){ +module.exports.dumpError = async function(err, date = new Date(), subDir){ try{ //Crash directory - const dir = "./log/crash/" + const dir = `./log/crash/${subDir}` //Double check crash folder exists try{ diff --git a/src/utils/mailUtils.js b/src/utils/mailUtils.js index ec825a1..fce92ca 100644 --- a/src/utils/mailUtils.js +++ b/src/utils/mailUtils.js @@ -19,6 +19,11 @@ const config = require('../../config.json'); //NPM imports const nodeMailer = require("nodemailer"); +const validator = require('validator'); + +//local imports +const loggerUtils = require('./loggerUtils'); + //Setup mail transport /** @@ -43,28 +48,38 @@ const transporter = nodeMailer.createTransport({ * @returns {Object} Sent mail info */ module.exports.mailem = async function(to, subject, body, htmlBody = false){ - //Create mail object - const mailObj = { - from: `"Tokebot🤖💨"<${config.mail.address}>`, - to, - subject - }; + try{ + //If we have a bad email address + if(!validator.isEmail(to)){ + //fuck off + return; + } - //If we're sending HTML - if(htmlBody){ - //set body as html - mailObj.html = body; - //If we're sending plaintext - }else{ - //Set body as plaintext - mailObj.text = body + //Create mail object + const mailObj = { + from: `"Tokebot🤖💨"<${config.mail.address}>`, + to, + subject + }; + + //If we're sending HTML + if(htmlBody){ + //set body as html + mailObj.html = body; + //If we're sending plaintext + }else{ + //Set body as plaintext + mailObj.text = body + } + + //Send mail based on mail object + const sentMail = await transporter.sendMail(mailObj); + + //return the mail info + return sentMail; + }catch(err){ + loggerUtils.dumpError(err, new Date(), 'mail/'); } - - //Send mail based on mail object - const sentMail = await transporter.sendMail(mailObj); - - //return the mail info - return sentMail; } /** diff --git a/src/views/migrate.ejs b/src/views/migrate.ejs new file mode 100644 index 0000000..3744efb --- /dev/null +++ b/src/views/migrate.ejs @@ -0,0 +1,46 @@ +<%# 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 . %> + + + + + <%- include('partial/styles', {instance, user}); %> + <%- include('partial/csrfToken', {csrfToken}); %> + + + <%= instance %> - Account Migration + + + <%- include('partial/navbar', {user}); %> +
+ + + + + + + + + + +
+ +
+ <%- include('partial/scripts', {user}); %> + + +
+ diff --git a/www/css/migrate.css b/www/css/migrate.css new file mode 100644 index 0000000..02ef485 --- /dev/null +++ b/www/css/migrate.css @@ -0,0 +1,31 @@ +/*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 .*/ +form{ + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5em; + margin: 5% 17%; +} + +.migrate-prompt{ + width: 100% +} + +#migrate-button{ + width: 6em; + height: 2em; +} \ No newline at end of file diff --git a/www/js/migrate.js b/www/js/migrate.js new file mode 100644 index 0000000..ed4a1ee --- /dev/null +++ b/www/js/migrate.js @@ -0,0 +1,68 @@ +/*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 .*/ + +class migratePrompt{ + constructor(){ + //Grab user prompt + this.user = document.querySelector("#migrate-username"); + //Grab pass prompts + this.oldPass = document.querySelector("#migrate-password-old"); + this.pass = document.querySelector("#migrate-password"); + this.passConfirm = document.querySelector("#migrate-password-confirm"); + //Grab migrate button + this.button = document.querySelector("#migrate-button"); + //Grab altcha widget + this.altcha = document.querySelector("altcha-widget"); + //Setup null property to hold verification payload from altcha widget + this.verification = null + + //Run input setup after DOM content has completely loaded to ensure altcha event listeners work + document.addEventListener('DOMContentLoaded', this.setupInput.bind(this)); + } + + setupInput(){ + //Add verification event listener to altcha widget + this.altcha.addEventListener("verified", this.verify.bind(this)); + + //Add migrate event listener to migrate button + this.button.addEventListener("click", this.migrate.bind(this)); + } + + verify(event){ + //pull verification payload from event + this.verification = event.detail.payload; + } + + migrate(){ + //If altcha verification isn't complete + if(this.verification == null){ + //don't bother + return; + } + + //if the confirmation password doesn't match + if(this.pass.value != this.passConfirm.value){ + //Scream and shout + new canopyUXUtils.popup(`

Confirmation password does not match!

`); + return; + } + + //Send the registration informaiton off to the server + utils.ajax.migrate(this.user.value , this.oldPass.value, this.pass.value , this.passConfirm.value , this.verification); + } +} + +const migrateForm = new migratePrompt(); \ No newline at end of file From 6bab5b4723b32069fe6b1f383df9839a769a0bd2 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Thu, 16 Oct 2025 21:22:37 -0400 Subject: [PATCH 44/92] Migration UI complete. --- .../api/account/loginController.js | 23 +++++++++---------- src/utils/loggerUtils.js | 2 +- src/views/migrate.ejs | 3 +++ src/views/register.ejs | 2 ++ www/css/migrate.css | 8 +++++++ www/css/register.css | 4 ++++ www/js/login.js | 1 + www/js/migrate.js | 3 +++ www/js/utils.js | 13 ++++++++++- 9 files changed, 45 insertions(+), 14 deletions(-) diff --git a/src/controllers/api/account/loginController.js b/src/controllers/api/account/loginController.js index fd926b0..f5f2130 100644 --- a/src/controllers/api/account/loginController.js +++ b/src/controllers/api/account/loginController.js @@ -56,24 +56,23 @@ module.exports.post = async function(req, res){ //Look for the username in the migration DB const migrationDB = await migrationModel.findOne({user}); - //If this isn't a migration - if(migrationDB == null){ - //Get login attempts - const attempts = sessionUtils.getLoginAttempts(user) - - //if we've gone over max attempts - if(attempts.count > sessionUtils.throttleAttempts){ - //tell client it needs a captcha - return res.sendStatus(429); - } - //otherwise - }else{ + //If we found a migration profile + if(migrationDB != null){ //If the user has a good password if(hashUtils.compareLegacyPassword(pass, migrationDB.pass)){ //Redirect to migrate return res.sendStatus(301); } } + + //Get login attempts + const attempts = sessionUtils.getLoginAttempts(user) + + //if we've gone over max attempts + if(attempts.count > sessionUtils.throttleAttempts){ + //tell client it needs a captcha + return res.sendStatus(429); + } }else{ res.status(400); return res.send({errors: validResult.array()}) diff --git a/src/utils/loggerUtils.js b/src/utils/loggerUtils.js index 12e5ad8..b3e30c3 100644 --- a/src/utils/loggerUtils.js +++ b/src/utils/loggerUtils.js @@ -225,7 +225,7 @@ module.exports.welcomeWagon = function(count, date, tokes){ \x1b[32m WEE EEE EED\x1b[0m C A A N NN O O P Y \x1b[32m WEEEEED\x1b[0m CCCC A A N NN OOO P Y \x1b[32m WEEE ! EEED\x1b[0m -\x1b[32m !\x1b[0m \x1b[34mInitialization Complete!\x1b[0m This server has booted \x1b[4m${count}\x1b[0m time${count == 1 ? '' : 's'} and taken ${tokes} \x1b[4mtoke${tokes == 1 ? '' : 's'}\x1b[0m. +\x1b[32m !\x1b[0m \x1b[34mInitialization Complete!\x1b[0m This server has booted \x1b[4m${count}\x1b[0m time${count == 1 ? '' : 's'} and taken \x1b[4m${tokes}\x1b[0m toke${tokes == 1 ? '' : 's'}. \x1b[32m !\x1b[0m This server was first booted on \x1b[4m${date}\x1b[0m.` //Dump art to console diff --git a/src/views/migrate.ejs b/src/views/migrate.ejs index 3744efb..63e8f87 100644 --- a/src/views/migrate.ejs +++ b/src/views/migrate.ejs @@ -25,6 +25,9 @@ along with this program. If not, see . %> <%- include('partial/navbar', {user}); %> +

Welcome Back!

+

<%= instance%> has received an update, and your account needs one too!

+

Remember your new password, you will need it for your first login!

diff --git a/src/views/register.ejs b/src/views/register.ejs index 3848eac..58e8af3 100644 --- a/src/views/register.ejs +++ b/src/views/register.ejs @@ -25,6 +25,8 @@ along with this program. If not, see . %> <%- include('partial/navbar', {user}); %> +

Account Registration

+

Remember your password, you will need it for your first login!

diff --git a/www/css/migrate.css b/www/css/migrate.css index 02ef485..7c56ebc 100644 --- a/www/css/migrate.css +++ b/www/css/migrate.css @@ -28,4 +28,12 @@ form{ #migrate-button{ width: 6em; height: 2em; +} + +h1, h2{ + text-align: center; +} + +h2{ + margin-bottom: 0; } \ No newline at end of file diff --git a/www/css/register.css b/www/css/register.css index 0d25816..d070af8 100644 --- a/www/css/register.css +++ b/www/css/register.css @@ -28,4 +28,8 @@ form{ #register-button{ width: 6em; height: 2em; +} + +h1, h2{ + text-align: center; } \ No newline at end of file diff --git a/www/js/login.js b/www/js/login.js index a2a4903..c5a3408 100644 --- a/www/js/login.js +++ b/www/js/login.js @@ -18,6 +18,7 @@ class registerPrompt{ constructor(){ //Grab user prompt this.user = document.querySelector("#login-page-username"); + this.user.value = window.location.search.replace("?user=",''); //Grab pass prompts this.pass = document.querySelector("#login-page-password"); //Grab register button diff --git a/www/js/migrate.js b/www/js/migrate.js index ed4a1ee..d714ce7 100644 --- a/www/js/migrate.js +++ b/www/js/migrate.js @@ -18,6 +18,7 @@ class migratePrompt{ constructor(){ //Grab user prompt this.user = document.querySelector("#migrate-username"); + this.user.value = window.location.search.replace("?user=",''); //Grab pass prompts this.oldPass = document.querySelector("#migrate-password-old"); this.pass = document.querySelector("#migrate-password"); @@ -37,6 +38,8 @@ class migratePrompt{ //Add verification event listener to altcha widget this.altcha.addEventListener("verified", this.verify.bind(this)); + console.log(this.button); + //Add migrate event listener to migrate button this.button.addEventListener("click", this.migrate.bind(this)); } diff --git a/www/js/utils.js b/www/js/utils.js index a2c0d8b..9e023f1 100644 --- a/www/js/utils.js +++ b/www/js/utils.js @@ -756,7 +756,7 @@ class canopyAjaxUtils{ } async login(user, pass, verification){ - var response = await fetch(`/api/account/login`,{ + const response = await fetch(`/api/account/login`,{ method: "POST", headers: { "Content-Type": "application/json", @@ -769,6 +769,17 @@ class canopyAjaxUtils{ location.reload(); }else if(response.status == 429){ location = `/login?user=${user}`; + }else if(response.status == 301){ + /* + * So this is gross but I don't know that theres a better way to do this + * Reloading the page would mean either sending the pass to the server as a URL query string which is insecure + * Or the server pre-loading it from the request, however sending passwords back to users seems like a bad idea too, even if it's just an echo + * Using fetch API to load the page assets in dynamically fucks up too, because register.js waits for DOM to load + * + * We could try an iframe and inject the password into that, however that seems really fucking dirty + * Sometimes it might just be better to make the user re-enter it... + */ + location = `/migrate?user=${user}`; }else{ utils.ux.displayResponseError(await response.json()); } From 06f552a9ec2a034f37c21ae9a67e8b6857acadd4 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Sat, 18 Oct 2025 07:21:17 -0400 Subject: [PATCH 45/92] Improved link validation and sanatization, in order to mitigate CVE-2025-56200 from validator.js NPM package. --- package.json | 8 +++++--- src/app/channel/media/playlistHandler.js | 4 ++-- src/app/channel/media/queue.js | 4 ++-- src/utils/linkUtils.js | 9 ++++++--- src/utils/loggerUtils.js | 2 +- src/utils/media/yanker.js | 10 +++++++--- src/validators/accountValidator.js | 9 +++++++-- src/validators/channelValidator.js | 6 +++++- src/validators/emoteValidator.js | 5 +++-- 9 files changed, 38 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index 0c49972..86045a3 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,10 @@ "version": "0.4", "license": "AGPL-3.0-only", "dependencies": { + "@braintree/sanitize-url": "^7.1.1", "altcha": "^1.0.7", "altcha-lib": "^1.2.0", + "argon2": "^0.44.0", "bcrypt": "^5.1.1", "bootstrap-icons": "^1.11.3", "connect-mongo": "^5.1.0", @@ -16,7 +18,7 @@ "hls.js": "^1.6.2", "mongoose": "^8.4.3", "node-cron": "^3.0.3", - "nodemailer": "^6.9.16", + "nodemailer": "^7.0.9", "socket.io": "^4.8.1", "youtube-dl-exec": "^3.0.20" }, @@ -26,7 +28,7 @@ "build": "node node_modules/jsdoc/jsdoc.js --verbose -r src/ -R README.md -d www/doc/server/ && node node_modules/jsdoc/jsdoc.js --verbose -r www/js/channel -r README.md -d www/doc/client/" }, "devDependencies": { - "nodemon": "^3.1.10", - "jsdoc": "^4.0.4" + "jsdoc": "^4.0.4", + "nodemon": "^3.1.10" } } diff --git a/src/app/channel/media/playlistHandler.js b/src/app/channel/media/playlistHandler.js index 9345590..70b8c2b 100644 --- a/src/app/channel/media/playlistHandler.js +++ b/src/app/channel/media/playlistHandler.js @@ -120,12 +120,12 @@ class playlistHandler{ */ async addToPlaylistValidator(socket, url){ //If we where given a bad URL - if(typeof url != 'string' || !validator.isURL(url)){ + if(typeof url != 'string' || !validator.isURL(url,{require_valid_protocol: true})){ //Attempt to fix the situation by encoding it url = encodeURI(url); //If it's still bad - if(typeof url != 'string' || !validator.isURL(url)){ + if(typeof url != 'string' || !validator.isURL(url,{require_valid_protocol: true})){ //Bitch, moan, complain... loggerUtils.socketErrorHandler(socket, "Bad URL!", "validation"); //and ignore it! diff --git a/src/app/channel/media/queue.js b/src/app/channel/media/queue.js index 7866783..9f68021 100644 --- a/src/app/channel/media/queue.js +++ b/src/app/channel/media/queue.js @@ -132,12 +132,12 @@ class queue{ let url = data.url; //If we where given a bad URL - if(!validator.isURL(url)){ + if(!validator.isURL(url,{require_valid_protocol: true})){ //Attempt to fix the situation by encoding it url = encodeURI(url); //If it's still bad - if(!validator.isURL(url)){ + if(!validator.isURL(url,{require_valid_protocol: true})){ //Bitch, moan, complain... loggerUtils.socketErrorHandler(socket, "Bad URL!", "validation"); //and ignore it! diff --git a/src/utils/linkUtils.js b/src/utils/linkUtils.js index 6452db1..9e85870 100644 --- a/src/utils/linkUtils.js +++ b/src/utils/linkUtils.js @@ -16,6 +16,7 @@ along with this program. If not, see .*/ //NPM Imports const validator = require('validator');//No express here, so regular validator it is! +const {sanitizeUrl} = require("@braintree/sanitize-url"); //Create link cache /** @@ -25,10 +26,12 @@ module.exports.cache = new Map(); /** * Validates links and returns a marked link object that can be returned to the client to format/embed accordingly - * @param {String} link - URL to Validate + * @param {String} dirtyLink - URL to Validate * @returns {Object} Marked link object */ -module.exports.markLink = async function(link){ +module.exports.markLink = async function(dirtyLink){ + const link = sanitizeUrl(dirtyLink); + //Check link cache for the requested link const cachedLink = module.exports.cache.get(link); @@ -44,7 +47,7 @@ module.exports.markLink = async function(link){ var type = "malformedLink" //Make sure we have an actual, factual URL - if(validator.isURL(link)){ + if(validator.isURL(link,{require_valid_protocol: true, protocols: ['http', 'https']})){ //The URL is valid, so this is at least a dead link type = 'deadLink'; diff --git a/src/utils/loggerUtils.js b/src/utils/loggerUtils.js index b3e30c3..3e9c0aa 100644 --- a/src/utils/loggerUtils.js +++ b/src/utils/loggerUtils.js @@ -173,7 +173,7 @@ module.exports.errorMiddleware = function(err, req, res, next){ * @param {Error} err - error to dump to file * @param {Date} date - Date of error, defaults to now */ -module.exports.dumpError = async function(err, date = new Date(), subDir){ +module.exports.dumpError = async function(err, date = new Date(), subDir = ''){ try{ //Crash directory const dir = `./log/crash/${subDir}` diff --git a/src/utils/media/yanker.js b/src/utils/media/yanker.js index f16640d..8072712 100644 --- a/src/utils/media/yanker.js +++ b/src/utils/media/yanker.js @@ -17,6 +17,7 @@ along with this program. If not, see .*/ //NPM Imports //const url = require("node:url"); const validator = require('validator');//No express here, so regular validator it is! +const {sanitizeUrl} = require("@braintree/sanitize-url"); //local import const iaUtil = require('./internetArchiveUtils'); @@ -96,12 +97,15 @@ module.exports.refreshRawLink = async function(mediaObj){ * Still this has some improvements like url pre-checks and the fact that it's handled serverside, recuing possibility of bad requests. * Some of the regex expressions for certain services have also been improved, such as youtube, and the fore.st-unique archive.org * - * @param {String} url - URL to determine media type of + * @param {String} dirtyURL - URL to determine media type of * @returns {Object} containing URL type and clipped ID string */ -module.exports.getMediaType = async function(url){ +module.exports.getMediaType = async function(dirtyURL){ + //Sanatize our URL + const url = sanitizeUrl(dirtyURL); + //Check if we have a valid url, encode it on the fly in case it's too humie-friendly - if(!validator.isURL(encodeURI(url))){ + if(!validator.isURL(encodeURI(url,{require_valid_protocol: true}))){ //If not toss the fucker out return { type: null, diff --git a/src/validators/accountValidator.js b/src/validators/accountValidator.js index 38f08b7..aa1d942 100644 --- a/src/validators/accountValidator.js +++ b/src/validators/accountValidator.js @@ -16,6 +16,7 @@ along with this program. If not, see .*/ //NPM Imports const { checkSchema } = require('express-validator'); +const {sanitizeUrl} = require("@braintree/sanitize-url"); //local imports const {isRank} = require('./permissionsValidator'); @@ -99,11 +100,15 @@ module.exports.img = function(field = 'img'){ isURL: { options: { require_tld: false, - require_host: false + require_host: false, + require_valid_protocol: true }, errorMessage: "Invalid URL." }, - trim: true + trim: true, + customSanitizer: { + options: sanitizeUrl + } } }); } diff --git a/src/validators/channelValidator.js b/src/validators/channelValidator.js index 60c9170..350dd87 100644 --- a/src/validators/channelValidator.js +++ b/src/validators/channelValidator.js @@ -83,7 +83,11 @@ module.exports.settingsMap = function(){ }, 'settingsMap.streamURL': { optional: true, - isURL: true, + isURL: { + options:{ + require_valid_protocol: true + } + }, errorMessage: "Invalid Stream URL" } }) diff --git a/src/validators/emoteValidator.js b/src/validators/emoteValidator.js index 3c4ee74..6416516 100644 --- a/src/validators/emoteValidator.js +++ b/src/validators/emoteValidator.js @@ -48,7 +48,8 @@ module.exports.link = function(field = 'link'){ isURL: { options: { require_tld: false, - require_host: false + require_host: false, + require_valid_protocol: true }, errorMessage: "Invalid URL." }, @@ -76,7 +77,7 @@ module.exports.manualLink = function(input){ const clean = validator.trim(input) //If we have a URL return the trimmed input - if(validator.isURL(clean)){ + if(validator.isURL(clean,{require_valid_protocol: true})){ return clean; } From 7f6abdf8e2c56de63755adfed8ab585925adf23b Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Sat, 18 Oct 2025 08:36:05 -0400 Subject: [PATCH 46/92] Improved Email Change and Password Reset token security by increasing token size. --- src/schemas/user/emailChangeSchema.js | 2 +- src/schemas/user/passwordResetSchema.js | 2 +- src/validators/accountValidator.js | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/schemas/user/emailChangeSchema.js b/src/schemas/user/emailChangeSchema.js index bffd13f..5e839ba 100644 --- a/src/schemas/user/emailChangeSchema.js +++ b/src/schemas/user/emailChangeSchema.js @@ -52,7 +52,7 @@ const emailChangeSchema = new mongoose.Schema({ type: mongoose.SchemaTypes.String, required: true, //Use a cryptographically secure algorythm to create a random hex string from 16 bytes as our change/cancel token - default: ()=>{return crypto.randomBytes(16).toString('hex')} + default: ()=>{return crypto.randomBytes(32).toString('hex')} }, ipHash: { type: mongoose.SchemaTypes.String, diff --git a/src/schemas/user/passwordResetSchema.js b/src/schemas/user/passwordResetSchema.js index ecba77f..9391bee 100644 --- a/src/schemas/user/passwordResetSchema.js +++ b/src/schemas/user/passwordResetSchema.js @@ -48,7 +48,7 @@ const passwordResetSchema = new mongoose.Schema({ type: mongoose.SchemaTypes.String, required: true, //Use a cryptographically secure algorythm to create a random hex string from 16 bytes as our reset token - default: ()=>{return crypto.randomBytes(16).toString('hex')} + default: ()=>{return crypto.randomBytes(32).toString('hex')} }, ipHash: { type: mongoose.SchemaTypes.String, diff --git a/src/validators/accountValidator.js b/src/validators/accountValidator.js index aa1d942..4e031d3 100644 --- a/src/validators/accountValidator.js +++ b/src/validators/accountValidator.js @@ -185,8 +185,8 @@ module.exports.securityToken = function(field = 'token'){ isHexadecimal: true, isLength: { options: { - min: 32, - max: 32 + min: 64, + max: 64 } }, errorMessage: "Invalid security token." From 895a8201a5a00a07675b4931e19807951f1c4454 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Sat, 18 Oct 2025 09:15:00 -0400 Subject: [PATCH 47/92] Started work on Remember Me Tokens. --- src/schemas/user/rememberMeSchema.js | 103 +++++++++++++++++++++++++++ src/utils/hashUtils.js | 21 ++++++ 2 files changed, 124 insertions(+) create mode 100644 src/schemas/user/rememberMeSchema.js diff --git a/src/schemas/user/rememberMeSchema.js b/src/schemas/user/rememberMeSchema.js new file mode 100644 index 0000000..6b63942 --- /dev/null +++ b/src/schemas/user/rememberMeSchema.js @@ -0,0 +1,103 @@ +/*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 .*/ + +//You could make an argument for making this part of the userModel +//However, this is so rarely used the preformance benefits aren't worth the extra clutter + +//Config +const config = require('../../../config.json'); + +//Node Imports +const crypto = require("node:crypto"); + +//NPM Imports +const {mongoose} = require('mongoose'); + +//Local Imports +const userSchema = require('./userSchema'); +const hashUtil = require('../../utils/hashUtils'); +const loggerUtils = require('../../utils/loggerUtils'); + +/** + * Password reset token retention time + * + * Lasts about half a year + */ +const daysToExpire = 182; + +/** + * DB Schema for documents containing a single expiring password reset token + */ +const rememberMeToken = new mongoose.Schema({ + id: { + type: mongoose.SchemaTypes.UUID, + required: true, + default: crypto.randomUUID() + }, + user: { + type: mongoose.SchemaTypes.ObjectID, + ref: "user", + required: true + }, + token: { + type: mongoose.SchemaTypes.String, + required: true + }, + date: { + type: mongoose.SchemaTypes.Date, + required: true, + default: new Date() + } +}); + +/** + * Pre-Save function for rememberMeSchema + */ +rememberMeToken.pre('save', async function (next){ + //If the token was changed + if(this.isModified("token")){ + //Hash that sunnovabitch, no questions asked. + this.token = hashUtil.hashRememberMeToken(this.token); + } + + //All is good, continue on saving. + next(); +}); + +//statics +rememberMeToken.statics.genToken = async function(user, pass){ + try{ + //Authenticate user and pull document + const userDB = await userSchema.authenticate(user, pass); + + //Generate a cryptographically secure string of 32 bytes in hexidecimal + const token = crypto.randomBytes(32).toString('hex'); + + //Create token document off of user and token string + const tokenDB = await this.create({user: userDB._id, token}); + + //Return token document UUID w/ plaintext token for browser consumption + return { + id: tokenDB.id, + token + }; + //If we failed (most likely for bad login) + }catch(err){ + return loggerUtils.localExceptionHandler(err); + } +} + +module.exports = mongoose.model("rememberMe", rememberMeToken); \ No newline at end of file diff --git a/src/utils/hashUtils.js b/src/utils/hashUtils.js index 95aaf71..b9086cc 100644 --- a/src/utils/hashUtils.js +++ b/src/utils/hashUtils.js @@ -21,6 +21,7 @@ const config = require('../../config.json'); const crypto = require('node:crypto'); //NPM Imports +const argon2 = require('argon2'); const bcrypt = require('bcrypt'); /** @@ -69,4 +70,24 @@ module.exports.hashIP = function(ip){ //return the IP hash as a string return hashObj.digest('hex'); +} + +/** + * Site-wide remember-me token hashing function + * @param {String} token - Token to hash + * @returns {String} - Hashed token + */ +module.exports.hashRememberMeToken = async function(token){ + return await argon2.hash(token); +} + +/** + * Site-wide remember-me token hash comparison function + * @param {String} token - Token to compare + * @param {String} hash - Hash to compare + * @returns {String} - Comparison results + */ +module.exports.compareRememberMeToken = async function(token, hash){ + //Compare hash and return result + return await argon2.verify(hash, token); } \ No newline at end of file From 5caa679b9230b81a9a0ba3c6dfd4039865402123 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Sat, 18 Oct 2025 09:42:08 -0400 Subject: [PATCH 48/92] Upgraded password hashing algo to argon2id. --- config.example.json | 10 +++++++--- config.example.jsonc | 23 +++++++++++++++-------- src/schemas/user/userSchema.js | 12 ++++++------ src/server.js | 2 +- src/utils/altchaUtils.js | 4 ++-- src/utils/configCheck.js | 16 +++++++++++++--- src/utils/hashUtils.js | 22 ++++++++++++---------- 7 files changed, 56 insertions(+), 33 deletions(-) diff --git a/config.example.json b/config.example.json index 5e3eb01..cb43df6 100644 --- a/config.example.json +++ b/config.example.json @@ -6,11 +6,15 @@ "protocol": "http", "domain": "localhost", "ytdlpPath": "/home/canopy/.local/pipx/venvs/yt-dlp/bin/yt-dlp", - "sessionSecret": "CHANGE_ME", - "altchaSecret": "CHANGE_ME", - "ipSecret": "CHANGE_ME", "migrate": false, "dropLegacyTokes": false, + "secrets":{ + "passwordSecret": "CHANGE_ME", + "rememberMeSecret": "CHANGE_ME", + "sessionSecret": "CHANGE_ME", + "altchaSecret": "CHANGE_ME", + "ipSecret": "CHANGE_ME" + }, "ssl":{ "cert": "./server.cert", "key": "./server.key" diff --git a/config.example.jsonc b/config.example.jsonc index 01eeab1..02fb88e 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -16,14 +16,6 @@ //Path to YT-DLP Executable for scraping youtube, dailymotion, and vimeo //Dailymotion and Vimeo could work using official apis w/o keys, but you wouldn't have any raw file playback options :P "ytdlpPath": "/home/canopy/.local/pipx/venvs/yt-dlp/bin/yt-dlp", - //Be careful with what you keep in secrets, you should use special chars, but test your deployment, as some chars may break account registration - //An update to either kill the server and bitch about the issue in console is planned so it's not so confusing for new admins - //Session secret used to secure session keys - "sessionSecret": "CHANGE_ME", - //Altacha secret used to generate altcha challenges - "altchaSecret": "CHANGE_ME", - //IP Secret used to salt IP Hashes - "ipSecret": "CHANGE_ME", //Enable to migrate legacy DB and toke files dumped into the ./migration/ directory //WARNING: The migration folder is cleared after server boot, whether or not a migration took place or this option is enabled. //Keep your backups in a safe place, preferably a machine that DOESN'T have open inbound ports exposed to the internet/a publically accessible reverse proxy! @@ -32,6 +24,21 @@ //Requires migration to be disabled before it takes effect. //WARNING: this does NOT affect user toke counts, migrated or otherwise. Use carefully! "dropLegacyTokes": false, + //Server Secrets + //Be careful with what you keep in secrets, you should use special chars, but test your deployment, as some chars may break account registration + //An update to either kill the server and bitch about the issue in console is planned so it's not so confusing for new admins + "secrets":{ + //Password secret used to pepper password hashes + "passwordSecret": "CHANGE_ME", + //Password secret used to pepper rememberMe token hashes + "rememberMeSecret": "CHANGE_ME", + //Session secret used to secure session keys + "sessionSecret": "CHANGE_ME", + //Altacha secret used to generate altcha challenges + "altchaSecret": "CHANGE_ME", + //IP Secret used to pepper IP Hashes + "ipSecret": "CHANGE_ME" + }, //SSL cert and key locations "ssl":{ "cert": "./server.cert", diff --git a/src/schemas/user/userSchema.js b/src/schemas/user/userSchema.js index 209312e..e9368a3 100644 --- a/src/schemas/user/userSchema.js +++ b/src/schemas/user/userSchema.js @@ -164,7 +164,7 @@ 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); + this.pass = await hashUtil.hashPassword(this.pass); } //If the flair was changed @@ -321,7 +321,7 @@ userSchema.statics.authenticate = async function(user, pass, failLine = "Bad Use } //Check our password is correct - if(userDB.checkPass(pass)){ + if(await userDB.checkPass(pass)){ return userDB; }else{ //if not scream and shout @@ -492,8 +492,8 @@ userSchema.statics.processAgedIPRecords = async function(){ * @param {String} pass - Password to authenticate * @returns {Boolean} True if authenticated */ -userSchema.methods.checkPass = function(pass){ - return hashUtil.comparePassword(pass, this.pass) +userSchema.methods.checkPass = async function(pass){ + return await hashUtil.comparePassword(pass, this.pass) } /** @@ -824,7 +824,7 @@ userSchema.methods.killAllSessions = async function(reason = "A full log-out fro * @param {Object} passChange - passChange object handed down from Browser */ userSchema.methods.changePassword = async function(passChange){ - if(this.checkPass(passChange.oldPass)){ + if(await 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; @@ -877,7 +877,7 @@ userSchema.methods.nuke = async function(pass){ } //Check that the password is correct - if(this.checkPass(pass)){ + if(await this.checkPass(pass)){ //delete the user var oldUser = await this.deleteOne(); }else{ diff --git a/src/server.js b/src/server.js index 336071c..8aedf91 100644 --- a/src/server.js +++ b/src/server.js @@ -84,7 +84,7 @@ module.exports.store = mongoStore.create({mongoUrl: dbUrl}); //define sessionMiddleware const sessionMiddleware = session({ - secret: config.sessionSecret, + secret: config.secrets.sessionSecret, resave: false, saveUninitialized: false, store: module.exports.store diff --git a/src/utils/altchaUtils.js b/src/utils/altchaUtils.js index 4c7754f..68ed7e1 100644 --- a/src/utils/altchaUtils.js +++ b/src/utils/altchaUtils.js @@ -44,7 +44,7 @@ module.exports.genCaptcha = async function(difficulty = 2, uniqueSecret = ''){ //Generate Altcha Challenge return await createChallenge({ - hmacKey: [config.altchaSecret, uniqueSecret].join(''), + hmacKey: [config.secrets.altchaSecret, uniqueSecret].join(''), maxNumber: 100000 * difficulty, expires: expiration }); @@ -73,5 +73,5 @@ module.exports.verify = async function(payload, uniqueSecret = ''){ setTimeout(() => {spent.splice(payloadIndex,1);}, lifetime * 60 * 1000); //Return verification results - return await verifySolution(payload, [config.altchaSecret, uniqueSecret].join('')); + return await verifySolution(payload, [config.secrets.altchaSecret, uniqueSecret].join('')); } \ No newline at end of file diff --git a/src/utils/configCheck.js b/src/utils/configCheck.js index a705195..d979b32 100644 --- a/src/utils/configCheck.js +++ b/src/utils/configCheck.js @@ -40,18 +40,28 @@ module.exports.securityCheck = function(){ loggerUtil.consoleWarn("Mail transport security disabled! This server should be used for development purposes only!"); } + //check password pepper + if(!validator.isStrongPassword(config.secrets.passwordSecret) || config.secrets.passwordSecret == "CHANGE_ME"){ + loggerUtil.consoleWarn("Insecure Password Secret! Change Password Secret!"); + } + + //check RememberMe pepper + if(!validator.isStrongPassword(config.secrets.rememberMeSecret) || config.secrets.rememberMeSecret == "CHANGE_ME"){ + loggerUtil.consoleWarn("Insecure RememberMe Secret! Change RememberMe Secret!"); + } + //check session secret - if(!validator.isStrongPassword(config.sessionSecret) || config.sessionSecret == "CHANGE_ME"){ + if(!validator.isStrongPassword(config.secrets.sessionSecret) || config.secrets.sessionSecret == "CHANGE_ME"){ loggerUtil.consoleWarn("Insecure Session Secret! Change Session Secret!"); } //check altcha secret - if(!validator.isStrongPassword(config.altchaSecret) || config.altchaSecret == "CHANGE_ME"){ + if(!validator.isStrongPassword(config.secrets.altchaSecret) || config.secrets.altchaSecret == "CHANGE_ME"){ loggerUtil.consoleWarn("Insecure Altcha Secret! Change Altcha Secret!"); } //check ipHash secret - if(!validator.isStrongPassword(config.ipSecret) || config.ipSecret == "CHANGE_ME"){ + if(!validator.isStrongPassword(config.secrets.ipSecret) || config.secrets.ipSecret == "CHANGE_ME"){ loggerUtil.consoleWarn("Insecure IP Hashing Secret! Change IP Hashing Secret!"); } diff --git a/src/utils/hashUtils.js b/src/utils/hashUtils.js index b9086cc..e60d81e 100644 --- a/src/utils/hashUtils.js +++ b/src/utils/hashUtils.js @@ -29,9 +29,9 @@ const bcrypt = require('bcrypt'); * @param {String} pass - Password to hash * @returns {String} Hashed/Salted password */ -module.exports.hashPassword = function(pass){ - const salt = bcrypt.genSaltSync(); - return bcrypt.hashSync(pass, salt); +module.exports.hashPassword = async function(pass){ + //Hash password with argon2id + return await argon2.hash(pass, {secret: Buffer.from(config.secrets.passwordSecret)}); } /** @@ -40,8 +40,9 @@ module.exports.hashPassword = function(pass){ * @param {String} hash - Salty Hash * @returns {Boolean} True if authentication success */ -module.exports.comparePassword = function(pass, hash){ - return bcrypt.compareSync(pass, hash); +module.exports.comparePassword = async function(pass, hash){ + //Verify password against argon2 hash + return await argon2.verify(hash, pass, {secret: Buffer.from(config.secrets.passwordSecret)}); } /** @@ -59,14 +60,14 @@ module.exports.compareLegacyPassword = function(pass, hash){ * * Provides a basic level of privacy by only logging salted hashes of IP's * @param {String} ip - IP to hash - * @returns {String} Hashed/Salted IP Adress + * @returns {String} Hashed/Peppered IP Adress */ module.exports.hashIP = function(ip){ //Create hash object const hashObj = crypto.createHash('sha512'); - //add IP and salt to the hash - hashObj.update(`${ip}${config.ipSecret}`); + //add IP and pepper to the hash + hashObj.update(`${ip}${config.secrets.ipSecret}`); //return the IP hash as a string return hashObj.digest('hex'); @@ -78,7 +79,8 @@ module.exports.hashIP = function(ip){ * @returns {String} - Hashed token */ module.exports.hashRememberMeToken = async function(token){ - return await argon2.hash(token); + //hash token with argon2id + return await argon2.hash(token, {secret: Buffer.from(config.secrets.rememberMeSecret)}); } /** @@ -89,5 +91,5 @@ module.exports.hashRememberMeToken = async function(token){ */ module.exports.compareRememberMeToken = async function(token, hash){ //Compare hash and return result - return await argon2.verify(hash, token); + return await argon2.verify(hash, token, {secret: Buffer.from(config.secrets.rememberMeSecret)}); } \ No newline at end of file From 95ed2fa40311f54e6735a73c64e288a5560ac7ee Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Mon, 20 Oct 2025 05:15:34 -0400 Subject: [PATCH 49/92] Prepped session utils for remember me tokens. --- src/utils/sessionUtils.js | 73 ++++++++++++++++++++++++--------------- 1 file changed, 46 insertions(+), 27 deletions(-) diff --git a/src/utils/sessionUtils.js b/src/utils/sessionUtils.js index b1b15cd..584411d 100644 --- a/src/utils/sessionUtils.js +++ b/src/utils/sessionUtils.js @@ -41,16 +41,19 @@ const maxAttempts = 200; * Sole and Singular Session Authentication method. * All logins should happen through here, all other site-wide authentication should happen by sessions authenticated by this model. * This is important, as reducing authentication endpoints reduces attack surface. - * @param {String} user - Username to login as - * @param {String} pass - Password to authenticat session with + * + * Ended up not splitting this in two/three for remember-me tokens. Kind of fucked up it was actually easier this way... + * @param {String} identifier - Identifer used to identify account, either username or token UUID + * @param {String} secret - Secret to authenticate session with, either password or token secret * @param {express.Request} req - Express request object w/ session to authenticate + * @param {Boolean} useRememberMeToken - Whether or not we're using username/pass or remember-me tokens * @returns Username of authticated user upon success */ -module.exports.authenticateSession = async function(user, pass, req){ +module.exports.authenticateSession = async function(identifier, secret, req, useRememberMeToken = false){ //Fuck you yoda try{ //Grab previous attempts - const attempt = failedAttempts.get(user); + const attempt = failedAttempts.get(identifier); //If we're proxied use passthrough IP const ip = config.proxied ? req.headers['x-forwarded-for'] : req.ip; @@ -74,7 +77,7 @@ module.exports.authenticateSession = async function(user, pass, req){ } //If we have failed attempts - if(attempt != null){ + if(!useRememberMeToken && attempt != null){ //If we have more failed attempts than allowed if(attempt.count > maxAttempts){ throw loggerUtils.exceptionSmith("This account has been locked for at 24 hours due to a large amount of failed log-in attempts", "unauthorized"); @@ -86,14 +89,23 @@ module.exports.authenticateSession = async function(user, pass, req){ //Since we've already got access to the request and dont need to import anything, why bother getting it from a parameter? if(req.body.verification == null){ throw loggerUtils.exceptionSmith("Verification failed!", "unauthorized"); - }else if(!altchaUtils.verify(req.body.verification, user)){ + }else if(!altchaUtils.verify(req.body.verification, identifier)){ throw loggerUtils.exceptionSmith("Verification failed!", ""); } } } - //Authenticate the session - const userDB = await userModel.authenticate(user, pass); + //define/scope empty userDB variable + let userDB = null; + + //If we're using remember me tokens + if(useRememberMeToken){ + + //Otherwise + }else{ + //Fallback on to username/password authentication + userDB = await userModel.authenticate(identifier, secret); + } //Check for user ban const userBanDB = await userBanModel.checkBanByUserDoc(userDB); @@ -123,33 +135,40 @@ module.exports.authenticateSession = async function(user, pass, req){ //Tattoo hashed IP address to user account for seven days userDB.tattooIPRecord(ip); - //If we got to here then the log-in was successful. We should clear-out any failed attempts. - failedAttempts.delete(user); + if(!useRememberMeToken){ + //If we got to here then the log-in was successful. We should clear-out any failed attempts. + failedAttempts.delete(identifier); + } //return user return userDB.user; }catch(err){ - //Look for previous failed attempts - var attempt = failedAttempts.get(user); + //Failed attempts at good tokens are handled by the token schema by dropping the users effected tokens and screaming bloody murder + //Failed attempts with bad tokens don't need to be handled as it's not like attacking a bad UUID is going to get you anywhere anywho + //This also makes it way easier to re-use parts of this function + if(!useRememberMeToken){ + //Look for previous failed attempts + var attempt = failedAttempts.get(identifier); - //If this is the first attempt - if(attempt == null){ - //Create new attempt object - attempt = { - count: 1, - lastAttempt: new Date() - } - }else{ - //Create updated attempt object - attempt = { - count: attempt.count + 1, - lastAttempt: new Date() + //If this is the first attempt + if(attempt == null){ + //Create new attempt object + attempt = { + count: 1, + lastAttempt: new Date() + } + }else{ + //Create updated attempt object + attempt = { + count: attempt.count + 1, + lastAttempt: new Date() + } } + + //Commit the failed attempt to the failed sign-in cache + failedAttempts.set(identifier, attempt); } - //Commit the failed attempt to the failed sign-in cache - failedAttempts.set(user, attempt); - //y33t throw err; } From e00e5a608be09f4955340ad6e6b67b9d31d3a5fa Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Mon, 20 Oct 2025 07:49:41 -0400 Subject: [PATCH 50/92] Continued work on remember me tokens. --- package.json | 1 + .../api/account/loginController.js | 48 ++++++++++++++---- src/schemas/user/rememberMeSchema.js | 16 +++--- src/server.js | 17 ++++++- src/utils/sessionUtils.js | 1 + src/validators/accountValidator.js | 50 +++++++++++++------ src/views/login.ejs | 1 + src/views/partial/navbar.ejs | 2 + www/js/login.js | 6 ++- www/js/navbar.js | 3 +- www/js/utils.js | 4 +- 11 files changed, 113 insertions(+), 36 deletions(-) diff --git a/package.json b/package.json index 86045a3..0076162 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "bcrypt": "^5.1.1", "bootstrap-icons": "^1.11.3", "connect-mongo": "^5.1.0", + "cookie-parser": "^1.4.7", "csrf-sync": "^4.0.3", "ejs": "^3.1.10", "express": "^4.18.2", diff --git a/src/controllers/api/account/loginController.js b/src/controllers/api/account/loginController.js index f5f2130..e7c5345 100644 --- a/src/controllers/api/account/loginController.js +++ b/src/controllers/api/account/loginController.js @@ -22,6 +22,7 @@ const {validationResult, matchedData} = require('express-validator'); //local imports const migrationModel = require('../../../schemas/user/migrationSchema.js'); +const rememberMeModel = require('../../../schemas/user/rememberMeSchema.js'); const sessionUtils = require('../../../utils/sessionUtils'); const hashUtils = require('../../../utils/hashUtils.js'); const {exceptionHandler, errorHandler} = require('../../../utils/loggerUtils'); @@ -35,10 +36,39 @@ module.exports.post = async function(req, res){ //if we don't have errors if(validResult.isEmpty()){ //Pull sanatzied/validated data - const {user, pass} = matchedData(req); - - //try to authenticate the session, and return a successful code if it works - await sessionUtils.authenticateSession(user, pass, req); + const data = matchedData(req); + + //try to authenticate the session, throwing an error and breaking the current code block if user is un-authorized + await sessionUtils.authenticateSession(data.user, data.pass, req); + + //If the user already has a remember me token + if(data.rememberme != null && data.rememberme.id != null){ + //Fucking nuke the bitch + await rememberMeModel.deleteOne({id: data.rememberme.id}) + + //Tell the client to drop the token + res.clearCookie("rememberme.id"); + res.clearCookie("rememberme.token"); + } + + //If the user requested a rememberMe token (I'm not validation checking a fucking boolean) + if(req.body.rememberMe){ + //Gen user token + //requires second DB call, but this enforces password requirement for toke generation while ensuring we only + //need one function in the userModel for authentication, even if the second woulda just been a wrapper. + //Less attack surface is less attack surface, and this isn't something thats going to be getting constantly called + const authToken = await rememberMeModel.genToken(data.user, data.pass); + + //Check config for protocol + const secure = config.protocol.toLowerCase() == "https"; + + //Set remember me ID and token as browser-side cookies for safe-keeping + res.cookie("rememberme.id", authToken.id, {sameSite: 'strict', httpOnly: true, secure}); + //This should be the servers last interaction with the plaintext token before saving the hashed copy, and dropping it out of RAM + res.cookie("rememberme.token", authToken.token, {sameSite: 'strict', httpOnly: true, secure}); + } + + //Tell the browser everything is dandy return res.sendStatus(200); }else{ res.status(400); @@ -64,22 +94,22 @@ module.exports.post = async function(req, res){ return res.sendStatus(301); } } - + //Get login attempts const attempts = sessionUtils.getLoginAttempts(user) //if we've gone over max attempts - if(attempts.count > sessionUtils.throttleAttempts){ + if(attempts != null && attempts.count > sessionUtils.throttleAttempts){ //tell client it needs a captcha return res.sendStatus(429); + }else{ + //Scream about any un-caught errors + return exceptionHandler(res, err); } }else{ res.status(400); return res.send({errors: validResult.array()}) } - - //Scream about any un-caught errors - return exceptionHandler(res, err); } } \ No newline at end of file diff --git a/src/schemas/user/rememberMeSchema.js b/src/schemas/user/rememberMeSchema.js index 6b63942..3ac78ef 100644 --- a/src/schemas/user/rememberMeSchema.js +++ b/src/schemas/user/rememberMeSchema.js @@ -27,7 +27,7 @@ const crypto = require("node:crypto"); const {mongoose} = require('mongoose'); //Local Imports -const userSchema = require('./userSchema'); +const {userModel} = require('./userSchema'); const hashUtil = require('../../utils/hashUtils'); const loggerUtils = require('../../utils/loggerUtils'); @@ -67,10 +67,14 @@ const rememberMeToken = new mongoose.Schema({ * Pre-Save function for rememberMeSchema */ rememberMeToken.pre('save', async function (next){ + //Ensure tokens ALWAYS get a new UUID and creation date + this.id = crypto.randomUUID(); + this.date = new Date(); + //If the token was changed if(this.isModified("token")){ //Hash that sunnovabitch, no questions asked. - this.token = hashUtil.hashRememberMeToken(this.token); + this.token = await hashUtil.hashRememberMeToken(this.token); } //All is good, continue on saving. @@ -79,10 +83,10 @@ rememberMeToken.pre('save', async function (next){ //statics rememberMeToken.statics.genToken = async function(user, pass){ - try{ - //Authenticate user and pull document - const userDB = await userSchema.authenticate(user, pass); + //Authenticate user and pull document + const userDB = await userModel.authenticate(user, pass); + try{ //Generate a cryptographically secure string of 32 bytes in hexidecimal const token = crypto.randomBytes(32).toString('hex'); @@ -94,7 +98,7 @@ rememberMeToken.statics.genToken = async function(user, pass){ id: tokenDB.id, token }; - //If we failed (most likely for bad login) + //If we failed for a non-login reason }catch(err){ return loggerUtils.localExceptionHandler(err); } diff --git a/src/server.js b/src/server.js index 8aedf91..87472a2 100644 --- a/src/server.js +++ b/src/server.js @@ -25,6 +25,7 @@ const fs = require('fs'); const express = require('express'); const session = require('express-session'); const {createServer } = require('http'); +const cookieParser = require('cookie-parser'); const { Server } = require('socket.io'); const path = require('path'); const mongoStore = require('connect-mongo'); @@ -38,6 +39,8 @@ const pmHandler = require('./app/pm/pmHandler'); const configCheck = require('./utils/configCheck'); const scheduler = require('./utils/scheduler'); const {errorMiddleware} = require('./utils/loggerUtils'); +//Validator +const accountValidator = require('./validators/accountValidator'); //DB Model const statModel = require('./schemas/statSchema'); const flairModel = require('./schemas/flairSchema'); @@ -87,7 +90,11 @@ const sessionMiddleware = session({ secret: config.secrets.sessionSecret, resave: false, saveUninitialized: false, - store: module.exports.store + store: module.exports.store, + cookie: { + sameSite: "strict", + secure: config.protocol.toLowerCase() == "https" + } }); //Declare web server @@ -143,7 +150,9 @@ app.set('views', __dirname + '/views'); //Middlware //Enable Express app.use(express.json()); -//app.use(express.urlencoded()); + +//Enable Express Ccokie-Parser +app.use(cookieParser()); //Enable Express-Sessions app.use(sessionMiddleware); @@ -151,6 +160,10 @@ app.use(sessionMiddleware); //Enable Express-Session w/ Socket.IO io.engine.use(sessionMiddleware); +//Use rememberMe validators accross all requests. +app.use(accountValidator.rememberMeID()); +app.use(accountValidator.rememberMeToken()); + //Routes //Humie-Friendly app.use('/', indexRouter); diff --git a/src/utils/sessionUtils.js b/src/utils/sessionUtils.js index 584411d..970718b 100644 --- a/src/utils/sessionUtils.js +++ b/src/utils/sessionUtils.js @@ -18,6 +18,7 @@ along with this program. If not, see .*/ const config = require('../../config.json'); const {userModel} = require('../schemas/user/userSchema.js'); const userBanModel = require('../schemas/user/userBanSchema.js'); +const rememberMeModel = require('../schemas/user/rememberMeSchema.js'); const altchaUtils = require('../utils/altchaUtils.js'); const loggerUtils = require('../utils/loggerUtils.js'); diff --git a/src/validators/accountValidator.js b/src/validators/accountValidator.js index 4e031d3..806aa43 100644 --- a/src/validators/accountValidator.js +++ b/src/validators/accountValidator.js @@ -177,19 +177,41 @@ module.exports.rank = function(field = 'rank'){ }); } -module.exports.securityToken = function(field = 'token'){ - return checkSchema({ - [field]: { - escape: true, - trim: true, - isHexadecimal: true, - isLength: { - options: { - min: 64, - max: 64 - } - }, - errorMessage: "Invalid security token." +const securityTokenSchema = { + escape: true, + trim: true, + isHexadecimal: true, + isLength: { + options: { + min: 64, + max: 64 } - }); + }, + errorMessage: "Invalid security token." +} + +module.exports.securityToken = function(field = 'token'){ + return checkSchema({[field]:securityTokenSchema}); +} + +module.exports.rememberMeID = function(field = 'rememberme.id'){ + return checkSchema({ + [field]:{ + in: ['cookies'], + optional: true, + isUUID: true + } + }) +} + +module.exports.rememberMeToken = function(field = 'rememberme.token'){ + //Create our own schema with blackjack and hookers + const tokenSchema = structuredClone(securityTokenSchema); + + //Modify as needed + tokenSchema.in = ['cookies']; + tokenSchema.optional = true; + + //Return the validator + return checkSchema({[field]:tokenSchema}); } \ No newline at end of file diff --git a/src/views/login.ejs b/src/views/login.ejs index 1a5e63f..12e8e78 100644 --- a/src/views/login.ejs +++ b/src/views/login.ejs @@ -38,6 +38,7 @@ along with this program. If not, see . %> <% if(challenge != null){ %> <% } %> + Create New Account Forgot Password diff --git a/src/views/partial/navbar.ejs b/src/views/partial/navbar.ejs index 924efba..3dc3065 100644 --- a/src/views/partial/navbar.ejs +++ b/src/views/partial/navbar.ejs @@ -19,6 +19,8 @@ along with this program. If not, see . %> <% if(user){ %> <% }else{ %> + + diff --git a/www/js/login.js b/www/js/login.js index c5a3408..cb93172 100644 --- a/www/js/login.js +++ b/www/js/login.js @@ -21,6 +21,8 @@ class registerPrompt{ this.user.value = window.location.search.replace("?user=",''); //Grab pass prompts this.pass = document.querySelector("#login-page-password"); + //Remember me checkbox + this.rememberMe = document.querySelector("#login-page-remember-me"); //Grab register button this.button = document.querySelector("#login-page-button"); //Grab altcha widget @@ -58,10 +60,10 @@ class registerPrompt{ } //login with verification - utils.ajax.login(this.user.value , this.pass.value, this.verification); + utils.ajax.login(this.user.value , this.pass.value, this.rememberMe.checked, this.verification); }else{ //login - utils.ajax.login(this.user.value, this.pass.value); + utils.ajax.login(this.user.value, this.pass.value, this.rememberMe.checked); } } } diff --git a/www/js/navbar.js b/www/js/navbar.js index 69697fd..ce9bac3 100644 --- a/www/js/navbar.js +++ b/www/js/navbar.js @@ -19,6 +19,7 @@ async function navbarLogin(event){ if(!event || !event.key || event.key == "Enter"){ var user = document.querySelector("#username-prompt").value; var pass = document.querySelector("#password-prompt").value; + var rememberMe = document.querySelector("#remember-me").checked; //If no user or pass is presented if(user == "" || pass == ""){ @@ -26,7 +27,7 @@ async function navbarLogin(event){ window.location = '/login' } - utils.ajax.login(user, pass); + utils.ajax.login(user, pass, rememberMe); } } diff --git a/www/js/utils.js b/www/js/utils.js index 9e023f1..2db077a 100644 --- a/www/js/utils.js +++ b/www/js/utils.js @@ -755,14 +755,14 @@ class canopyAjaxUtils{ } } - async login(user, pass, verification){ + async login(user, pass, rememberMe, verification){ const response = await fetch(`/api/account/login`,{ method: "POST", headers: { "Content-Type": "application/json", "x-csrf-token": utils.ajax.getCSRFToken() }, - body: JSON.stringify(verification ? {user, pass, verification} : {user, pass}) + body: JSON.stringify(verification ? {user, pass, rememberMe, verification} : {user, rememberMe, pass}) }); if(response.ok){ From 61ec3ffc5286a595bf3fa86bea482da189e60433 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Tue, 21 Oct 2025 00:10:17 -0400 Subject: [PATCH 51/92] Finished up with remember me middleware. --- .../api/account/loginController.js | 7 ++- src/schemas/user/rememberMeSchema.js | 47 +++++++++++++++++++ src/server.js | 20 ++++---- src/utils/sessionUtils.js | 44 ++++++++++++++++- 4 files changed, 107 insertions(+), 11 deletions(-) diff --git a/src/controllers/api/account/loginController.js b/src/controllers/api/account/loginController.js index e7c5345..910ff5e 100644 --- a/src/controllers/api/account/loginController.js +++ b/src/controllers/api/account/loginController.js @@ -62,10 +62,13 @@ module.exports.post = async function(req, res){ //Check config for protocol const secure = config.protocol.toLowerCase() == "https"; + //Create expiration date for cookies (180 days) + const expires = new Date(Date.now() + (1000 * 60 * 60 * 24 * 180)) + //Set remember me ID and token as browser-side cookies for safe-keeping - res.cookie("rememberme.id", authToken.id, {sameSite: 'strict', httpOnly: true, secure}); + res.cookie("rememberme.id", authToken.id, {sameSite: 'strict', httpOnly: true, secure, expires}); //This should be the servers last interaction with the plaintext token before saving the hashed copy, and dropping it out of RAM - res.cookie("rememberme.token", authToken.token, {sameSite: 'strict', httpOnly: true, secure}); + res.cookie("rememberme.token", authToken.token, {sameSite: 'strict', httpOnly: true, secure, expires}); } //Tell the browser everything is dandy diff --git a/src/schemas/user/rememberMeSchema.js b/src/schemas/user/rememberMeSchema.js index 3ac78ef..74fc11d 100644 --- a/src/schemas/user/rememberMeSchema.js +++ b/src/schemas/user/rememberMeSchema.js @@ -81,6 +81,12 @@ rememberMeToken.pre('save', async function (next){ next(); }); +//Methods +rememberMeToken.methods.checkToken = async function(token){ + //Compare ingested token to saved hash + return await hashUtil.compareRememberMeToken(token, this.token); +} + //statics rememberMeToken.statics.genToken = async function(user, pass){ //Authenticate user and pull document @@ -104,4 +110,45 @@ rememberMeToken.statics.genToken = async function(user, pass){ } } +/** + * Authenticates an id and token pair + * @param {String} id - id of token auth against + * @param {String} token - token string to auth against + * @param {String} failLine - Line to paste into custom error upon login failure + * @returns {Mongoose.Document} - User DB Document upon success + */ +rememberMeToken.statics.authenticate = async function(id, token, failLine = "Bad Username or Password."){ + //check for missing pass + if(!id || !token){ + throw loggerUtils.exceptionSmith("Missing id/token.", "validation"); + } + + //get the token if it exists + const tokenDB = await this.findOne({id}); + + //if not scream and shout + if(!tokenDB){ + badLogin(); + } + + //Check our password is correct + if(await tokenDB.checkToken(token)){ + //Populate the user field + await tokenDB.populate('user'); + + //Return the user doc + return tokenDB.user; + }else{ + //Nuke the token for security + await tokenDB.deleteOne(); + //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"); + } +} + module.exports = mongoose.model("rememberMe", rememberMeToken); \ No newline at end of file diff --git a/src/server.js b/src/server.js index 87472a2..3f47c43 100644 --- a/src/server.js +++ b/src/server.js @@ -39,6 +39,7 @@ const pmHandler = require('./app/pm/pmHandler'); const configCheck = require('./utils/configCheck'); const scheduler = require('./utils/scheduler'); const {errorMiddleware} = require('./utils/loggerUtils'); +const sessionUtils = require('./utils/sessionUtils'); //Validator const accountValidator = require('./validators/accountValidator'); //DB Model @@ -143,6 +144,14 @@ mongoose.set("sanitizeFilter", true).connect(dbUrl).then(() => { process.exit(); }); +//Static File Server, set this up first to avoid middleware running on top of it +//Serve client-side libraries +app.use('/lib/bootstrap-icons',express.static(path.join(__dirname, '../node_modules/bootstrap-icons'))); //Icon set +app.use('/lib/altcha',express.static(path.join(__dirname, '../node_modules/altcha/dist_external'))); //Self-Hosted PoW-based Captcha +app.use('/lib/hls.js',express.static(path.join(__dirname, '../node_modules/hls.js/dist'))); //HLS Media Handler +//Server public 'www' folder +app.use(express.static(path.join(__dirname, '../www'))); + //Set View Engine app.set('view engine', 'ejs'); app.set('views', __dirname + '/views'); @@ -164,6 +173,9 @@ io.engine.use(sessionMiddleware); app.use(accountValidator.rememberMeID()); app.use(accountValidator.rememberMeToken()); +//Use remember me middleware +app.use(sessionUtils.rememberMeMiddleware); + //Routes //Humie-Friendly app.use('/', indexRouter); @@ -183,14 +195,6 @@ app.use('/tooltip', tooltipRouter); //Bot-Ready app.use('/api', apiRouter); -//Static File Server -//Serve client-side libraries -app.use('/lib/bootstrap-icons',express.static(path.join(__dirname, '../node_modules/bootstrap-icons'))); //Icon set -app.use('/lib/altcha',express.static(path.join(__dirname, '../node_modules/altcha/dist_external'))); //Self-Hosted PoW-based Captcha -app.use('/lib/hls.js',express.static(path.join(__dirname, '../node_modules/hls.js/dist'))); //HLS Media Handler -//Server public 'www' folder -app.use(express.static(path.join(__dirname, '../www'))); - //Handle error checking app.use(errorMiddleware); diff --git a/src/utils/sessionUtils.js b/src/utils/sessionUtils.js index 970718b..fd166a9 100644 --- a/src/utils/sessionUtils.js +++ b/src/utils/sessionUtils.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 .*/ +//npm imports +const {validationResult, matchedData} = require('express-validator'); + //Local Imports const config = require('../../config.json'); const {userModel} = require('../schemas/user/userSchema.js'); @@ -101,7 +104,7 @@ module.exports.authenticateSession = async function(identifier, secret, req, use //If we're using remember me tokens if(useRememberMeToken){ - + userDB = await rememberMeModel.authenticate(identifier, secret); //Otherwise }else{ //Fallback on to username/password authentication @@ -211,5 +214,44 @@ module.exports.processExpiredAttempts = function(){ } } +module.exports.rememberMeMiddleware = function(req, res, next){ + //if we have an un-authenticated user + if(req.session.user == null || req.session.user == ""){ + //Check validation result + const validResult = validationResult(req); + + //if we don't have errors + if(validResult.isEmpty()){ + //Pull verified data from request + const data = matchedData(req); + + //If we have a valid remember me id and token + if(data.rememberme != null && data.rememberme.id != null && data.rememberme.token != null){ + //Authenticate against standard auth function in remember me mode + module.exports.authenticateSession(data.rememberme.id, data.rememberme.token, req, true).then((userDB)=>{ + //Jump to next middleware + next(); + }).catch((err)=>{ + //Clear out remember me fields + res.clearCookie('rememberme.id'); + res.clearCookie('rememberme.token'); + + //Bitch, Moan, and guess what? That's fuckin' right! COMPLAIN!!!! + return loggerUtils.exceptionHandler(res, err); + }); + }else{ + //Jump to next middleware, this looks gross but it's only because they made me use .then like a bunch of fucking dicks + next(); + } + }else{ + //Jump to next middleware + next(); + } + }else{ + //Jump to next middleware + next(); + } +} + module.exports.throttleAttempts = throttleAttempts; module.exports.maxAttempts = maxAttempts; \ No newline at end of file From 1d5a087d79a302167b8a9bac24062c66a0521798 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Tue, 21 Oct 2025 00:21:44 -0400 Subject: [PATCH 52/92] Server now deletes associated remember-me token on user requested log-outs. --- .../api/account/loginController.js | 2 +- .../api/account/logoutController.js | 29 +++++++++++++++++-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/controllers/api/account/loginController.js b/src/controllers/api/account/loginController.js index 910ff5e..293842c 100644 --- a/src/controllers/api/account/loginController.js +++ b/src/controllers/api/account/loginController.js @@ -63,7 +63,7 @@ module.exports.post = async function(req, res){ const secure = config.protocol.toLowerCase() == "https"; //Create expiration date for cookies (180 days) - const expires = new Date(Date.now() + (1000 * 60 * 60 * 24 * 180)) + const expires = new Date(Date.now() + (1000 * 60 * 60 * 24 * 180)); //Set remember me ID and token as browser-side cookies for safe-keeping res.cookie("rememberme.id", authToken.id, {sameSite: 'strict', httpOnly: true, secure, expires}); diff --git a/src/controllers/api/account/logoutController.js b/src/controllers/api/account/logoutController.js index 0499469..1964edc 100644 --- a/src/controllers/api/account/logoutController.js +++ b/src/controllers/api/account/logoutController.js @@ -15,13 +15,36 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see .*/ //local imports -const accountUtils = require('../../../utils/sessionUtils'); -const {exceptionHandler, errorHandler} = require('../../../utils/loggerUtils'); +const rememberMeModel = require('../../../schemas/user/rememberMeSchema'); +const sessionUtils = require('../../../utils/sessionUtils'); +const {exceptionHandler} = require('../../../utils/loggerUtils'); +const {validationResult, matchedData} = require('express-validator'); module.exports.post = async function(req, res){ if(req.session.user){ try{ - accountUtils.killSession(req.session); + sessionUtils.killSession(req.session); + + //Check validation results + const validResult = validationResult(req); + + //if we don't have errors + if(validResult.isEmpty()){ + //Pull sanatzied/validated data + const data = matchedData(req); + + //If the user has a remember me token id they've submitted with the request + if(data.rememberme.id){ + //Find the associated token and nuke it + await rememberMeModel.deleteOne({id: data.rememberme.id}) + } + } + + //Clear out remember me tokens + res.clearCookie("rememberme.id"); + res.clearCookie("rememberme.token"); + + //Return status return res.sendStatus(200); }catch(err){ return exceptionHandler(res, err); From 3fb71ffb7890f7e9d9268687fd7579a4ffc30193 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Tue, 21 Oct 2025 07:42:20 -0400 Subject: [PATCH 53/92] Improved Documentation. --- src/utils/sessionUtils.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/utils/sessionUtils.js b/src/utils/sessionUtils.js index fd166a9..7d4a94b 100644 --- a/src/utils/sessionUtils.js +++ b/src/utils/sessionUtils.js @@ -214,6 +214,12 @@ module.exports.processExpiredAttempts = function(){ } } +/** + * Express Middleware for handling remember-me authentication tokens + * @param {express.Request} req - Express Request Object + * @param {express.Response} res - Express Response Object + * @param {function} next - Function to call upon next middleware + */ module.exports.rememberMeMiddleware = function(req, res, next){ //if we have an un-authenticated user if(req.session.user == null || req.session.user == ""){ From bc0657a70243646cc538b009f9a352057e49a32a Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Tue, 21 Oct 2025 07:59:15 -0400 Subject: [PATCH 54/92] Remember me tokens now nuked upon full account logout. --- .../api/account/loginController.js | 23 +++++++++++-------- .../api/account/logoutController.js | 2 +- src/schemas/user/rememberMeSchema.js | 11 +++++---- src/schemas/user/userSchema.js | 4 ++++ src/utils/sessionUtils.js | 2 +- 5 files changed, 26 insertions(+), 16 deletions(-) diff --git a/src/controllers/api/account/loginController.js b/src/controllers/api/account/loginController.js index 293842c..d509342 100644 --- a/src/controllers/api/account/loginController.js +++ b/src/controllers/api/account/loginController.js @@ -39,7 +39,7 @@ module.exports.post = async function(req, res){ const data = matchedData(req); //try to authenticate the session, throwing an error and breaking the current code block if user is un-authorized - await sessionUtils.authenticateSession(data.user, data.pass, req); + const userDB = await sessionUtils.authenticateSession(data.user, data.pass, req); //If the user already has a remember me token if(data.rememberme != null && data.rememberme.id != null){ @@ -57,18 +57,21 @@ module.exports.post = async function(req, res){ //requires second DB call, but this enforces password requirement for toke generation while ensuring we only //need one function in the userModel for authentication, even if the second woulda just been a wrapper. //Less attack surface is less attack surface, and this isn't something thats going to be getting constantly called - const authToken = await rememberMeModel.genToken(data.user, data.pass); + const authToken = await rememberMeModel.genToken(userDB, data.pass); - //Check config for protocol - const secure = config.protocol.toLowerCase() == "https"; + //If we properly authed + if(authToken != null){ + //Check config for protocol + const secure = config.protocol.toLowerCase() == "https"; - //Create expiration date for cookies (180 days) - const expires = new Date(Date.now() + (1000 * 60 * 60 * 24 * 180)); + //Create expiration date for cookies (180 days) + const expires = new Date(Date.now() + (1000 * 60 * 60 * 24 * 180)); - //Set remember me ID and token as browser-side cookies for safe-keeping - res.cookie("rememberme.id", authToken.id, {sameSite: 'strict', httpOnly: true, secure, expires}); - //This should be the servers last interaction with the plaintext token before saving the hashed copy, and dropping it out of RAM - res.cookie("rememberme.token", authToken.token, {sameSite: 'strict', httpOnly: true, secure, expires}); + //Set remember me ID and token as browser-side cookies for safe-keeping + res.cookie("rememberme.id", authToken.id, {sameSite: 'strict', httpOnly: true, secure, expires}); + //This should be the servers last interaction with the plaintext token before saving the hashed copy, and dropping it out of RAM + res.cookie("rememberme.token", authToken.token, {sameSite: 'strict', httpOnly: true, secure, expires}); + } } //Tell the browser everything is dandy diff --git a/src/controllers/api/account/logoutController.js b/src/controllers/api/account/logoutController.js index 1964edc..0688f42 100644 --- a/src/controllers/api/account/logoutController.js +++ b/src/controllers/api/account/logoutController.js @@ -34,7 +34,7 @@ module.exports.post = async function(req, res){ const data = matchedData(req); //If the user has a remember me token id they've submitted with the request - if(data.rememberme.id){ + if(data.rememberme != null && data.rememberme.id != null){ //Find the associated token and nuke it await rememberMeModel.deleteOne({id: data.rememberme.id}) } diff --git a/src/schemas/user/rememberMeSchema.js b/src/schemas/user/rememberMeSchema.js index 74fc11d..dfb925b 100644 --- a/src/schemas/user/rememberMeSchema.js +++ b/src/schemas/user/rememberMeSchema.js @@ -27,7 +27,6 @@ const crypto = require("node:crypto"); const {mongoose} = require('mongoose'); //Local Imports -const {userModel} = require('./userSchema'); const hashUtil = require('../../utils/hashUtils'); const loggerUtils = require('../../utils/loggerUtils'); @@ -88,9 +87,13 @@ rememberMeToken.methods.checkToken = async function(token){ } //statics -rememberMeToken.statics.genToken = async function(user, pass){ - //Authenticate user and pull document - const userDB = await userModel.authenticate(user, pass); +rememberMeToken.statics.genToken = async function(userDB, pass){ + //Normally I'd use userModel auth, but this saves on DB calls and keeps us from having to refrence the userModel directly + //Saving us from circular depedency hell + //Plus this is only really getting called along-side other auth, theres already going to be an error message if this is wrong XP + if(!await userDB.checkPass(pass)){ + return; + } try{ //Generate a cryptographically secure string of 32 bytes in hexidecimal diff --git a/src/schemas/user/userSchema.js b/src/schemas/user/userSchema.js index e9368a3..cc4d364 100644 --- a/src/schemas/user/userSchema.js +++ b/src/schemas/user/userSchema.js @@ -28,6 +28,7 @@ const permissionModel = require('../permissionSchema'); const emoteModel = require('../emoteSchema'); const emailChangeModel = require('./emailChangeSchema'); const playlistSchema = require('../channel/media/playlistSchema'); +const rememberMeModel = require('./rememberMeSchema'); //Utils const hashUtil = require('../../utils/hashUtils'); const mailUtil = require('../../utils/mailUtils'); @@ -807,6 +808,9 @@ userSchema.methods.tattooIPRecord = async function(ip){ * @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."){ + //Nuke all related remember me tokens + await rememberMeModel.deleteMany({user: this._id}); + //get authenticated sessions var sessions = await this.getAuthenticatedSessions(); diff --git a/src/utils/sessionUtils.js b/src/utils/sessionUtils.js index 7d4a94b..7c5ad33 100644 --- a/src/utils/sessionUtils.js +++ b/src/utils/sessionUtils.js @@ -145,7 +145,7 @@ module.exports.authenticateSession = async function(identifier, secret, req, use } //return user - return userDB.user; + return userDB; }catch(err){ //Failed attempts at good tokens are handled by the token schema by dropping the users effected tokens and screaming bloody murder //Failed attempts with bad tokens don't need to be handled as it's not like attacking a bad UUID is going to get you anywhere anywho From d874f5e2daa7eb028e9c54150e7559bb3924dce4 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Tue, 21 Oct 2025 08:09:55 -0400 Subject: [PATCH 55/92] Cleaned up remember-me error handling. --- src/utils/loggerUtils.js | 4 ++-- src/utils/sessionUtils.js | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/utils/loggerUtils.js b/src/utils/loggerUtils.js index 3e9c0aa..075f9ed 100644 --- a/src/utils/loggerUtils.js +++ b/src/utils/loggerUtils.js @@ -64,8 +64,8 @@ module.exports.errorHandler = function(res, msg, type = "Generic", status = 400) * @param {Error} err - Exception to handle */ module.exports.localExceptionHandler = function(err){ - //If we're being verbose - if(config.verbose){ + //If we're being verbose and this isn't just a basic bitch + if(!err.custom && config.verbose){ //Log the error module.exports.dumpError(err); } diff --git a/src/utils/sessionUtils.js b/src/utils/sessionUtils.js index 7c5ad33..655dd46 100644 --- a/src/utils/sessionUtils.js +++ b/src/utils/sessionUtils.js @@ -242,8 +242,11 @@ module.exports.rememberMeMiddleware = function(req, res, next){ res.clearCookie('rememberme.id'); res.clearCookie('rememberme.token'); - //Bitch, Moan, and guess what? That's fuckin' right! COMPLAIN!!!! - return loggerUtils.exceptionHandler(res, err); + //Quietly handle exceptions without pestering the user + loggerUtils.localExceptionHandler(err); + + //Go on with life + next(); }); }else{ //Jump to next middleware, this looks gross but it's only because they made me use .then like a bunch of fucking dicks From 1e48d3af2c6dad405a00094d26bb70021b3b899d Mon Sep 17 00:00:00 2001 From: rainbownapkin Date: Tue, 21 Oct 2025 19:07:51 -0400 Subject: [PATCH 56/92] Added repo badges to readme. Added repo badges to readme. --- README.md | 72 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 40 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 2f346eb..ac1f39c 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,41 @@ -Canopy - 0.4-INDEV -====== - -Canopy - /ˈkæ.nə.pi/: - - The upper layer of foliage and branches of a forest, containing the majority of animal life. - - An honest attempt at a freedom/privacy respecting, libre, and open-source refrence implementation of what a stoner streaming service can be. - -Canopy is a community chat & synced video embedding web application, intended to replace fore.st as the server software for ourfore.st. -This new codebase intends to solve the following issues with the current CyTube based software: - - - Unmaintained upstream codebase. - - Different goals. - - Different coding styles. - - Obsolete Technology and Dependencies. - - General Clunk - - Less Unique Community Identity - -Canopy is a simple node/express.js app, leveraging yt-dlp and the internet archive REST api for metadata gathering. Persistant storage is handled by mongodb, as it's document based nature inherintly works well for cleanly storing large config documents for user/channel settings, and the low use of inter-collection references within the canopy software. All hardcore security functions like server-side input sanatization, session handling, CSRF mitigation, and password hashing are handled by industry-standard open source libraries such as validator/express-validator, express-sessions, csrf-sync, and bcrypt, however it IS hobbiest software, and it should be treated as such. - -The Canopy codebase does not, nor will it ever contain: - - Advertisements (targetted or otherwise) - - Proprietary Code - - Cryptocurrency/Blockchain integration - - 'Analytics/Telemtry' spyware - - The use of video sources which require proprietary 'Digital ~~Rights Management~~ Ristricitons Malware' such as Widevine. - - The use of large language models, stable diffusion, or generative AI in either development or function. - - Thirdparty media providers may or may not contain all of the above atrocities :P (though browser-side DRM extensions will never be required), always use an ad-blocker! - - Our current goal is to create a cleaner, more modern, purpose-built back-end that has feature-parity with the current version of fore.st, writing improvements where possible. Paired with this functionality, are a mix of engineering and artistic choices which attempt to re-create the power-user friendly UX of desktop sites from the early 2010's, with the 'aged like wine' looks that late oughts/early web 2.0 designs graced us with. Making sure that pageloads are low, and GPU use is non-existant along the way, to ensure everything is usable, even on low-end machines. - - ## License +Canopy +====== + + + + + + +0.4-INDEV +========= + +Canopy - /ˈkæ.nə.pi/: + - The upper layer of foliage and branches of a forest, containing the majority of animal life. + - An honest attempt at a freedom/privacy respecting, libre, and open-source refrence implementation of what a stoner streaming service can be. + +Canopy is a community chat & synced video embedding web application, intended to replace fore.st as the server software for ourfore.st. +This new codebase intends to solve the following issues with the current CyTube based software: + + - Unmaintained upstream codebase. + - Different goals. + - Different coding styles. + - Obsolete Technology and Dependencies. + - General Clunk + - Less Unique Community Identity + +Canopy is a simple node/express.js app, leveraging yt-dlp and the internet archive REST api for metadata gathering. Persistant storage is handled by mongodb, as it's document based nature inherintly works well for cleanly storing large config documents for user/channel settings, and the low use of inter-collection references within the canopy software. All hardcore security functions like server-side input sanatization, session handling, CSRF mitigation, and password hashing are handled by industry-standard open source libraries such as validator/express-validator, express-sessions, csrf-sync, and bcrypt, however it IS hobbiest software, and it should be treated as such. + +The Canopy codebase does not, nor will it ever contain: + - Advertisements (targetted or otherwise) + - Proprietary Code + - Cryptocurrency/Blockchain integration + - 'Analytics/Telemtry' spyware + - The use of video sources which require proprietary 'Digital ~~Rights Management~~ Ristricitons Malware' such as Widevine. + - The use of large language models, stable diffusion, or generative AI in either development or function. + + Thirdparty media providers may or may not contain all of the above atrocities :P (though browser-side DRM extensions will never be required), always use an ad-blocker! + + Our current goal is to create a cleaner, more modern, purpose-built back-end that has feature-parity with the current version of fore.st, writing improvements where possible. Paired with this functionality, are a mix of engineering and artistic choices which attempt to re-create the power-user friendly UX of desktop sites from the early 2010's, with the 'aged like wine' looks that late oughts/early web 2.0 designs graced us with. Making sure that pageloads are low, and GPU use is non-existant along the way, to ensure everything is usable, even on low-end machines. + + ## License Canopy is written by the community, and provided under the GNU Affero General Public License v3 in order to prevent Canopy from being used in proprietary software or shitcoin scams. \ No newline at end of file From 597a984e46390007e8712083b9ecd9058559e3b8 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Wed, 22 Oct 2025 03:04:16 -0400 Subject: [PATCH 57/92] Split pmHandler to create auxServer class for easy creation of server classes auxiliary to the channel. --- src/app/auxServer.js | 79 ++++++++++++++++++++++++++++++++ src/app/pm/pmHandler.js | 51 +++++---------------- www/js/channel/panels/pmPanel.js | 1 - 3 files changed, 90 insertions(+), 41 deletions(-) create mode 100644 src/app/auxServer.js diff --git a/src/app/auxServer.js b/src/app/auxServer.js new file mode 100644 index 0000000..3a66337 --- /dev/null +++ b/src/app/auxServer.js @@ -0,0 +1,79 @@ +/*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 .*/ + +//local includes +const loggerUtils = require("../utils/loggerUtils"); +const socketUtils = require("../utils/socketUtils"); + +/** + * Class containg global server-side logic for handling namespaces outside of the main one + */ +class auxServer{ + /** + * Instantiates object containing global server-side private message relay logic + * @param {Socket.io} io - Socket.io server instanced passed down from server.js + * @param {channelManager} chanServer - Sister channel management server object + */ + constructor(io, chanServer, namespace){ + /** + * Socket.io server instance passed down from server.js + */ + this.io = io; + + /** + * Sister channel management server object + */ + this.chanServer = chanServer; + + /** + * Socket.io server namespace for handling messaging + */ + this.namespace = io.of(namespace); + + //Handle connections from private messaging namespace + this.namespace.on("connection", this.handleConnection.bind(this) ); + } + + /** + * Handles global server-side initialization for new connections to aux server + * @param {Socket} socket - Requesting Socket + */ + async handleConnection(socket){ + try{ + //ensure unbanned ip and valid CSRF token + if(!(await socketUtils.validateSocket(socket))){ + socket.disconnect(); + return false; + } + + //If the socket wasn't authorized + if(await socketUtils.authSocketLite(socket) == null){ + socket.disconnect(); + return false; + } + + return true; + }catch(err){ + //Flip a table if something fucks up + return loggerUtils.socketCriticalExceptionHandler(socket, err); + } + } + + defineListeners(socket){ + } +} + +module.exports = auxServer; \ No newline at end of file diff --git a/src/app/pm/pmHandler.js b/src/app/pm/pmHandler.js index b783be8..5e16ffc 100644 --- a/src/app/pm/pmHandler.js +++ b/src/app/pm/pmHandler.js @@ -14,44 +14,25 @@ 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 validator = require('validator');//No express here, so regular validator it is! - //local includes +const auxServer = require('../auxServer'); const chatPreprocessor = require('../chatPreprocessor'); const loggerUtils = require("../../utils/loggerUtils"); -const socketUtils = require("../../utils/socketUtils"); const message = require("./message"); /** * Class containg global server-side private message relay logic */ -class pmHandler{ +class pmHandler extends auxServer{ /** * Instantiates object containing global server-side private message relay logic * @param {Socket.io} io - Socket.io server instanced passed down from server.js * @param {channelManager} chanServer - Sister channel management server object */ constructor(io, chanServer){ - /** - * Socket.io server instance passed down from server.js - */ - this.io = io; - - /** - * Sister channel management server object - */ - this.chanServer = chanServer; - - /** - * Socket.io server namespace for handling messaging - */ - this.namespace = io.of('/pm'); + super(io, chanServer, "/pm"); this.chatPreprocessor = new chatPreprocessor(null, null); - - //Handle connections from private messaging namespace - this.namespace.on("connection", this.handleConnection.bind(this) ); } /** @@ -59,31 +40,21 @@ class pmHandler{ * @param {Socket} socket - Requesting Socket */ async handleConnection(socket){ - try{ - //ensure unbanned ip and valid CSRF token - if(!(await socketUtils.validateSocket(socket))){ - socket.disconnect(); - return; - } + //Check if we're properly authorized + const authorized = await super.handleConnection(socket, "${user}"); - //If the socket wasn't authorized - if(await socketUtils.authSocketLite(socket) == null){ - socket.disconnect(); - return; - } - - //Throw socket into room named after it's user + //If we're authorized + if(authorized){ + //Throw the user into their own unique channel socket.join(socket.user.user); - //Define network related event listeners against socket + //Define listeners this.defineListeners(socket); - }catch(err){ - //Flip a table if something fucks up - return loggerUtils.socketCriticalExceptionHandler(socket, err); - } + } } defineListeners(socket){ + super.defineListeners(socket); socket.on("pm", (data)=>{this.handlePM(data, socket)}); } diff --git a/www/js/channel/panels/pmPanel.js b/www/js/channel/panels/pmPanel.js index 7751d59..aaab30b 100644 --- a/www/js/channel/panels/pmPanel.js +++ b/www/js/channel/panels/pmPanel.js @@ -369,7 +369,6 @@ class pmPanel extends panelObj{ handleAutoScroll(){ //If autoscroll is enabled if(this.autoScroll){ - console.log("SCROLLME"); //Set seshBuffer scrollTop to the difference between scrollHeight and buffer height (scroll to the bottom) this.seshBuffer.scrollTop = this.seshBuffer.scrollHeight - Math.round(this.seshBuffer.getBoundingClientRect().height); } From 5eb307bb9ef4d4445b37ef6984d9132098bc48f6 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Wed, 22 Oct 2025 04:34:05 -0400 Subject: [PATCH 58/92] Created dedicated queue broadcast namespace, to make authorized queue broadcasts as painless as possible. --- README.md | 2 +- src/app/auxServer.js | 19 +++-- src/app/channel/channelManager.js | 13 ++- .../channel/media/queueBroadcastManager.js | 82 +++++++++++++++++++ src/app/pm/pmHandler.js | 5 +- .../channel/channelPermissionSchema.js | 6 ++ src/utils/socketUtils.js | 4 + www/js/channel/channel.js | 5 ++ www/js/utils.js | 4 + 9 files changed, 131 insertions(+), 9 deletions(-) create mode 100644 src/app/channel/media/queueBroadcastManager.js diff --git a/README.md b/README.md index ac1f39c..75720ec 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Canopy ====== - + diff --git a/src/app/auxServer.js b/src/app/auxServer.js index 3a66337..2779dbf 100644 --- a/src/app/auxServer.js +++ b/src/app/auxServer.js @@ -51,21 +51,30 @@ class auxServer{ * Handles global server-side initialization for new connections to aux server * @param {Socket} socket - Requesting Socket */ - async handleConnection(socket){ + async handleConnection(socket, lite = true){ try{ + //Define empty value to hold result + let result = null; + //ensure unbanned ip and valid CSRF token if(!(await socketUtils.validateSocket(socket))){ socket.disconnect(); - return false; + return null; + } + + if(lite){ + result = await socketUtils.authSocketLite(socket); + }else{ + result = await socketUtils.authSocket(socket); } //If the socket wasn't authorized - if(await socketUtils.authSocketLite(socket) == null){ + if(result == null){ socket.disconnect(); - return false; + return null; } - return true; + return result; }catch(err){ //Flip a table if something fucks up return loggerUtils.socketCriticalExceptionHandler(socket, err); diff --git a/src/app/channel/channelManager.js b/src/app/channel/channelManager.js index 289c227..e9223f1 100644 --- a/src/app/channel/channelManager.js +++ b/src/app/channel/channelManager.js @@ -25,6 +25,7 @@ const loggerUtils = require('../../utils/loggerUtils'); const presenceUtils = require('../../utils/presenceUtils'); const activeChannel = require('./activeChannel'); const chatHandler = require('./chatHandler'); +const queueBroadcastManager = require('./media/queueBroadcastManager'); /** * Class containing global server-side channel connection management logic @@ -55,6 +56,16 @@ class channelManager{ */ this.chatHandler = new chatHandler(this); + /** + * Global Chat Handler Object + */ + this.chatHandler = new chatHandler(this); + + /** + * Global Auxiliary Server for Authenticated Queue Metadata Brodcasting + */ + this.queueBroadcastManager = new queueBroadcastManager(this.io, this) + //Handle connections from socket.io io.on("connection", this.handleConnection.bind(this) ); } @@ -152,7 +163,7 @@ class channelManager{ * @returns {Object} Object containing users active channel name and channel document object */ async getActiveChan(socket){ - socket.chan = socket.handshake.headers.referer.split('/c/')[1].split('/')[0]; + socket.chan = socketUtils.getChannelName(socket); const chanDB = (await channelModel.findOne({name: socket.chan})); //Check if channel exists diff --git a/src/app/channel/media/queueBroadcastManager.js b/src/app/channel/media/queueBroadcastManager.js new file mode 100644 index 0000000..c72a1c3 --- /dev/null +++ b/src/app/channel/media/queueBroadcastManager.js @@ -0,0 +1,82 @@ +/*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 .*/ + +//local includes +const auxServer = require("../../auxServer"); +const socketUtils = require("../../../utils/socketUtils") +const loggerUtils = require("../../../utils/loggerUtils"); +const channelModel = require("../../../schemas/channel/channelSchema"); + +/** + * Class containg global server-side private message relay logic + * + * Exists to make broadcasting channel queues to groups of authenticated users with the 'read-queue' perm as painless as possible, + * reducing DB call/perm checks to just connection time, and not requireing any out-of-library user iteration at broadcast time. + * + * Calls to modify and write to the schedule are still handled by the main namespace + * This is both for it's ease of access to the rest of the channel logic, but also to keep this class as small as possible. + */ +class queueBroadcastManager extends auxServer{ + /** + * Instantiates object containing global server-side channel schedule broadcasting subsystem + * @param {Socket.io} io - Socket.io server instanced passed down from server.js + * @param {channelManager} chanServer - Sister channel management server object + */ + constructor(io, chanServer){ + super(io, chanServer, "/queue-broadcast"); + } + + /** + * Handles global server-side initialization for new connections to the queue broadcast subsystem + * @param {Socket} socket - Requesting Socket + */ + async handleConnection(socket){ + //Check if we're properly authorized + const userObj = await super.handleConnection(socket); + + //If we're un-authorized + if(userObj == null){ + //Drop the connection + return; + } + + //Set socket channel value + socket.chan = socketUtils.getChannelName(socket); + //Pull channel DB + const chanDB = (await channelModel.findOne({name: socket.chan})); + + //If the user is connecting from an invalid channel + if(chanDB == null){ + //Drop the connection + return; + } + + //If the user is allowed to read the schedule + if(await chanDB.permCheck(socket.user, 'readSchedule')){ + //Throw the user into the channels room within the queue-broadcast instance + socket.join(socket.chan); + + //Define listeners + this.defineListeners(socket); + } + } + + defineListeners(socket){ + super.defineListeners(socket); + } +} + +module.exports = queueBroadcastManager; \ No newline at end of file diff --git a/src/app/pm/pmHandler.js b/src/app/pm/pmHandler.js index 5e16ffc..10c505e 100644 --- a/src/app/pm/pmHandler.js +++ b/src/app/pm/pmHandler.js @@ -19,6 +19,7 @@ const auxServer = require('../auxServer'); const chatPreprocessor = require('../chatPreprocessor'); const loggerUtils = require("../../utils/loggerUtils"); const message = require("./message"); +const socketUtils = require("../../utils/socketUtils"); /** * Class containg global server-side private message relay logic @@ -41,10 +42,10 @@ class pmHandler extends auxServer{ */ async handleConnection(socket){ //Check if we're properly authorized - const authorized = await super.handleConnection(socket, "${user}"); + const authorized = await super.handleConnection(socket); //If we're authorized - if(authorized){ + if(authorized != null){ //Throw the user into their own unique channel socket.join(socket.user.user); diff --git a/src/schemas/channel/channelPermissionSchema.js b/src/schemas/channel/channelPermissionSchema.js index bdd709e..1916efd 100644 --- a/src/schemas/channel/channelPermissionSchema.js +++ b/src/schemas/channel/channelPermissionSchema.js @@ -89,6 +89,12 @@ const channelPermissionSchema = new mongoose.Schema({ default: "admin", required: true }, + readSchedule: { + type: mongoose.SchemaTypes.String, + enum: rankEnum, + default: "admin", + required: true + }, scheduleMedia: { type: mongoose.SchemaTypes.String, enum: rankEnum, diff --git a/src/utils/socketUtils.js b/src/utils/socketUtils.js index 765ea02..8b538df 100644 --- a/src/utils/socketUtils.js +++ b/src/utils/socketUtils.js @@ -98,4 +98,8 @@ module.exports.authSocket = async function(socket){ }; return userDB; +} + +module.exports.getChannelName = function(socket){ + return socket.handshake.headers.referer.split('/c/')[1].split('/')[0]; } \ No newline at end of file diff --git a/www/js/channel/channel.js b/www/js/channel/channel.js index 9198ee1..e63a8a0 100644 --- a/www/js/channel/channel.js +++ b/www/js/channel/channel.js @@ -67,6 +67,10 @@ class channel{ //Freak out any weirdos who take a peek in the dev console for shits n gigs console.log("👁️👄👁️ ℬℴ𝓊𝓃𝒿ℴ𝓊𝓇."); + //Preach the good word about software which is Free as in Freedom + console.log(`Did you know Canopy, the software that runs '${utils.ux.getInstanceName()}' is software that is free as in freedom AND free weed?`); + console.log("This means you can read/modify/redistribute the code, run your own server, and even contribute back!"); + console.log("https://git.ourfore.st/rainbownapkin/canopy"); } /** @@ -81,6 +85,7 @@ class channel{ }; this.socket = io(clientOptions); + this.queueBroadcastSocket = io("/queue-broadcast", clientOptions); this.pmSocket = io("/pm", clientOptions); } diff --git a/www/js/utils.js b/www/js/utils.js index 2db077a..1c25aeb 100644 --- a/www/js/utils.js +++ b/www/js/utils.js @@ -50,6 +50,10 @@ class canopyUXUtils{ constructor(){ } + getInstanceName(){ + return document.querySelector("#instance-title a").innerText; + } + async awaitNextFrame(){ //return a new promise return new Promise((resolve)=>{ From 6d16ac2353b248e13143b0232ae74a82308e94f2 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Wed, 22 Oct 2025 05:00:59 -0400 Subject: [PATCH 59/92] Moved server-side queue transmission to it's own protected namespace. --- src/app/channel/connectedUser.js | 5 +---- src/app/channel/media/queue.js | 13 ++++++++++++- src/app/channel/media/queueBroadcastManager.js | 12 ++++++++++++ www/js/channel/channel.js | 6 ++---- www/js/channel/panels/queuePanel/queuePanel.js | 3 +-- 5 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/app/channel/connectedUser.js b/src/app/channel/connectedUser.js index 81b3220..03bea6e 100644 --- a/src/app/channel/connectedUser.js +++ b/src/app/channel/connectedUser.js @@ -226,9 +226,6 @@ class connectedUser{ } }); - //Get schedule as a temporary array - const queue = await this.channel.queue.prepQueue(chanDB); - //Get schedule lock status const queueLock = this.channel.queue.locked; @@ -236,7 +233,7 @@ class connectedUser{ const chatBuffer = this.channel.chatBuffer.buffer; //Send off the metadata to our user's clients - this.emit("clientMetadata", {user: userObj, flairList, queue, queueLock, chatBuffer}); + this.emit("clientMetadata", {user: userObj, flairList, queueLock, chatBuffer}); } /** diff --git a/src/app/channel/media/queue.js b/src/app/channel/media/queue.js index 9f68021..8284420 100644 --- a/src/app/channel/media/queue.js +++ b/src/app/channel/media/queue.js @@ -1607,7 +1607,18 @@ class queue{ * @param {Mongoose.Document} chanDB - Pass through Channel Document to save on DB Transactions */ async broadcastQueue(chanDB){ - this.server.io.in(this.channel.name).emit('queue',{queue: await this.prepQueue(chanDB)}); + //Broadcast queue to authenticated sockets within the channels room inside the protected 'queue-broadcast' namespace + this.server.queueBroadcastManager.namespace.in(this.channel.name).emit('queue',{queue: await this.prepQueue(chanDB)}); + } + + /** + * Broadcasts channel queue to a single socket + * @param {Mongoose.Document} chanDB - Pass through Channel Document to save on DB Transactions + * @param {Socket} socket - Socket to send queue to + */ + async emitQueue(chanDB, socket){ + //Broadcast queue to authenticated sockets within the channels room inside the protected 'queue-broadcast' namespace + socket.emit('queue',{queue: await this.prepQueue(chanDB)}); } /** diff --git a/src/app/channel/media/queueBroadcastManager.js b/src/app/channel/media/queueBroadcastManager.js index c72a1c3..61e0497 100644 --- a/src/app/channel/media/queueBroadcastManager.js +++ b/src/app/channel/media/queueBroadcastManager.js @@ -55,6 +55,15 @@ class queueBroadcastManager extends auxServer{ //Set socket channel value socket.chan = socketUtils.getChannelName(socket); + //Pull active channel + const activeChannel = this.chanServer.activeChannels.get(socket.chan); + + //If there isn't an active channel + if(activeChannel == null){ + //Drop the connection + return; + } + //Pull channel DB const chanDB = (await channelModel.findOne({name: socket.chan})); @@ -69,6 +78,9 @@ class queueBroadcastManager extends auxServer{ //Throw the user into the channels room within the queue-broadcast instance socket.join(socket.chan); + //Send the queue down to our newly connected user + activeChannel.queue.emitQueue(chanDB, socket); + //Define listeners this.defineListeners(socket); } diff --git a/www/js/channel/channel.js b/www/js/channel/channel.js index e63a8a0..6220ed0 100644 --- a/www/js/channel/channel.js +++ b/www/js/channel/channel.js @@ -113,7 +113,8 @@ class channel{ this.socket.on("error", utils.ux.displayResponseError); - this.socket.on("queue", (data) => { + this.queueBroadcastSocket.on("queue", (data) => { + console.log(data); this.queue = new Map(data.queue); }); @@ -138,9 +139,6 @@ class channel{ //should it have its own event listener instead? Guess it's a stylistic choice :P this.chatBox.handleClientInfo(data); - //Store queue for use by the queue panel - this.queue = new Map(data.queue); - //Store queue lock status this.queueLock = data.queueLock; } diff --git a/www/js/channel/panels/queuePanel/queuePanel.js b/www/js/channel/panels/queuePanel/queuePanel.js index 2b05766..a872c92 100644 --- a/www/js/channel/panels/queuePanel/queuePanel.js +++ b/www/js/channel/panels/queuePanel/queuePanel.js @@ -137,8 +137,7 @@ class queuePanel extends panelObj{ defineListeners(){ //Render queue when we receive a new copy of the queue data from the server //Render queue should be called within an arrow function so that it's called with default parameters, and not handed an event as a date - this.client.socket.on("clientMetadata", () => {this.renderQueue();}); - this.client.socket.on("queue", () => {this.renderQueue();}); + this.client.queueBroadcastSocket.on("queue", () => {this.renderQueue();}); this.client.socket.on("start", this.handleStart.bind(this)); this.client.socket.on("end", this.handleEnd.bind(this)); this.client.socket.on("lock", this.handleScheduleLock.bind(this)); From 57787f81e7560c18eca84b0146610d61f5cfa6aa Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Wed, 22 Oct 2025 05:36:55 -0400 Subject: [PATCH 60/92] Queue icon now only shows when readSchedule is allowed. --- src/views/partial/channel/chatPanel.ejs | 2 +- www/js/channel/channel.js | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/views/partial/channel/chatPanel.ejs b/src/views/partial/channel/chatPanel.ejs index e56c28a..0939f30 100644 --- a/src/views/partial/channel/chatPanel.ejs +++ b/src/views/partial/channel/chatPanel.ejs @@ -75,7 +75,7 @@ along with this program. If not, see . %>
- +

diff --git a/www/js/channel/channel.js b/www/js/channel/channel.js index 6220ed0..8bc9a24 100644 --- a/www/js/channel/channel.js +++ b/www/js/channel/channel.js @@ -113,14 +113,13 @@ class channel{ this.socket.on("error", utils.ux.displayResponseError); - this.queueBroadcastSocket.on("queue", (data) => { - console.log(data); - this.queue = new Map(data.queue); - }); - this.socket.on("lock", (data) => { this.queueLock = data.locked; }); + + this.queueBroadcastSocket.on("queue", (data) => { + this.queue = new Map(data.queue); + }); } /** @@ -135,6 +134,12 @@ class channel{ this.user.permMap.site = new Map(data.user.permMap.site); this.user.permMap.chan = new Map(data.user.permMap.chan); + //If we can read the schedule + if(client.user.permMap.chan.get('readSchedule')){ + //Display the queue icon + this.chatBox.adminIcon.style.display = ""; + } + //Tell the chatbox to handle client info //should it have its own event listener instead? Guess it's a stylistic choice :P this.chatBox.handleClientInfo(data); From aa325872598f6f37b1c921224a934d0928716e56 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Wed, 22 Oct 2025 20:17:53 -0400 Subject: [PATCH 61/92] Server now auto-magically nukes expired remember me tokens on startup and at midnight UTC. --- src/schemas/user/rememberMeSchema.js | 52 ++++++++++++++++++++++++---- src/utils/scheduler.js | 6 +++- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/src/schemas/user/rememberMeSchema.js b/src/schemas/user/rememberMeSchema.js index dfb925b..32df58e 100644 --- a/src/schemas/user/rememberMeSchema.js +++ b/src/schemas/user/rememberMeSchema.js @@ -80,12 +80,6 @@ rememberMeToken.pre('save', async function (next){ next(); }); -//Methods -rememberMeToken.methods.checkToken = async function(token){ - //Compare ingested token to saved hash - return await hashUtil.compareRememberMeToken(token, this.token); -} - //statics rememberMeToken.statics.genToken = async function(userDB, pass){ //Normally I'd use userModel auth, but this saves on DB calls and keeps us from having to refrence the userModel directly @@ -154,4 +148,50 @@ rememberMeToken.statics.authenticate = async function(id, token, failLine = "Bad } } +/** + * Schedulable function for processing expired remember me tokens + */ +rememberMeToken.statics.processExpiredTokens = async function(){ + //Pull all tokens from the DB + //Tested finding request by date, but mongoose kept throwing casting errors. + //This seems to be an intermittent issue online. Maybe it will work in a future version? + const tokenDB = await this.find({}); + + //Fire em all off at once without waiting for the last one to complete since we don't fuckin' need to + for(let tokenIndex in tokenDB){ + //pull token from tokenDB by index + const token = tokenDB[tokenIndex]; + + //If the token hasn't been processed and it's been expired + if(token.getDaysUntilExpiration() <= 0){ + //Delete the token + await token.deleteOne(); + } + } +} + +//Methods +/** + * Intakes a plaintext token string and compares it to the hashed remember me token from the database + * @param {String} token - Plaintext token retrieved from browser cookie + * @returns {Boolean} Comparison result + */ +rememberMeToken.methods.checkToken = async function(token){ + //Compare ingested token to saved hash + return await hashUtil.compareRememberMeToken(token, this.token); +} + +/** + * Returns number of days until token expiration + * @returns {Number} Number of days until token expiration + */ +rememberMeToken.methods.getDaysUntilExpiration = function(){ + //Get request date + const expirationDate = new Date(this.date); + //Get expiration days and calculate expiration date + expirationDate.setDate(expirationDate.getDate() + daysToExpire); + //Calculate and return days until request expiration + return ((expirationDate - new Date()) / (1000 * 60 * 60 * 24)).toFixed(1); +} + module.exports = mongoose.model("rememberMe", rememberMeToken); \ No newline at end of file diff --git a/src/utils/scheduler.js b/src/utils/scheduler.js index f1a09bc..a0851ee 100644 --- a/src/utils/scheduler.js +++ b/src/utils/scheduler.js @@ -22,9 +22,9 @@ const {userModel} = require('../schemas/user/userSchema'); const userBanModel = require('../schemas/user/userBanSchema'); const passwordResetModel = require('../schemas/user/passwordResetSchema'); const emailChangeModel = require('../schemas/user/emailChangeSchema'); +const rememberMeModel = require('../schemas/user/rememberMeSchema'); const channelModel = require('../schemas/channel/channelSchema'); const sessionUtils = require('./sessionUtils'); -const { email } = require('../validators/accountValidator'); /** * Schedules all timed jobs accross the server @@ -42,6 +42,8 @@ module.exports.schedule = function(){ cron.schedule('0 0 * * *', ()=>{passwordResetModel.processExpiredRequests()},{scheduled: true, timezone: "UTC"}); //Process expired email change requests every night at midnight cron.schedule('0 0 * * *', ()=>{emailChangeModel.processExpiredRequests()},{scheduled: true, timezone: "UTC"}); + //Process expired remember me tokens every night at midnight + cron.schedule('0 0 * * *', ()=>{rememberMeModel.processExpiredTokens()},{scheduled: true, timezone: "UTC"}); } /** @@ -58,6 +60,8 @@ module.exports.kickoff = function(){ passwordResetModel.processExpiredRequests(); //Process expired email change requests that may have expired since last restart emailChangeModel.processExpiredRequests(); + //Process expired remember me tokens that may have expired since last restart + rememberMeModel.processExpiredTokens() //Schedule jobs module.exports.schedule(); From a68bc6a7dac8c0cb96fc74d32cda18a403f77586 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Wed, 22 Oct 2025 20:29:32 -0400 Subject: [PATCH 62/92] Update README badges (super important) --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 75720ec..c485bb0 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ Canopy ====== - + + + From b620b423f640bedbf604d2051ce85f022a032e47 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Wed, 22 Oct 2025 20:34:21 -0400 Subject: [PATCH 63/92] Updated badges again... --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index c485bb0..65f4680 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Canopy + From 875baa833f5ea50a6e8b27961abc4437f636be4c Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Wed, 22 Oct 2025 20:46:00 -0400 Subject: [PATCH 64/92] Moar readme updates. --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 65f4680..cd53f8c 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,12 @@ Canopy ====== + - - - - + + + 0.4-INDEV ========= From f9ac076e6f065e9edc93a8393b183bef60cccecc Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Wed, 22 Oct 2025 20:49:30 -0400 Subject: [PATCH 65/92] readme --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index cd53f8c..1bb3e5e 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ Canopy - - - + + + 0.4-INDEV ========= From 79df27b72cbd5127d069247bacfb24a3adba4178 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Wed, 22 Oct 2025 21:04:08 -0400 Subject: [PATCH 66/92] Autocomplete placeholder now replaces spaces in input value with unicode non-breaking space character. --- www/js/channel/chat.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/www/js/channel/chat.js b/www/js/channel/chat.js index e733bdf..5311fb5 100644 --- a/www/js/channel/chat.js +++ b/www/js/channel/chat.js @@ -278,11 +278,11 @@ class chatBox{ //Find current match const match = this.checkAutocomplete(); + //Set placeholder to space out the autocomplete display - //Use text content because it's unescaped, and while this only effects local users, it'll keep someone from noticing and whinging about it - this.autocompletePlaceholder.textContent = this.chatPrompt.value; + this.autocompletePlaceholder.innerText = this.chatPrompt.value.replaceAll(" ","\u00A0"); //Set the autocomplete display - this.autocompleteDisplay.textContent = match.match.replace(match.word, ''); + this.autocompleteDisplay.innerText = match.match.replace(match.word, ''); } /** From a34ece43744e9289bc186157ea623652fcd05ff5 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Wed, 22 Oct 2025 21:20:07 -0400 Subject: [PATCH 67/92] Video title now renders escaped entities properly. --- www/js/channel/mediaHandler.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/www/js/channel/mediaHandler.js b/www/js/channel/mediaHandler.js index 4c4f491..f15ef89 100644 --- a/www/js/channel/mediaHandler.js +++ b/www/js/channel/mediaHandler.js @@ -187,7 +187,7 @@ class mediaHandler{ * @param {String} title - Title to set */ setVideoTitle(title){ - this.player.title.textContent = `Currently Playing: ${title}`; + this.player.title.innerText = `Currently Playing: ${utils.unescapeEntities(title)}`; } /** @@ -370,7 +370,7 @@ class nullHandler extends rawFileBase{ } setVideoTitle(title){ - this.player.title.textContent = `Channel Off Air`; + this.player.title.innerText = `Channel Off Air`; } } @@ -601,7 +601,7 @@ class youtubeEmbedHandler extends mediaHandler{ setVideoTitle(){ //Clear out the player title so that youtube's baked in title can do it's thing. //This will be replaced once we complete the full player control and remove the defualt youtube UI - this.player.title.textContent = ""; + this.player.title.innerText = ""; } /** @@ -741,12 +741,12 @@ class hlsLiveStreamHandler extends hlsBase{ setVideoTitle(title){ //Add title as text content for security :P - this.player.title.textContent = `: ${title}`; + this.player.title.innerText = `: ${utils.unescapeEntities(title)}`; //Create glow span const glowSpan = document.createElement('span'); //Fill glow span content - glowSpan.textContent = "🔴LIVE"; + glowSpan.innerText = "🔴LIVE"; //Set glowspan class glowSpan.classList.add('critical-danger-text'); From 1bd9fcdc8025ce3e1fef01fc91b11c8f47a440cd Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Wed, 22 Oct 2025 21:53:41 -0400 Subject: [PATCH 68/92] High-level rank changes and bad attempts and good Remember-Me tokens now logged. --- .gitignore | 5 +-- src/schemas/user/rememberMeSchema.js | 7 ++-- src/schemas/user/userSchema.js | 5 +++ src/utils/loggerUtils.js | 57 ++++++++++++++++++++-------- src/utils/mailUtils.js | 2 +- 5 files changed, 53 insertions(+), 23 deletions(-) diff --git a/.gitignore b/.gitignore index 9868f22..76cfb45 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,6 @@ node_modules/ -log/crash/* -!log/crash +log/* www/doc/*/* -!www/doc/client -!www/doc/server package-lock.json config.json config.json.old diff --git a/src/schemas/user/rememberMeSchema.js b/src/schemas/user/rememberMeSchema.js index 32df58e..be162fd 100644 --- a/src/schemas/user/rememberMeSchema.js +++ b/src/schemas/user/rememberMeSchema.js @@ -128,14 +128,15 @@ rememberMeToken.statics.authenticate = async function(id, token, failLine = "Bad badLogin(); } + //Populate the user field + await tokenDB.populate('user'); + //Check our password is correct if(await tokenDB.checkToken(token)){ - //Populate the user field - await tokenDB.populate('user'); - //Return the user doc return tokenDB.user; }else{ + loggerUtils.dumpSecurityLog(`Failed attempt at ${tokenDB.user.user}'s Remember-Me token {${tokenDB.id}}... Nuking token!`); //Nuke the token for security await tokenDB.deleteOne(); //if not scream and shout diff --git a/src/schemas/user/userSchema.js b/src/schemas/user/userSchema.js index cc4d364..2d8517c 100644 --- a/src/schemas/user/userSchema.js +++ b/src/schemas/user/userSchema.js @@ -186,6 +186,11 @@ userSchema.pre('save', async function (next){ //If rank was changed if(this.isModified("rank")){ + //If this rank change is above 2 (Mod or above) + if(permissionModel.rankToNum(this.rank) > 2){ + loggerUtils.dumpSecurityLog(`${this.user}'s rank was set to ${this.rank}.`); + } + //force a full log-out await this.killAllSessions("Your site-wide rank has changed. Sign-in required."); } diff --git a/src/utils/loggerUtils.js b/src/utils/loggerUtils.js index 075f9ed..6017a9b 100644 --- a/src/utils/loggerUtils.js +++ b/src/utils/loggerUtils.js @@ -16,6 +16,7 @@ along with this program. If not, see .*/ //Node const fs = require('node:fs/promises'); +const crypto = require('node:crypto'); //Config const config = require('../../config.json'); @@ -172,42 +173,68 @@ module.exports.errorMiddleware = function(err, req, res, next){ * Dumps unexpected server crashes to dedicated log files * @param {Error} err - error to dump to file * @param {Date} date - Date of error, defaults to now + * @param {String} subDir - subdirectory inside the log folder we want to dump to + * @param {Boolean} muzzle - Tells the function to STFU */ -module.exports.dumpError = async function(err, date = new Date(), subDir = ''){ +module.exports.dumpError = async function(err, date = new Date(), subDir = 'crash/', muzzle = false){ + //Generate content from error + const content = `Error Date: ${date.toLocaleString()} (UTC-${date.getTimezoneOffset()/60})\nError Type: ${err.name}\nError Msg:${err.message}\nStack Trace:\n\n${err.stack}`; + + //Dump text to file + module.exports.dumpLog(content, date.getTime(), subDir, muzzle); +} + + +module.exports.dumpSecurityLog = async function(content, date = new Date()){ + module.exports.dumpLog(content, `Incident-{${crypto.randomUUID()}}-${date.getTime()}`, 'security/', true); +} + +/** + * Dumps log file to log folder + * @param {String} content - Text to dump to file + * @param {String} name - file name to save to + * @param {String} subDir - subdirectory inside the log folder we want to dump to + * @param {Boolean} muzzle - Tells the function to STFU + */ +module.exports.dumpLog = async function(content, name, subDir = '/', muzzle = false){ try{ //Crash directory - const dir = `./log/crash/${subDir}` + const dir = `./log/${subDir}` //Double check crash folder exists try{ await fs.stat(dir); //If we caught an error (most likely it's missing) }catch(err){ - //Shout about it - module.exports.consoleWarn("Log folder missing, mking dir!") + if(!muzzle){ + //Shout about it + module.exports.consoleWarn("Log folder missing, mking dir!") + } //Make it if doesn't await fs.mkdir(dir, {recursive: true}); } //Assemble log file path - const path = `${dir}${date.getTime()}.log`; - //Generate error file content - const content = `Error Date: ${date.toLocaleString()} (UTC-${date.getTimezoneOffset()/60})\nError Type: ${err.name}\nError Msg:${err.message}\nStack Trace:\n\n${err.stack}`; + const path = `${dir}${name}.log`; //Write content to file fs.writeFile(path, content); - //Whine about the error - module.exports.consoleWarn(`Warning: Unexpected Server Crash gracefully dumped to '${path}'... SOMETHING MAY BE VERY BROKEN!!!!`); + if(!muzzle){ + //Whine about the error + module.exports.consoleWarn(`Warning: Unexpected Server Crash gracefully dumped to '${path}'... SOMETHING MAY BE VERY BROKEN!!!!`); + } //If somethine went really really wrong }catch(doubleErr){ - //Use humor to cope with the pain - module.exports.consoleWarn("Yo Dawg, I herd you like errors, so I put an error in your error dump, so you can dump while you dump:"); - //Dump the original error to console - module.exports.consoleWarn(err); - //Dump the error we had saving that error to file to console - module.exports.consoleWarn(doubleErr); + if(!muzzle){ + //Use humor to cope with the pain + module.exports.consoleWarn("Yo Dawg, I herd you like errors, so I put an error in your error dump, so you can dump while you dump:"); + //Dump the original error to console + module.exports.consoleWarn(err); + //Dump the error we had saving that error to file to console + module.exports.consoleWarn(doubleErr); + } } } diff --git a/src/utils/mailUtils.js b/src/utils/mailUtils.js index fce92ca..4287160 100644 --- a/src/utils/mailUtils.js +++ b/src/utils/mailUtils.js @@ -78,7 +78,7 @@ module.exports.mailem = async function(to, subject, body, htmlBody = false){ //return the mail info return sentMail; }catch(err){ - loggerUtils.dumpError(err, new Date(), 'mail/'); + loggerUtils.dumpError(err, new Date(), 'crash/mail/'); } } From 7cda9517d4aa61bd75bd07f8ca5d11ba09afe073 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Thu, 23 Oct 2025 07:50:49 -0400 Subject: [PATCH 69/92] Added basic about page. --- config.example.json | 3 +- config.example.jsonc | 4 ++- src/controllers/aboutController.js | 27 ++++++++++++++++ src/routers/aboutRouter.js | 34 +++++++++++++++++++++ src/server.js | 2 ++ src/views/about.ejs | 49 ++++++++++++++++++++++++++++++ src/views/partial/navbar.ejs | 9 ++++-- www/css/about.css | 39 ++++++++++++++++++++++++ 8 files changed, 162 insertions(+), 5 deletions(-) create mode 100644 src/controllers/aboutController.js create mode 100644 src/routers/aboutRouter.js create mode 100644 src/views/about.ejs create mode 100644 www/css/about.css diff --git a/config.example.json b/config.example.json index cb43df6..58445d8 100644 --- a/config.example.json +++ b/config.example.json @@ -32,5 +32,6 @@ "secure": true, "address": "toke@42069.weed", "pass": "CHANGE_ME" - } + }, + "aboutText":"ourfore.st is the one and only original canopy instance. Setup, ran, and administered by rainbownapkin herself. This site exists to provide a featureful, preformant, and comfy replacement for the TTN community." } \ No newline at end of file diff --git a/config.example.jsonc b/config.example.jsonc index 02fb88e..3f1bd0a 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -59,5 +59,7 @@ "secure": true, "address": "toke@42069.weed", "pass": "CHANGE_ME" - } + }, + //Fills the 'about ${instanceName}' section on the /about page, lets users know about your specific instance + "aboutText":"ourfore.st is the one and only original canopy instance. Setup, ran, and administered by rainbownapkin herself. This site exists to provide a featureful, preformant, and comfy replacement for the TTN community." } \ No newline at end of file diff --git a/src/controllers/aboutController.js b/src/controllers/aboutController.js new file mode 100644 index 0000000..2b32b84 --- /dev/null +++ b/src/controllers/aboutController.js @@ -0,0 +1,27 @@ +/*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 .*/ + +//Config +const config = require('../../config.json'); + +//Local Imports +const csrfUtils = require('../utils/csrfUtils'); + +//register page functions +module.exports.get = async function(req, res){ + //Render page + return res.render('about', {aboutText: config.aboutText, instance: config.instanceName, user: req.session.user, csrfToken: csrfUtils.generateToken(req)}); +} \ No newline at end of file diff --git a/src/routers/aboutRouter.js b/src/routers/aboutRouter.js new file mode 100644 index 0000000..8739880 --- /dev/null +++ b/src/routers/aboutRouter.js @@ -0,0 +1,34 @@ +/*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 { Router } = require('express'); + + +//local imports +const aboutController = require("../controllers/aboutController"); +const presenceUtils = require("../utils/presenceUtils"); + +//globals +const router = Router(); + +//Use presence middleware +router.use(presenceUtils.presenceMiddleware); + +//routing functions +router.get('/', aboutController.get); + +module.exports = router; diff --git a/src/server.js b/src/server.js index 3f47c43..da30147 100644 --- a/src/server.js +++ b/src/server.js @@ -54,6 +54,7 @@ const fileNotFoundController = require('./controllers/404Controller'); //Router //Humie-Friendly const indexRouter = require('./routers/indexRouter'); +const aboutRouter = require('./routers/aboutRouter'); const registerRouter = require('./routers/registerRouter'); const loginRouter = require('./routers/loginRouter'); const profileRouter = require('./routers/profileRouter'); @@ -179,6 +180,7 @@ app.use(sessionUtils.rememberMeMiddleware); //Routes //Humie-Friendly app.use('/', indexRouter); +app.use('/about', aboutRouter); app.use('/register', registerRouter); app.use('/login', loginRouter); app.use('/profile', profileRouter); diff --git a/src/views/about.ejs b/src/views/about.ejs new file mode 100644 index 0000000..8842ff0 --- /dev/null +++ b/src/views/about.ejs @@ -0,0 +1,49 @@ +<%# 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 . %> + + + + <%- include('partial/styles', {instance, user}); %> + <%- include('partial/csrfToken', {csrfToken}); %> + + <%= instance %> - about + + + <%- include('partial/navbar', {user}); %> +
+

About <%= instance %>

+
+

About <%= instance %>

+ <%# It's not XSS if the text came from the config made by the server admin. If you can't trust that, you're already fucked.%> +

<%- aboutText %>

+

About Canopy

+

Canopy is the software behind <%= instance %>. Originally written by rainbownapkin for the founding instance, + ourfore.st. Ourfore.st was originally a cytube instance, set up after the 2021 + shutdown of TTN, a movie watching/weed smoking community related to the r/trees + subreddit. +
+
+ After a years of service, thousands of lines worth of stapled on modifications, the shutdown of sister sites, + it was decided that the original cytube fork, fore.st, had been run past it's prime. In summer/fall 2024, work began on a + replacement. The resulting software became Canopy, which was + first used to run the ourfore.st instance in late 2025.

+
+
+ +
+ <%- include('partial/scripts', {user}); %> +
+ diff --git a/src/views/partial/navbar.ejs b/src/views/partial/navbar.ejs index 3dc3065..aa9a56a 100644 --- a/src/views/partial/navbar.ejs +++ b/src/views/partial/navbar.ejs @@ -14,16 +14,19 @@ 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 . %> \ No newline at end of file diff --git a/www/css/about.css b/www/css/about.css new file mode 100644 index 0000000..52ce658 --- /dev/null +++ b/www/css/about.css @@ -0,0 +1,39 @@ +/*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 .*/ +#about-div{ + display: flex; + flex-direction: column; +} + +@media (orientation: landscape){ + #about-div{ + margin: 0 25%; + } +} + +@media (orientation: portrait){ + #about-div{ + margin: 0 10%; + } +} + +h1{ + text-align: center; +} + +#about-text{ + padding: 0 0.5em; +} \ No newline at end of file From b8de76b448c25deb88468535ef975996f5c2e9e1 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Thu, 23 Oct 2025 08:14:25 -0400 Subject: [PATCH 70/92] Fixed canopyUXUtils.getInstanceName() --- www/js/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/utils.js b/www/js/utils.js index 1c25aeb..4532934 100644 --- a/www/js/utils.js +++ b/www/js/utils.js @@ -51,7 +51,7 @@ class canopyUXUtils{ } getInstanceName(){ - return document.querySelector("#instance-title a").innerText; + return document.querySelector("#instance-title").innerText; } async awaitNextFrame(){ From db3ec58ad9fa3134ae414919bc4839fe3056912e Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Fri, 24 Oct 2025 00:26:29 -0400 Subject: [PATCH 71/92] Simplified chatMetadata based classes. --- src/app/channel/chat.js | 7 +------ src/app/chatMetadata.js | 8 +++++++- src/app/pm/message.js | 7 +------ 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/app/channel/chat.js b/src/app/channel/chat.js index c9c5853..eb09071 100644 --- a/src/app/channel/chat.js +++ b/src/app/channel/chat.js @@ -27,12 +27,7 @@ class chat extends chatMetadata{ */ constructor(user, flair, highLevel, msg, type, links){ //Call derived constructor - super(flair, highLevel, msg, type, links); - - /** - * User who sent the message - */ - this.user = user; + super(user, flair, highLevel, msg, type, links); } } diff --git a/src/app/chatMetadata.js b/src/app/chatMetadata.js index df5fbd6..23320ac 100644 --- a/src/app/chatMetadata.js +++ b/src/app/chatMetadata.js @@ -20,13 +20,19 @@ along with this program. If not, see .*/ class chatMetadata{ /** * Instantiates a chat metadata object + * @param {String} user - Name of user who sent the message * @param {String} flair - Flair ID String for the flair used to send the message * @param {Number} highLevel - Number representing current high level * @param {String} msg - Contents of the message, with links replaced with numbered file-seperator markers * @param {String} type - Message Type Identifier, used for client-side processing. * @param {Array} links - Array of URLs/Links included in the message. */ - constructor(flair, highLevel, msg, type, links){ + constructor(user, flair, highLevel, msg, type, links){ + /** + * Name of user who sent the message + */ + this.user = user; + /** * Flair ID String for the flair used to send the message */ diff --git a/src/app/pm/message.js b/src/app/pm/message.js index 28dc46f..a6604ff 100644 --- a/src/app/pm/message.js +++ b/src/app/pm/message.js @@ -27,12 +27,7 @@ class message extends chatMetadata{ */ constructor(user, recipients, flair, highLevel, msg, type, links){ //Call derived constructor - super(flair, highLevel, msg, type, links); - - /** - * Name of user who sent the message - */ - this.user = user; + super(user, flair, highLevel, msg, type, links); /** * Array of usernames who are supposed to receive the message From f95a0ae48c4dcd7c1e03b8a61de01d2ef3b1ddfd Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Sat, 25 Oct 2025 09:46:28 -0400 Subject: [PATCH 72/92] Added queue debugging. --- config.example.json | 1 + config.example.jsonc | 5 ++++ src/app/channel/media/queue.js | 49 ++++++++++++++++++++++++++++++++- src/schemas/permissionSchema.js | 6 ++++ src/server.js | 6 ++-- src/utils/configCheck.js | 5 ++++ 6 files changed, 68 insertions(+), 4 deletions(-) diff --git a/config.example.json b/config.example.json index 58445d8..6e7519f 100644 --- a/config.example.json +++ b/config.example.json @@ -8,6 +8,7 @@ "ytdlpPath": "/home/canopy/.local/pipx/venvs/yt-dlp/bin/yt-dlp", "migrate": false, "dropLegacyTokes": false, + "debug": false, "secrets":{ "passwordSecret": "CHANGE_ME", "rememberMeSecret": "CHANGE_ME", diff --git a/config.example.jsonc b/config.example.jsonc index 3f1bd0a..75d4591 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -24,6 +24,11 @@ //Requires migration to be disabled before it takes effect. //WARNING: this does NOT affect user toke counts, migrated or otherwise. Use carefully! "dropLegacyTokes": false, + //Enters the server into debug mode, allows specific commands to be emitted from the client-side dev console + //Usually to get the server to dump some sort of internal data for debugging purposes. + //Obviously, this means enabling this can have some gnar implications. + //Probably don't enable this on your production server unless you REALLY REALLY have to, and you probably don't. + "debug": false, //Server Secrets //Be careful with what you keep in secrets, you should use special chars, but test your deployment, as some chars may break account registration //An update to either kill the server and bitch about the issue in console is planned so it's not so confusing for new admins diff --git a/src/app/channel/media/queue.js b/src/app/channel/media/queue.js index 8284420..8fa933f 100644 --- a/src/app/channel/media/queue.js +++ b/src/app/channel/media/queue.js @@ -18,10 +18,12 @@ along with this program. If not, see .*/ const validator = require('validator'); //Local imports +const config = require('../../../../config.json'); const queuedMedia = require('./queuedMedia'); const yanker = require('../../../utils/media/yanker'); const loggerUtils = require('../../../utils/loggerUtils'); const channelModel = require('../../../schemas/channel/channelSchema'); +const permissionModel = require('../../../schemas/permissionSchema'); /** * Object represneting a single channel's media queue @@ -114,6 +116,11 @@ class queue{ socket.on("move", (data) => {this.moveMedia(socket, data)}); socket.on("lock", () => {this.toggleLock(socket)}); socket.on("goLive", (data) => {this.goLive(socket, data)}); + + //If debug mode is enabled + if(config.debug){ + socket.on("dumpQueue", (data) => {this.dumpQueue(socket, data)}); + } } //--- USER FACING QUEUEING FUNCTIONS --- @@ -404,6 +411,44 @@ class queue{ } } + async dumpQueue(socket, data){ + //If we somehow got here while config.debug is disabled, or the user isn't allowed to preform server-side debugging + if(!(config.debug && await permissionModel.permCheck(socket.user, "debug"))){ + //FUCKIN' CHEESE IT! + return; + } + + //If a full data dump was requested + if(data != null && data.full){ + //Pull the channel DB doc + const chanDB = await channelModel.findOne({name:this.channel.name}); + + //Cook and emit a new object from all of the data + socket.emit("dumpQueue", { + cache: { + schedule: Array.from(this.schedule), + nowPlaying: this.nowPlaying + }, + DB: { + schedule: chanDB.media.scheduled, + nowPlaying: chanDB.media.nowPlaying, + archived: chanDB.media.archived, + } + }); + + //DONE. + return; + } + + //Otherwise, just dump whats in RAM + socket.emit("dumpQueue", { + cache: { + schedule: Array.from(this.schedule), + nowPlaying: this.nowPlaying + } + }); + } + //--- INTERNAL USE ONLY QUEUEING FUNCTIONS --- /** * Clears and scheduling timers @@ -1788,7 +1833,9 @@ class queue{ chanDB.media.scheduled = newSched; //Save the DB - await chanDB.save(); + await chanDB.save() + + //End the media; //if something fucked up }catch(err){ diff --git a/src/schemas/permissionSchema.js b/src/schemas/permissionSchema.js index 108c888..322e8e2 100644 --- a/src/schemas/permissionSchema.js +++ b/src/schemas/permissionSchema.js @@ -100,6 +100,12 @@ const permissionSchema = new mongoose.Schema({ type: channelPermissionSchema, default: () => ({}) }, + debug: { + type: mongoose.SchemaTypes.String, + enum: rankEnum, + default: "admin", + required: true + }, }); //Statics diff --git a/src/server.js b/src/server.js index da30147..50dcda0 100644 --- a/src/server.js +++ b/src/server.js @@ -78,9 +78,6 @@ const config = require('../config.json'); const port = config.port; const dbUrl = `mongodb://${config.db.user}:${config.db.pass}@${config.db.address}:${config.db.port}/${config.db.database}`; -//Check for insecure config -configCheck.securityCheck(); - //Define express const app = express(); @@ -234,6 +231,9 @@ async function asyncKickStart(){ //Kick off scheduled-jobs scheduler.kickoff(); + //Check for insecure config + configCheck.securityCheck(); + //Increment launch counter await statModel.incrementLaunchCount(); diff --git a/src/utils/configCheck.js b/src/utils/configCheck.js index d979b32..411c8b8 100644 --- a/src/utils/configCheck.js +++ b/src/utils/configCheck.js @@ -74,4 +74,9 @@ module.exports.securityCheck = function(){ if(!validator.isStrongPassword(config.mail.pass) || config.mail.pass == "CHANGE_ME"){ loggerUtil.consoleWarn("Insecure Email Password! Change Email password!"); } + + //check debug mode + if(config.debug){ + loggerUtil.consoleWarn("Debug mode enabled! Understand the risks and security implications before enabling on production servers!"); + } } \ No newline at end of file From 37990ff8c31862db834a7555f706302188013da8 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Sat, 25 Oct 2025 09:55:40 -0400 Subject: [PATCH 73/92] Traded bug with queue.end() being called as volatile from functions which handle their own DB save, in which stale item was left in cache, for a simple queue rending bug. --- src/app/channel/media/queue.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/app/channel/media/queue.js b/src/app/channel/media/queue.js index 8fa933f..ec44c81 100644 --- a/src/app/channel/media/queue.js +++ b/src/app/channel/media/queue.js @@ -1209,6 +1209,12 @@ class queue{ return this.endLivestream(wasPlaying, chanDB) } + + //Moved this from the block below to prevent accidental over-caching + //We may need to throw this into it's own conditional if it causes issues + //Take it out of the active schedule + this.schedule.delete(wasPlaying.startTime); + //If we're not in volatile mode and we're not ending a livestream if(!volatile){ //If we wheren't handed a channel @@ -1229,9 +1235,6 @@ class queue{ await chanDB.media.nowPlaying.deleteOne(); } - //Take it out of the active schedule - this.schedule.delete(wasPlaying.startTime); - //If archiving is enabled if(!noArchive){ //Add the item to the channel archive From 166e174397424f1c770d4feef6d579d1451142d9 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Sat, 25 Oct 2025 09:56:24 -0400 Subject: [PATCH 74/92] comment --- src/app/channel/media/queue.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/channel/media/queue.js b/src/app/channel/media/queue.js index ec44c81..9bbaa01 100644 --- a/src/app/channel/media/queue.js +++ b/src/app/channel/media/queue.js @@ -1213,6 +1213,7 @@ class queue{ //Moved this from the block below to prevent accidental over-caching //We may need to throw this into it's own conditional if it causes issues //Take it out of the active schedule + //Ultimitaly though, if something is voltaile it should handle saving chanDB on its own, so this should be a non-issue this.schedule.delete(wasPlaying.startTime); //If we're not in volatile mode and we're not ending a livestream From 787846c7d6ec88efa478f3b9d30bba61dd61f60c Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Mon, 27 Oct 2025 19:29:07 -0400 Subject: [PATCH 75/92] Updated config example. --- config.example.json | 6 +++--- config.example.jsonc | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/config.example.json b/config.example.json index 6e7519f..5d8f957 100644 --- a/config.example.json +++ b/config.example.json @@ -1,9 +1,9 @@ { "instanceName": "Canopy", "verbose": false, - "port": 8080, - "proxied": false, - "protocol": "http", + "port": 8443, + "proxied": true, + "protocol": "https", "domain": "localhost", "ytdlpPath": "/home/canopy/.local/pipx/venvs/yt-dlp/bin/yt-dlp", "migrate": false, diff --git a/config.example.jsonc b/config.example.jsonc index 75d4591..f315d9a 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -6,9 +6,9 @@ //Scream about exceptions in the console "verbose": false, //Port to bind to (most linux/unix systems req root for ports below 1000, you should probably use nginx if you want port 80 or 443) - "port": 8080, + "port": 8443, //Lets the server know it's sitting behind a reverse-proxy - "proxied": false, + "proxied": true, //Protocol (either HTTP or HTTPS) "protocol": "http", //Domain the server is available at, used for server-side link generation From dd66601f0dbd477e58bc53f0234e9f37c4a17753 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Mon, 27 Oct 2025 20:31:14 -0400 Subject: [PATCH 76/92] Fixed out of order queues being sent off to clients. --- src/app/channel/media/queue.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/channel/media/queue.js b/src/app/channel/media/queue.js index 9bbaa01..671fac7 100644 --- a/src/app/channel/media/queue.js +++ b/src/app/channel/media/queue.js @@ -1708,8 +1708,8 @@ class queue{ media.earlyEnd = null; } - //Add it to the schedule array as if it where part of the actual schedule map - schedule.push([media.startTime, media]); + //Add it to the temporary schedule array as if it where part of the actual schedule map + schedule.unshift([media.startTime, media]); //Otherwise if it's older }else{ //Then we should be done as archived items are added as they are played/end. From a1f08243308d453b898f77c22b9914e1a52d928c Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Tue, 28 Oct 2025 06:33:52 -0400 Subject: [PATCH 77/92] Fixed queue rendering issue by changing server-side behavior around queue broadcasting. --- src/app/channel/media/queue.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/app/channel/media/queue.js b/src/app/channel/media/queue.js index 671fac7..f52d75c 100644 --- a/src/app/channel/media/queue.js +++ b/src/app/channel/media/queue.js @@ -1078,6 +1078,7 @@ class queue{ if(this.nowPlaying != null){ //Silently end the media in RAM so the database isn't stepping on itself up ahead //Alternatively we could've used await, but then we'd be doubling up on DB transactions :P + //VOLATILE END this.end(true, true, true); } @@ -1116,6 +1117,9 @@ class queue{ //Save the channel await chanDB.save(); + + //Broadcast queue since we ended our media item early + this.broadcastQueue(chanDB); }catch(err){ loggerUtils.localExceptionHandler(err); } @@ -1171,7 +1175,7 @@ class queue{ * End currently playing media * @param {Boolean} quiet - Enable to prevent ending the media client-side * @param {Boolean} noArchive - Enable to prevent ended media from being written to channel archive. Deletes media if Volatile is false - * @param {Boolean} volatile - Enable to prevent DB Transactions + * @param {Boolean} volatile - Enable to prevent DB Transactions, also prevents queue broadcasting so the calling code can broadcast once it's done. * @param {Mongoose.Document} chanDB - Pass through Channel Document to save on DB Transactions */ async end(quiet = false, noArchive = false, volatile = false, chanDB){ @@ -1247,9 +1251,6 @@ class queue{ //Save our changes to the DB await chanDB.save(); - }else{ - //broadcast queue using unsaved archive - this.broadcastQueue(chanDB); } }catch(err){ this.broadcastQueue(); From 349a6b82aab76a0948e20f681663a491d7df7f58 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Tue, 28 Oct 2025 07:03:16 -0400 Subject: [PATCH 78/92] Moving currently playing items to an invalid spot in the schedule no longer creates ghost items. --- src/app/channel/media/queue.js | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/app/channel/media/queue.js b/src/app/channel/media/queue.js index f52d75c..3905702 100644 --- a/src/app/channel/media/queue.js +++ b/src/app/channel/media/queue.js @@ -642,6 +642,8 @@ class queue{ //Find our media, don't remove it yet since we want to do some more testing first let media = this.getItemByUUID(uuid); + //Create value to hold old media in-case somethin' fucks up + let oldMedia = null; //If we got a bad request if(media == null){ @@ -658,6 +660,8 @@ class queue{ if(media.startTime < new Date().getTime()){ //If the item is currently playing if(media.getEndTime() > new Date().getTime()){ + //Set old media + oldMedia = media; //Dupe media for the rest of the function media = media.clone(); @@ -699,6 +703,13 @@ class queue{ //Reset the start time stamp for re-calculation media.startTimeStamp = 0; + //If there's a cut-off from moving a now-playing item + if(oldMedia != null){ + //Remove it from the queue to prevent ghost items + this.removeMedia(oldMedia.uuid, socket, chanDB, true); + } + + //Schedule in old slot with noSave enabled await this.scheduleMedia([media], socket, chanDB, true); } @@ -721,7 +732,7 @@ class queue{ * @param {String} uuid - UUID of item to reschedule * @param {Socket} socket - Requesting Socket * @param {Mongoose.Document} chanDB - Channnel Document Passthrough to save on DB Access - * @param {Boolean} noScheduling - Disables schedule timer refresh if true + * @param {Boolean} noScheduling - Disables schedule timer refresh and this.save() calls if true, good for internal function calls * @returns {Media} Deleted Media Item */ async removeMedia(uuid, socket, chanDB, noScheduling = false){ @@ -773,8 +784,12 @@ class queue{ }else{ //Broadcast changes this.broadcastQueue(chanDB); - //Save changes to the DB - await chanDB.save(); + + //If saving is disabled + if(!noScheduling){ + //Save changes to the DB + await chanDB.save(); + } } }catch(err){ //If this was originated by someone From e0832c2c1f94ac089cd5e48dacffe347da5f44ed Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Wed, 29 Oct 2025 08:35:37 -0400 Subject: [PATCH 79/92] Started working on pushing multiple raw links to user. --- src/app/channel/media/media.js | 20 +++++++++++++------ src/utils/media/yanker.js | 3 +++ src/utils/media/ytdlpUtils.js | 36 ++++++++++++++++++++++++++++------ 3 files changed, 47 insertions(+), 12 deletions(-) diff --git a/src/app/channel/media/media.js b/src/app/channel/media/media.js index a072e6e..609f1bf 100644 --- a/src/app/channel/media/media.js +++ b/src/app/channel/media/media.js @@ -26,9 +26,9 @@ class media{ * @param {String} id - Video ID from source (IE: youtube watch code/archive.org file path) * @param {String} type - Original video source * @param {Number} duration - Length of media in seconds - * @param {String} rawLink - URL to raw file copy of media, not applicable to all sources + * @param {String} rawLink - URLs to raw file copies of media, not applicable to all sources, not saved to the DB */ - constructor(title, fileName, url, id, type, duration, rawLink = url){ + constructor(title, fileName, url, id, type, duration, rawLink){ /** * Chosen title of media */ @@ -59,10 +59,18 @@ class media{ */ this.duration = duration; - /** - * URL to raw file copy of media, not applicable to all sources - */ - this.rawLink = rawLink; + if(rawLink == null){ + /** + * URL to raw file copy of media, not applicable to all sources + */ + this.rawLink = { + audio: [], + video: [], + combo: [['default',url]] + }; + }else{ + this.rawLink = rawLink; + } } } diff --git a/src/utils/media/yanker.js b/src/utils/media/yanker.js index 8072712..c5109ab 100644 --- a/src/utils/media/yanker.js +++ b/src/utils/media/yanker.js @@ -67,6 +67,8 @@ module.exports.yankMedia = async function(url, title){ module.exports.refreshRawLink = async function(mediaObj){ switch(mediaObj.type){ case 'yt': + console.log("lolnope"); + /* We're skipping this one for now... //Scrape expiration from query strings const expires = mediaObj.rawLink.match(/expire=([0-9]+)/); //Went with regex for speed, but I figure I'd keep this around in case we want the accuracy of a battle-tested implementation @@ -85,6 +87,7 @@ module.exports.refreshRawLink = async function(mediaObj){ //return media object return mediaObj; + */ } //Return null to tell the calling function there is no refresh required for this media type diff --git a/src/utils/media/ytdlpUtils.js b/src/utils/media/ytdlpUtils.js index 075189a..e6bfcd6 100644 --- a/src/utils/media/ytdlpUtils.js +++ b/src/utils/media/ytdlpUtils.js @@ -100,7 +100,7 @@ module.exports.fetchDailymotionMetadata = async function(id, title){ * @param {String} type - Link type to attach to the resulting media object * @returns {Array} Array of Media objects containing relevant metadata */ -async function fetchVideoMetadata(link, title, type, format = 'b'){ +async function fetchVideoMetadata(link, title, type, format = 'ba,bv'){ //Create media list const mediaList = []; @@ -109,16 +109,40 @@ async function fetchVideoMetadata(link, title, type, format = 'b'){ //Pull data from rawMetadata, sanatizing title to prevent XSS const name = validator.escape(validator.trim(rawMetadata.title)); - const rawLink = rawMetadata.requested_downloads[0].url; + + //Create new raw link object (should we make a class? Probably over kill for a fucking method-less hashtable) + const rawLinks = { + audio: [], + video: [], + combo: [] + } + + //for each item + for(const link of rawMetadata.requested_downloads){ + //if there isn't video included + if(link.vcodec == 'none'){ + //Add the link under the format within the audio map + rawLinks.audio.push([link.format_note, link.url]); + //if there isn't audio included + }else if(link.acodec == 'none'){ + //Add the link under the format within the video map + rawLinks.video.push([link.format_note, link.url]); + //otherwise, it includes audio and video + }else{ + //Add the link under the format within the combo map + rawLinks.combo.push([link.format_note, link.url]); + } + } + const id = rawMetadata.id; //if we where handed a null title if(title == null || title == ''){ //Create new media object from file info substituting filename for title - mediaList.push(new media(name, name, link, id, type, Number(rawMetadata.duration), rawLink)); + mediaList.push(new media(name, name, link, id, type, Number(rawMetadata.duration), rawLinks)); }else{ //Create new media object from file info - mediaList.push(new media(title, name, link, id, type, Number(rawMetadata.duration), rawLink)); + mediaList.push(new media(title, name, link, id, type, Number(rawMetadata.duration), rawLinks)); } //Return list of media @@ -136,10 +160,10 @@ async function fetchVideoMetadata(link, title, type, format = 'b'){ * @param {String} format - Format string to hand YT-DLP, defaults to 'b' * @returns {Object} Metadata dump from YT-DLP */ -async function ytdlpFetch(link, format = 'b'){ +async function ytdlpFetch(link, format = 'ba,ogg'){ //return promise from ytdlp return ytdlp(link, { + format, dumpSingleJson: true, - format }); } \ No newline at end of file From a59b6d0e192d21de69f66446947a1e3b129cf809 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Fri, 31 Oct 2025 20:22:17 -0400 Subject: [PATCH 80/92] Started work on syncronizing seperated audio and video tracks into back into one player. --- www/js/channel/mediaHandler.js | 124 ++++++++++++++++++++++++++++++++- www/js/channel/player.js | 4 +- 2 files changed, 124 insertions(+), 4 deletions(-) diff --git a/www/js/channel/mediaHandler.js b/www/js/channel/mediaHandler.js index f15ef89..0eb0d4e 100644 --- a/www/js/channel/mediaHandler.js +++ b/www/js/channel/mediaHandler.js @@ -328,6 +328,18 @@ class rawFileBase extends mediaHandler{ //Pull volume from video this.player.volume = this.video.volume; } + + onSeek(event){ + super.onSeek(event); + } + + onBuffer(event){ + super.onBuffer(event); + } + + onPause(event){ + super.onPause(event); + } } /** @@ -397,17 +409,48 @@ class rawFileHandler extends rawFileBase{ //Run derived method super.defineListeners(); + this.video.addEventListener('playing', this.onPlay.bind(this)); this.video.addEventListener('pause', this.onPause.bind(this)); this.video.addEventListener('seeked', this.onSeek.bind(this)); this.video.addEventListener('waiting', this.onBuffer.bind(this)); } + buildPlayer(){ + super.buildPlayer(); + + this.audio = new Audio(); + } + + destroyPlayer(){ + //Call derived method + super.destroyPlayer(); + + //Destroy the audio player + this.audio.pause(); + this.audio.remove(); + } + start(){ //Call derived start super.start(); - //Set video - this.video.src = this.nowPlaying.rawLink; + //Just pull the combo source by default + const combo = this.nowPlaying.rawLink.combo[0] + + //Check if the combo source is null + if(combo != null){ + //Set video + this.video.src = combo[1]; + }else{ + + //Pull video only link + const video = this.nowPlaying.rawLink.video[0] + const audio = this.nowPlaying.rawLink.audio[0]; + + //Set video source + this.video.src = video[1]; + this.audio.src = audio[1]; + } //Set video volume this.video.volume = this.player.volume; @@ -417,14 +460,38 @@ class rawFileHandler extends rawFileBase{ //play video this.video.play(); + + //if we have an audio src + if(this.audio.src != ""){ + //Set audio volume + this.audio.volume = this.player.volume; + + //Play it too + this.audio.play(); + } } play(){ + //play video this.video.play(); + + + //if we have a seperate audio track + if(this.audio.src != ""){ + //Play it too + this.audio.play(); + } } pause(){ + //pause video this.video.pause(); + + //if we have a seperate audio track + if(this.audio.src != ""){ + //Pause it too + this.audio.pause(); + } } sync(timestamp = this.lastTimestamp){ @@ -436,12 +503,65 @@ class rawFileHandler extends rawFileBase{ //Set current video time based on timestamp received from server this.video.currentTime = timestamp; } + + //if we have a seperate audio track + if(this.audio != ""){ + //Re-sync it to the video, regardless if we synced video + this.audio.currentTime = this.video.currentTime; + } } getTimestamp(){ //Return current timestamp return this.video.currentTime; } + + onSeek(event){ + //Call derived event + super.onSeek(event); + + //if we have a seperate audio track + if(this.audio != "" && this.video != null){ + //Set it's timestamp too + this.audio.currentTime = this.video.currentTime; + } + } + + onBuffer(event){ + //Call derived event + super.onBuffer(event); + + //if we have a seperate audio track + if(this.audio != "" && this.video != null){ + //Set it's timestamp + this.audio.currentTime = this.video.currentTime; + //pause it + this.audio.pause(); + } + } + + onPause(event){ + //Call derived event + super.onPause(event); + + //if we have a seperate audio track + if(this.audio != "" && this.video != null){ + //Set it's timestamp + this.audio.currentTime = this.video.currentTime; + //pause it + this.audio.pause(); + } + } + + onPlay(event){ + //if we have a seperate audio track + if(this.audio != "" && this.video != null){ + //Set it's timestamp + this.audio.currentTime = this.video.currentTime; + //pause it + this.audio.play(); + } + } } /** diff --git a/www/js/channel/player.js b/www/js/channel/player.js index 568886f..612d946 100644 --- a/www/js/channel/player.js +++ b/www/js/channel/player.js @@ -195,12 +195,12 @@ class player{ //If we're running a source from IA if(data.media.type == 'ia'){ //Replace specified CDN with generic URL, in-case of hard reload - data.media.rawLink = data.media.rawLink.replace(/^https(.*)archive\.org(.*)items/g, "https://archive.org/download") + //data.media.rawLink = data.media.rawLink.replace(/^https(.*)archive\.org(.*)items/g, "https://archive.org/download") //If we have an IA source and a custom IA CDN Server set if(data.media.type == 'ia' && localStorage.getItem("IACDN") != ""){ //Generate and set new link - data.media.rawLink = data.media.rawLink.replace("https://archive.org/download", `https://${localStorage.getItem("IACDN")}.archive.org/0/items`); + //data.media.rawLink = data.media.rawLink.replace("https://archive.org/download", `https://${localStorage.getItem("IACDN")}.archive.org/0/items`); } } From ccb1d91a5bed870f04931e5779d5fc70facbff88 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Sat, 1 Nov 2025 07:10:40 -0400 Subject: [PATCH 81/92] Seperate audio tracks are now *reasonably* synchronized to their videos. This may need more testing to perfect. --- www/js/channel/mediaHandler.js | 46 +++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/www/js/channel/mediaHandler.js b/www/js/channel/mediaHandler.js index 0eb0d4e..b4792af 100644 --- a/www/js/channel/mediaHandler.js +++ b/www/js/channel/mediaHandler.js @@ -401,6 +401,15 @@ class rawFileHandler extends rawFileBase{ //Call derived constructor super(client, player, media, 'raw'); + //Re-sync audio every .1 seconds + this.audioSyncDelta = 1000; + + //Set audio sync tolerance to .3 + this.audioSyncTolerance = .3; + + //Create value to hold last calculated difference between audio and video timestamp + this.lastAudioDelta = 0; + //Define listeners this.defineListeners(); } @@ -428,6 +437,7 @@ class rawFileHandler extends rawFileBase{ //Destroy the audio player this.audio.pause(); this.audio.remove(); + clearInterval(this.audioInterval); } start(){ @@ -461,14 +471,11 @@ class rawFileHandler extends rawFileBase{ //play video this.video.play(); - //if we have an audio src + /*/if we have an audio src if(this.audio.src != ""){ - //Set audio volume - this.audio.volume = this.player.volume; - //Play it too this.audio.play(); - } + }*/ } play(){ @@ -476,22 +483,22 @@ class rawFileHandler extends rawFileBase{ this.video.play(); - //if we have a seperate audio track + /*/if we have a seperate audio track if(this.audio.src != ""){ //Play it too this.audio.play(); - } + }*/ } pause(){ //pause video this.video.pause(); - //if we have a seperate audio track + /*/if we have a seperate audio track if(this.audio.src != ""){ //Pause it too this.audio.pause(); - } + }*/ } sync(timestamp = this.lastTimestamp){ @@ -537,6 +544,7 @@ class rawFileHandler extends rawFileBase{ this.audio.currentTime = this.video.currentTime; //pause it this.audio.pause(); + clearInterval(this.audioInterval); } } @@ -550,18 +558,38 @@ class rawFileHandler extends rawFileBase{ this.audio.currentTime = this.video.currentTime; //pause it this.audio.pause(); + clearInterval(this.audioInterval); } } onPlay(event){ //if we have a seperate audio track if(this.audio != "" && this.video != null){ + //Set audio volume + this.audio.volume = this.player.volume; //Set it's timestamp this.audio.currentTime = this.video.currentTime; //pause it this.audio.play(); + this.audioInterval = setInterval(this.syncAudio.bind(this), this.audioSyncDelta); } } + + syncAudio(){ + //get current audi odelta + const audioDelta = this.video.currentTime - this.audio.currentTime; + + //If the audio is out of sync enough that someone would notice + if(Math.abs(audioDelta) > this.audioSyncTolerance){ + //Set audio volume + this.audio.volume = this.player.volume; + //Re-sync the audio + this.audio.currentTime = this.video.currentTime + audioDelta; + } + + //Set last audio delta + this.lastAudioDelta = audioDelta; + } } /** From b57d723d62fbbb5e287c14769ff3a034daa1af90 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Sat, 1 Nov 2025 07:45:23 -0400 Subject: [PATCH 82/92] Fixed client-side IACDN setting. --- www/js/channel/channel.js | 1 + www/js/channel/panels/settingsPanel.js | 2 +- www/js/channel/player.js | 16 +++++++++++----- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/www/js/channel/channel.js b/www/js/channel/channel.js index 8bc9a24..5d50499 100644 --- a/www/js/channel/channel.js +++ b/www/js/channel/channel.js @@ -242,6 +242,7 @@ class channel{ //If we're playing a video from Internet Archive if(nowPlaying != null && nowPlaying.type == 'ia'){ + console.log("RELOAD"); //Hard reload the media, forcing media handler re-creation this.player.hardReload(); } diff --git a/www/js/channel/panels/settingsPanel.js b/www/js/channel/panels/settingsPanel.js index d3949ef..e9f0519 100644 --- a/www/js/channel/panels/settingsPanel.js +++ b/www/js/channel/panels/settingsPanel.js @@ -137,7 +137,7 @@ class settingsPanel extends panelObj{ //If we hit enter if(event.key == "Enter"){ //If we have an invalid server string - if(!(this.iaCDN.value.match(/^ia[0-9]{6}\...$/g) || this.iaCDN.value == "")){ + if(!(this.iaCDN.value.match(/^(ia|dn)[0-9]{6}\...$/g) || this.iaCDN.value == "")){ //reset back to what was set before this.iaCDN.value = localStorage.getItem('IACDN'); diff --git a/www/js/channel/player.js b/www/js/channel/player.js index 612d946..3303b14 100644 --- a/www/js/channel/player.js +++ b/www/js/channel/player.js @@ -192,15 +192,21 @@ class player{ this.mediaHandler = new hlsDailymotionHandler(this.client, this, data.media); //Otherwise, if we have a raw-file compatible source }else if(data.media.type == 'ia' || data.media.type == 'raw' || data.media.type == 'yt' || data.media.type == 'dm'){ + //If we're running a source from IA if(data.media.type == 'ia'){ - //Replace specified CDN with generic URL, in-case of hard reload - //data.media.rawLink = data.media.rawLink.replace(/^https(.*)archive\.org(.*)items/g, "https://archive.org/download") - //If we have an IA source and a custom IA CDN Server set if(data.media.type == 'ia' && localStorage.getItem("IACDN") != ""){ - //Generate and set new link - //data.media.rawLink = data.media.rawLink.replace("https://archive.org/download", `https://${localStorage.getItem("IACDN")}.archive.org/0/items`); + for(const linkIndex in data.media.rawLink.combo){ + //Pull link to sprinkle on dat syntatic sugar (tasty) + let link = data.media.rawLink.combo[linkIndex][1] + + //Replace specified CDN with generic URL, in-case of hard reload + link = link.replace(/^https(.*)archive\.org(.*)items/g, "https://archive.org/download"); + + //Generate and set new link + data.media.rawLink.combo[linkIndex][1] = link.replace("https://archive.org/download", `https://${localStorage.getItem("IACDN")}.archive.org/0/items`); + } } } From a70879c76cc42c6ddb94b1709a63864d4132fee8 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Sat, 1 Nov 2025 07:55:34 -0400 Subject: [PATCH 83/92] Quick cleanup. --- src/views/partial/panels/settings.ejs | 2 +- www/js/channel/player.js | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/views/partial/panels/settings.ejs b/src/views/partial/panels/settings.ejs index 5fd29d7..6fe53d2 100644 --- a/src/views/partial/panels/settings.ejs +++ b/src/views/partial/panels/settings.ejs @@ -20,7 +20,7 @@ along with this program. If not, see . %>

Youtube Player Type:

diff --git a/www/js/channel/player.js b/www/js/channel/player.js index 3303b14..ddbdd0e 100644 --- a/www/js/channel/player.js +++ b/www/js/channel/player.js @@ -276,8 +276,6 @@ class player{ //End current media handler this.end(); - console.log(data); - //Restart from last media handlers this.start(data); } From 02c4d214fa6ea8505e2a6df868802ef35aa9303d Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Sat, 1 Nov 2025 08:09:54 -0400 Subject: [PATCH 84/92] Started work on re-implementation of youtube raw-link reloading. --- src/utils/media/yanker.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/utils/media/yanker.js b/src/utils/media/yanker.js index c5109ab..76df22e 100644 --- a/src/utils/media/yanker.js +++ b/src/utils/media/yanker.js @@ -67,7 +67,6 @@ module.exports.yankMedia = async function(url, title){ module.exports.refreshRawLink = async function(mediaObj){ switch(mediaObj.type){ case 'yt': - console.log("lolnope"); /* We're skipping this one for now... //Scrape expiration from query strings const expires = mediaObj.rawLink.match(/expire=([0-9]+)/); @@ -80,14 +79,16 @@ module.exports.refreshRawLink = async function(mediaObj){ return null; } + */ + //Re-fetch media metadata metadata = await ytdlpUtil.fetchYoutubeMetadata(mediaObj.id); + //Refresh media rawlink from metadata mediaObj.rawLink = metadata[0].rawLink; //return media object return mediaObj; - */ } //Return null to tell the calling function there is no refresh required for this media type From 366766d0a369b015d45a534b0021d26a82b33b41 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Sat, 1 Nov 2025 08:51:30 -0400 Subject: [PATCH 85/92] Finished up work with youtube raw link refreshing. --- src/utils/media/yanker.js | 49 +++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/src/utils/media/yanker.js b/src/utils/media/yanker.js index 76df22e..2dff869 100644 --- a/src/utils/media/yanker.js +++ b/src/utils/media/yanker.js @@ -67,28 +67,41 @@ module.exports.yankMedia = async function(url, title){ module.exports.refreshRawLink = async function(mediaObj){ switch(mediaObj.type){ case 'yt': - /* We're skipping this one for now... - //Scrape expiration from query strings - const expires = mediaObj.rawLink.match(/expire=([0-9]+)/); - //Went with regex for speed, but I figure I'd keep this around in case we want the accuracy of a battle-tested implementation - //const expires = new URL(mediaObj.rawLink).searchParams.get("expire"); + //Create boolean to hold expired state + let expired = false; + //Create boolean to hold whether or not rawLink object is empty + let empty = true; - //If we have a valid raw file link that will be good by the end of the video - if(expires != null && (expires * 1000) > mediaObj.getEndTime()){ - //Return null to tell the calling function there is no refresh required for this video at this time - return null; + //For each link map in the rawLink object + for(const key of Object.keys(mediaObj.rawLink)){ + //Ignore da wombo-combo since it's probably just the fuckin regular URL + if(key != "combo"){ + for(const link of mediaObj.rawLink[key]){ + //Let it be known, this bitch got links + empty = false; + //Get expiration parameter from the link + const expires = new URL(link[1]).searchParams.get("expire") * 1000; + + //If this shit's already expired + if(expires < Date.now()){ + //Set expired to true, don't directly set the bool because we don't ever want to unset this flag + expired = true; + } + } + } } - */ + //If the raw link object is empty or expired + if(empty || expired){ + //Re-fetch media metadata + metadata = await ytdlpUtil.fetchYoutubeMetadata(mediaObj.id); + + //Refresh media rawlink from metadata + mediaObj.rawLink = metadata[0].rawLink; - //Re-fetch media metadata - metadata = await ytdlpUtil.fetchYoutubeMetadata(mediaObj.id); - - //Refresh media rawlink from metadata - mediaObj.rawLink = metadata[0].rawLink; - - //return media object - return mediaObj; + //return media object + return mediaObj; + } } //Return null to tell the calling function there is no refresh required for this media type From c3712bfd779e9a1243b86454fcd02bb21114b93f Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Sat, 1 Nov 2025 09:10:47 -0400 Subject: [PATCH 86/92] Added some nullchecks to mediaHandler.js to quiet down stale handlers right after death. --- www/js/channel/mediaHandler.js | 31 +++++++------------------------ 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/www/js/channel/mediaHandler.js b/www/js/channel/mediaHandler.js index b4792af..3dd11ae 100644 --- a/www/js/channel/mediaHandler.js +++ b/www/js/channel/mediaHandler.js @@ -470,35 +470,18 @@ class rawFileHandler extends rawFileBase{ //play video this.video.play(); - - /*/if we have an audio src - if(this.audio.src != ""){ - //Play it too - this.audio.play(); - }*/ } play(){ + super.play(); //play video this.video.play(); - - - /*/if we have a seperate audio track - if(this.audio.src != ""){ - //Play it too - this.audio.play(); - }*/ } pause(){ + super.pause(); //pause video this.video.pause(); - - /*/if we have a seperate audio track - if(this.audio.src != ""){ - //Pause it too - this.audio.pause(); - }*/ } sync(timestamp = this.lastTimestamp){ @@ -512,7 +495,7 @@ class rawFileHandler extends rawFileBase{ } //if we have a seperate audio track - if(this.audio != ""){ + if(this.audio != null && this.audio != ""){ //Re-sync it to the video, regardless if we synced video this.audio.currentTime = this.video.currentTime; } @@ -528,7 +511,7 @@ class rawFileHandler extends rawFileBase{ super.onSeek(event); //if we have a seperate audio track - if(this.audio != "" && this.video != null){ + if(this.audio != null && this.audio != "" && this.video != null){ //Set it's timestamp too this.audio.currentTime = this.video.currentTime; } @@ -539,7 +522,7 @@ class rawFileHandler extends rawFileBase{ super.onBuffer(event); //if we have a seperate audio track - if(this.audio != "" && this.video != null){ + if(this.audio != null && this.audio != "" && this.video != null){ //Set it's timestamp this.audio.currentTime = this.video.currentTime; //pause it @@ -553,7 +536,7 @@ class rawFileHandler extends rawFileBase{ super.onPause(event); //if we have a seperate audio track - if(this.audio != "" && this.video != null){ + if(this.audio != null && this.audio != "" && this.video != null){ //Set it's timestamp this.audio.currentTime = this.video.currentTime; //pause it @@ -564,7 +547,7 @@ class rawFileHandler extends rawFileBase{ onPlay(event){ //if we have a seperate audio track - if(this.audio != "" && this.video != null){ + if(this.audio != null && this.audio != "" && this.video != null){ //Set audio volume this.audio.volume = this.player.volume; //Set it's timestamp From dd36b1d923ee34d7c9343c861531de3caa0ab393 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Sat, 1 Nov 2025 11:34:50 -0400 Subject: [PATCH 87/92] Optimized queue.removeRange() --- src/app/channel/media/queue.js | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/app/channel/media/queue.js b/src/app/channel/media/queue.js index 3905702..d76a92a 100644 --- a/src/app/channel/media/queue.js +++ b/src/app/channel/media/queue.js @@ -360,6 +360,7 @@ class queue{ chanDB.media.liveRemainder = this.nowPlaying.uuid; //Save the chanDB + console.log("Saving db from goLive() :363"); await chanDB.save(); } @@ -592,9 +593,24 @@ class queue{ //For each item for(let item of foundItems){ //Remove media, passing down chanDB so we're not looking again and again - await this.removeMedia(item.uuid, socket, chanDB); + await this.removeMedia(item.uuid, socket, chanDB, true); + + //If this is the current item + if(this.nowPlaying != null && item.uuid == this.nowPlaying.uuid){ + //End it + await this.end(false, true, false, chanDB); + } } + + //Save data to channe database + await chanDB.save(); + + //Refresh next timer + await this.refreshNextTimer(); + + console.log("Saved by removeRange() :599"); + }catch(err){ //If this was originated by someone if(socket != null){ @@ -788,6 +804,7 @@ class queue{ //If saving is disabled if(!noScheduling){ //Save changes to the DB + console.log("Saving db from removeMedia() :792"); await chanDB.save(); } } @@ -844,6 +861,7 @@ class queue{ //If saving is enabled (seperate from all DB transactions since caller function may want modifications but handle saving on its own) if(!noScheduling){ + console.log("Saving db from removeMedia() :849"); await chanDB.save(); } @@ -1034,6 +1052,7 @@ class queue{ try{ //If saving is enabled (seperate from all DB transactions since caller function may want modifications but handle saving on its own) if(!noSave){ + console.log("Saving db from scheduleMedia() :1040"); //Save the database await chanDB.save(); } @@ -1130,6 +1149,9 @@ class queue{ return record.uuid != mediaObj.uuid; }); + + console.log("Saving db from start() :1138"); + //Save the channel await chanDB.save(); @@ -1264,6 +1286,9 @@ class queue{ //broadcast queue using unsaved archive, run this before chanDB.save() for better responsiveness this.broadcastQueue(chanDB); + + console.log("Saving db from end() :1275"); + //Save our changes to the DB await chanDB.save(); } @@ -1293,6 +1318,7 @@ class queue{ } //Disable stream lock this.streamLock = false; + console.log("testem"); //We don't have to save here since someone else will do it for us :) //Reminder for those of us reading this in the future since I'm a dipshit: this only clears the DB liveRemainder, NOT the RAM backed variable @@ -1359,6 +1385,9 @@ class queue{ //Throw the livestream into the archive chanDB.media.archived.push(wasPlaying); + + console.log("Saving db from livestreamOverwriteSchedule() :1373"); + //Save the DB await chanDB.save(); @@ -1852,6 +1881,9 @@ class queue{ //Update schedule to only contain what hasn't been played yet chanDB.media.scheduled = newSched; + + console.log("Saving db from rehydrateQueue() :1869"); + //Save the DB await chanDB.save() From 75301ec7d9e1efd0416f81115ccd487ae53c13c5 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Mon, 3 Nov 2025 00:13:17 -0500 Subject: [PATCH 88/92] Finished optimizing automated queue transactions. --- src/app/channel/media/queue.js | 53 ++++++++++++++++------------------ 1 file changed, 25 insertions(+), 28 deletions(-) diff --git a/src/app/channel/media/queue.js b/src/app/channel/media/queue.js index d76a92a..0036019 100644 --- a/src/app/channel/media/queue.js +++ b/src/app/channel/media/queue.js @@ -360,7 +360,6 @@ class queue{ chanDB.media.liveRemainder = this.nowPlaying.uuid; //Save the chanDB - console.log("Saving db from goLive() :363"); await chanDB.save(); } @@ -593,12 +592,12 @@ class queue{ //For each item for(let item of foundItems){ //Remove media, passing down chanDB so we're not looking again and again - await this.removeMedia(item.uuid, socket, chanDB, true); + await this.removeMedia(item.uuid, socket, chanDB, true, true); //If this is the current item if(this.nowPlaying != null && item.uuid == this.nowPlaying.uuid){ //End it - await this.end(false, true, false, chanDB); + await this.end(false, true, false, chanDB, true); } } @@ -609,7 +608,8 @@ class queue{ //Refresh next timer await this.refreshNextTimer(); - console.log("Saved by removeRange() :599"); + //Broadcast Queue + await this.broadcastQueue(chanDB); }catch(err){ //If this was originated by someone @@ -749,9 +749,10 @@ class queue{ * @param {Socket} socket - Requesting Socket * @param {Mongoose.Document} chanDB - Channnel Document Passthrough to save on DB Access * @param {Boolean} noScheduling - Disables schedule timer refresh and this.save() calls if true, good for internal function calls + * @param {Boolean} noBroadcast - Disables schedule broadcasting to clients, good for internal function calls * @returns {Media} Deleted Media Item */ - async removeMedia(uuid, socket, chanDB, noScheduling = false){ + async removeMedia(uuid, socket, chanDB, noScheduling = false, noBroadcast = false){ //If we're streamlocked if(this.streamLock){ //If an originating socket was provided for this request @@ -798,13 +799,15 @@ class queue{ } //Otherwise }else{ - //Broadcast changes - this.broadcastQueue(chanDB); + //If broadcasting is enabled + if(!noBroadcast){ + //Broadcast changes + this.broadcastQueue(chanDB); + } //If saving is disabled if(!noScheduling){ //Save changes to the DB - console.log("Saving db from removeMedia() :792"); await chanDB.save(); } } @@ -861,16 +864,21 @@ class queue{ //If saving is enabled (seperate from all DB transactions since caller function may want modifications but handle saving on its own) if(!noScheduling){ - console.log("Saving db from removeMedia() :849"); await chanDB.save(); } - //Broadcast the channel - this.broadcastQueue(chanDB); + //If broadcasting is enabled + if(!noBroadcast){ + //Broadcast the channel + this.broadcastQueue(chanDB); + } }catch(err){ - //Broadcast the channel - this.broadcastQueue(); + //If broadcasting is enabled + if(!noBroadcast){ + //Broadcast the channel + this.broadcastQueue(); + } //If this was originated by someone if(socket != null){ @@ -1052,7 +1060,6 @@ class queue{ try{ //If saving is enabled (seperate from all DB transactions since caller function may want modifications but handle saving on its own) if(!noSave){ - console.log("Saving db from scheduleMedia() :1040"); //Save the database await chanDB.save(); } @@ -1149,9 +1156,6 @@ class queue{ return record.uuid != mediaObj.uuid; }); - - console.log("Saving db from start() :1138"); - //Save the channel await chanDB.save(); @@ -1286,9 +1290,6 @@ class queue{ //broadcast queue using unsaved archive, run this before chanDB.save() for better responsiveness this.broadcastQueue(chanDB); - - console.log("Saving db from end() :1275"); - //Save our changes to the DB await chanDB.save(); } @@ -1318,7 +1319,6 @@ class queue{ } //Disable stream lock this.streamLock = false; - console.log("testem"); //We don't have to save here since someone else will do it for us :) //Reminder for those of us reading this in the future since I'm a dipshit: this only clears the DB liveRemainder, NOT the RAM backed variable @@ -1385,9 +1385,6 @@ class queue{ //Throw the livestream into the archive chanDB.media.archived.push(wasPlaying); - - console.log("Saving db from livestreamOverwriteSchedule() :1373"); - //Save the DB await chanDB.save(); @@ -1509,7 +1506,7 @@ class queue{ const mediaObj = entry[1]; //Remove media from queue without calling chanDB.save() to make room before we move everything - await this.removeMedia(mediaObj.uuid, null, chanDB, true); + await this.removeMedia(mediaObj.uuid, null, chanDB, true, true); mediaObj.genUUID(); @@ -1525,6 +1522,9 @@ class queue{ //Schedule the moved schedule, letting scheduleMedia save our changes for us, starting w/o saves to prevent over-saving await this.scheduleMedia(newSched, null, chanDB); + + //Broadcast the queue now that everything is hunky dory + await this.broadcastQueue(chanDB); }catch(err){ //Null out live remainder for the next stream this.liveRemainder = null; @@ -1881,9 +1881,6 @@ class queue{ //Update schedule to only contain what hasn't been played yet chanDB.media.scheduled = newSched; - - console.log("Saving db from rehydrateQueue() :1869"); - //Save the DB await chanDB.save() From ade2a4210dc0170b2be2e28d42e16bea30d926da Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Mon, 3 Nov 2025 19:07:38 -0500 Subject: [PATCH 89/92] User IP Hashes are now salted with 24 bits from a cryptographically secure random generation function formatted into base 64 for extra privacy/security. --- src/app/chatPreprocessor.js | 1 - src/schemas/user/userBanSchema.js | 4 +--- src/schemas/user/userSchema.js | 6 ++---- src/utils/hashUtils.js | 35 +++++++++++++++++++++++++------ 4 files changed, 32 insertions(+), 14 deletions(-) diff --git a/src/app/chatPreprocessor.js b/src/app/chatPreprocessor.js index be7cf26..a4331ab 100644 --- a/src/app/chatPreprocessor.js +++ b/src/app/chatPreprocessor.js @@ -132,7 +132,6 @@ class chatPreprocessor{ commandObj.links = []; //For each link sent from the client - //this.rawData.links.forEach((link) => { for (const link of commandObj.rawData.links){ //Add a marked link object to our links array commandObj.links.push(await linkUtils.markLink(link)); diff --git a/src/schemas/user/userBanSchema.js b/src/schemas/user/userBanSchema.js index 0c1db1c..0fd352c 100644 --- a/src/schemas/user/userBanSchema.js +++ b/src/schemas/user/userBanSchema.js @@ -73,8 +73,6 @@ const userBanSchema = new mongoose.Schema({ * @returns {Mongoose.Document} Found ban Document if one exists. */ 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 @@ -106,7 +104,7 @@ userBanSchema.statics.checkBanByIP = async function(ip){ const curHash = ban.ips.hashed[ipIndex]; //Check the current hash against the given hash - if(ipHash == curHash){ + if(hashUtil.compareIPHash(ip, curHash)){ //If it matches we found the ban foundBan = ban; diff --git a/src/schemas/user/userSchema.js b/src/schemas/user/userSchema.js index 2d8517c..41dfcda 100644 --- a/src/schemas/user/userSchema.js +++ b/src/schemas/user/userSchema.js @@ -757,8 +757,6 @@ 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 @@ -766,7 +764,7 @@ userSchema.methods.tattooIPRecord = async function(ip){ //For every IP record in the current user for(let curRecord of curUser.recentIPs){ //If it matches the current ipHash - if(curRecord.ipHash == ipHash){ + if(hashUtil.compareIPHash(ip, curRecord.ipHash)){ //Check if we've already marked the user as an alt const foundAlt = this.alts.indexOf(curUser._id); @@ -803,7 +801,7 @@ userSchema.methods.tattooIPRecord = async function(ip){ //Look for matching ip record function checkHash(ipRecord){ //return matching records - return ipRecord.ipHash == ipHash; + return hashUtil.compareIPHash(ip, ipRecord.ipHash); } } diff --git a/src/utils/hashUtils.js b/src/utils/hashUtils.js index e60d81e..a428066 100644 --- a/src/utils/hashUtils.js +++ b/src/utils/hashUtils.js @@ -60,17 +60,40 @@ module.exports.compareLegacyPassword = function(pass, hash){ * * Provides a basic level of privacy by only logging salted hashes of IP's * @param {String} ip - IP to hash - * @returns {String} Hashed/Peppered IP Adress + * @param {String} salt - (optional) string to salt IP with, leave empty to default to a securely generated string encoded in base64 + * @returns {String} Hashed/Peppered/Salted IP Address */ -module.exports.hashIP = function(ip){ +module.exports.hashIP= function(ip, salt){ //Create hash object const hashObj = crypto.createHash('sha512'); - //add IP and pepper to the hash - hashObj.update(`${ip}${config.secrets.ipSecret}`); + //If we wheren't provided salt + if(salt == null){ + //Generate salt with cryptographically secure rng function + const rawSalt = crypto.randomBytes(24); + //Convert generated salt to base64 + salt = rawSalt.toString('base64'); + } - //return the IP hash as a string - return hashObj.digest('hex'); + //Generate new salted hash + hashObj.update(`${ip}${config.secrets.ipSecret}${salt}`); + + //Convert hash data into a base64 string + const hash = hashObj.digest('base64'); + + //Return salty hash + return `${salt}$${hash}`; +} + +module.exports.compareIPHash = function(ip, hash){ + //Split hash by salt delimiter + const splitHash = hash.split("$"); + + //Re-generate hash from received plaintext IP and salt scraped from existing hash + const tempHash = module.exports.hashIP(ip, splitHash[0]); + + //If the hash we calculates matches the original + return tempHash == hash; } /** From 35fd81e1b2fb8c9fafc32b78aa4a9de75e18fd20 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Mon, 3 Nov 2025 19:11:31 -0500 Subject: [PATCH 90/92] Improved JSDoc for IP Hash Comparison function. --- src/utils/hashUtils.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/utils/hashUtils.js b/src/utils/hashUtils.js index a428066..47352fb 100644 --- a/src/utils/hashUtils.js +++ b/src/utils/hashUtils.js @@ -85,6 +85,14 @@ module.exports.hashIP= function(ip, salt){ return `${salt}$${hash}`; } +/** + * Site-wide IP hash comparison function + * + * Allows us to identify new plaintext IP's against saved IP hashes + * @param {String} ip - IP to hash + * @param {String} salt - (optional) string to salt IP with, leave empty to default to a securely generated string encoded in base64 + * @returns {Boolean} Whether or not the IP matched the hash + */ module.exports.compareIPHash = function(ip, hash){ //Split hash by salt delimiter const splitHash = hash.split("$"); From 08fe051269785530f08adac00709aefa89f8fb8c Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Tue, 4 Nov 2025 06:09:26 -0500 Subject: [PATCH 91/92] Improved sanatization for server-side templating. --- src/controllers/adminPanelController.js | 6 +++- src/controllers/channelSettingsController.js | 5 +++- src/controllers/indexController.js | 5 +++- src/controllers/panel/profileController.js | 5 +++- src/controllers/profileController.js | 9 ++++-- src/controllers/tooltip/altListController.js | 3 +- src/controllers/tooltip/profileController.js | 7 +++-- src/schemas/tokebot/tokeSchema.js | 2 +- src/views/adminPanel.ejs | 4 +-- src/views/channelSettings.ejs | 8 +++--- src/views/index.ejs | 10 +++---- src/views/partial/adminPanel/channelList.ejs | 18 ++++++------ src/views/partial/adminPanel/permList.ejs | 12 ++++---- src/views/partial/adminPanel/userList.ejs | 26 ++++++++--------- src/views/partial/channelSettings/info.ejs | 6 ++-- .../partial/channelSettings/permList.ejs | 7 +++-- .../partial/channelSettings/settings.ejs | 6 ++-- src/views/partial/panels/profile.ejs | 28 +++++++++++-------- src/views/partial/profile/bio.ejs | 16 +++++++++-- src/views/partial/profile/date.ejs | 2 +- src/views/partial/profile/image.ejs | 2 +- src/views/partial/profile/pronouns.ejs | 4 +-- src/views/partial/profile/settings.ejs | 2 +- src/views/partial/profile/signature.ejs | 4 +-- src/views/partial/profile/status.ejs | 6 ++-- src/views/partial/profile/tokeCount.ejs | 6 ++-- src/views/partial/tooltip/altList.ejs | 12 ++++---- src/views/partial/tooltip/profile.ejs | 10 +++---- src/views/profile.ejs | 16 +++++------ www/css/panel/profile.css | 8 +++++- 30 files changed, 151 insertions(+), 104 deletions(-) diff --git a/src/controllers/adminPanelController.js b/src/controllers/adminPanelController.js index 2d5e72f..ca1a74c 100644 --- a/src/controllers/adminPanelController.js +++ b/src/controllers/adminPanelController.js @@ -17,6 +17,9 @@ along with this program. If not, see .*/ //Config const config = require('../../config.json'); +//NPM Imports +const validator = require('validator');//No express here, so regular validator it is! + //Local Imports const {userModel} = require('../schemas/user/userSchema'); const permissionModel = require('../schemas/permissionSchema'); @@ -45,7 +48,8 @@ module.exports.get = async function(req, res){ chanGuide: chanGuide, userList: userList, permList: permList, - csrfToken: csrfUtils.generateToken(req) + csrfToken: csrfUtils.generateToken(req), + unescape: validator.unescape }); }catch(err){ diff --git a/src/controllers/channelSettingsController.js b/src/controllers/channelSettingsController.js index 5b20aba..e310773 100644 --- a/src/controllers/channelSettingsController.js +++ b/src/controllers/channelSettingsController.js @@ -17,6 +17,9 @@ along with this program. If not, see .*/ //Config const config = require('../../config.json'); +//NPM Imports +const validator = require('validator');//No express here, so regular validator it is! + //local imports const channelModel = require('../schemas/channel/channelSchema'); const permissionModel = require('../schemas/permissionSchema'); @@ -39,7 +42,7 @@ module.exports.get = async function(req, res){ throw loggerUtils.exceptionSmith("Channel not found.", "queue"); } - return res.render('channelSettings', {instance: config.instanceName, user: req.session.user, channel: chanDB, reqRank, rankEnum: permissionModel.rankEnum, csrfToken: csrfUtils.generateToken(req)}); + return res.render('channelSettings', {instance: config.instanceName, user: req.session.user, channel: chanDB, reqRank, rankEnum: permissionModel.rankEnum, csrfToken: csrfUtils.generateToken(req), unescape: validator.unescape}); }catch(err){ return exceptionHandler(res, err); } diff --git a/src/controllers/indexController.js b/src/controllers/indexController.js index bbf1915..30e9682 100644 --- a/src/controllers/indexController.js +++ b/src/controllers/indexController.js @@ -17,6 +17,9 @@ along with this program. If not, see .*/ //Config const config = require('../../config.json'); +//NPM Imports +const validator = require('validator');//No express here, so regular validator it is! + //local imports const channelModel = require('../schemas/channel/channelSchema'); const csrfUtils = require('../utils/csrfUtils'); @@ -26,7 +29,7 @@ const {exceptionHandler, errorHandler} = require('../utils/loggerUtils'); module.exports.get = async function(req, res){ try{ const chanGuide = await channelModel.getChannelList(); - return res.render('index', {instance: config.instanceName, user: req.session.user, chanGuide: chanGuide, csrfToken: csrfUtils.generateToken(req)}); + return res.render('index', {instance: config.instanceName, user: req.session.user, chanGuide: chanGuide, csrfToken: csrfUtils.generateToken(req), unescape: validator.unescape}); }catch(err){ return exceptionHandler(res, err); } diff --git a/src/controllers/panel/profileController.js b/src/controllers/panel/profileController.js index 4a58c7d..f24d1dc 100644 --- a/src/controllers/panel/profileController.js +++ b/src/controllers/panel/profileController.js @@ -17,6 +17,9 @@ along with this program. If not, see .*/ //NPM Imports const {validationResult, matchedData} = require('express-validator'); +//NPM Imports +const validator = require('validator');//No express here, so regular validator it is! + //local imports const presenceUtils = require('../../utils/presenceUtils'); const {userModel} = require('../../schemas/user/userSchema'); @@ -34,7 +37,7 @@ module.exports.get = async function(req, res){ //Pull presence (should be quick since everyone whos been on since last startup will be backed in RAM) const presence = await presenceUtils.getPresence(profile.user); - return res.render('partial/panels/profile', {profile, presence}); + return res.render('partial/panels/profile', {profile, presence, unescape: validator.unescape}); }else{ res.status(400); return res.send({errors: validResult.array()}) diff --git a/src/controllers/profileController.js b/src/controllers/profileController.js index fd13cac..67d1895 100644 --- a/src/controllers/profileController.js +++ b/src/controllers/profileController.js @@ -20,6 +20,9 @@ const csrfUtils = require('../utils/csrfUtils'); const presenceUtils = require('../utils/presenceUtils'); const {exceptionHandler, errorHandler} = require('../utils/loggerUtils'); +//NPM Imports +const validator = require('validator');//No express here, so regular validator it is! + //Config const config = require('../../config.json'); @@ -44,7 +47,8 @@ module.exports.get = async function(req, res){ profile, selfProfile, presence, - csrfToken: csrfUtils.generateToken(req) + csrfToken: csrfUtils.generateToken(req), + unescape: validator.unescape }); }else{ res.render('profile', { @@ -53,7 +57,8 @@ module.exports.get = async function(req, res){ profile: null, selfProfile: false, presence: null, - csrfToken: csrfUtils.generateToken(req) + csrfToken: csrfUtils.generateToken(req), + unescape: validator.unescape }); } }catch(err){ diff --git a/src/controllers/tooltip/altListController.js b/src/controllers/tooltip/altListController.js index a037cd0..d470114 100644 --- a/src/controllers/tooltip/altListController.js +++ b/src/controllers/tooltip/altListController.js @@ -16,6 +16,7 @@ along with this program. If not, see .*/ //NPM Imports const {validationResult, matchedData} = require('express-validator'); +const validator = require('validator');//Because sometimes one isn't enough... //local imports const {userModel} = require('../../schemas/user/userSchema'); @@ -34,7 +35,7 @@ module.exports.get = async function(req, res){ return errorHandler(res, 'Cannot get alts for non-existant user!'); } - return res.render('partial/tooltip/altList', {alts: await userDB.getAltProfiles()}); + return res.render('partial/tooltip/altList', {alts: await userDB.getAltProfiles(), unescape: validator.unescape}); }else{ res.status(400); return res.send({errors: validResult.array()}) diff --git a/src/controllers/tooltip/profileController.js b/src/controllers/tooltip/profileController.js index 18f9cff..e4b4a0c 100644 --- a/src/controllers/tooltip/profileController.js +++ b/src/controllers/tooltip/profileController.js @@ -17,6 +17,9 @@ along with this program. If not, see .*/ //NPM Imports const {validationResult, matchedData} = require('express-validator'); +//NPM Imports +const validator = require('validator');//No express here, so regular validator it is! + //local imports const {userModel} = require('../../schemas/user/userSchema'); const {exceptionHandler, errorHandler} = require('../../utils/loggerUtils'); @@ -30,10 +33,10 @@ module.exports.get = async function(req, res){ const data = matchedData(req); const profile = await userModel.findProfile({user: data.user}); - return res.render('partial/tooltip/profile', {profile}); + return res.render('partial/tooltip/profile', {profile, unescape: validator.unescape}); }else{ res.status(400); - return res.send({errors: validResult.array()}) + return res.send({errors: validResult.array()}); } }catch(err){ diff --git a/src/schemas/tokebot/tokeSchema.js b/src/schemas/tokebot/tokeSchema.js index ca186af..7b86913 100644 --- a/src/schemas/tokebot/tokeSchema.js +++ b/src/schemas/tokebot/tokeSchema.js @@ -91,7 +91,7 @@ tokeSchema.statics.calculateTokeMap = async function(){ //Display calculated toke sats for funsies if(config.verbose){ - console.log(`Processed ${this.commandCount} toke command callouts accross ${await this.estimatedDocumentCount()} tokes.`); + console.log(`Processed ${this.commandCount} toke command callouts accross ${this.count} tokes, averaging ${(this.commandCount/this.count).toFixed(3)} tokers per toke.`); } } diff --git a/src/views/adminPanel.ejs b/src/views/adminPanel.ejs index d34c773..b49d7ea 100644 --- a/src/views/adminPanel.ejs +++ b/src/views/adminPanel.ejs @@ -25,8 +25,8 @@ along with this program. If not, see . %> <%- include('partial/navbar', {user}); %>

<%= instance %> Admin Panel

- <%- include('partial/adminPanel/channelList', {chanGuide}) %> - <%- include('partial/adminPanel/userList', {user, userList, rankEnum}) %> + <%- include('partial/adminPanel/channelList', {chanGuide, unescape}) %> + <%- include('partial/adminPanel/userList', {user, userList, rankEnum, unescape}) %> <%- include('partial/adminPanel/permList', {permList, rankEnum}) %> <%- include('partial/adminPanel/userBanList') %> <%- include('partial/adminPanel/tokeCommandList') %> diff --git a/src/views/channelSettings.ejs b/src/views/channelSettings.ejs index d94e44f..81b4e59 100644 --- a/src/views/channelSettings.ejs +++ b/src/views/channelSettings.ejs @@ -24,13 +24,13 @@ along with this program. If not, see . %> <%- include('partial/navbar', {user}); %> -

<%- channel.name %> - Channel Settings

+

<%= unescape(channel.name) %> - Channel Settings

- <%- include('partial/channelSettings/info.ejs', {channel}); %> + <%- include('partial/channelSettings/info.ejs', {unescape, channel}); %> <%- include('partial/channelSettings/userList.ejs'); %> <%- include('partial/channelSettings/banList.ejs'); %> - <%- include('partial/channelSettings/settings.ejs'); %> - <%- include('partial/channelSettings/permList.ejs'); %> + <%- include('partial/channelSettings/settings.ejs', {unescape, channel}); %> + <%- include('partial/channelSettings/permList.ejs', {channel}); %> <%- include('partial/channelSettings/tokeCommandList.ejs'); %> <%- include('partial/channelSettings/emoteList.ejs'); %>
diff --git a/src/views/index.ejs b/src/views/index.ejs index dc47f6d..4ee326c 100644 --- a/src/views/index.ejs +++ b/src/views/index.ejs @@ -26,11 +26,11 @@ along with this program. If not, see . %>

Start a new channel...

<% chanGuide.forEach((channel) => { %> -
- -

<%- channel.name %>

- -

<%- channel.description %> +

+ +

<%= unescape(channel.name) %>

+ +

<%= unescape(channel.description) %>

<% }); %> diff --git a/src/views/partial/adminPanel/channelList.ejs b/src/views/partial/adminPanel/channelList.ejs index 53311f7..e6e53ec 100644 --- a/src/views/partial/adminPanel/channelList.ejs +++ b/src/views/partial/adminPanel/channelList.ejs @@ -29,19 +29,19 @@ along with this program. If not, see . %> <% chanGuide.forEach((channel) => { %> - - - - + + + + - - - <%- channel.name %> + + + <%= unescape(channel.name) %> - - <%- channel.description %> + + <%= unescape(channel.description) %> <% }); %> diff --git a/src/views/partial/adminPanel/permList.ejs b/src/views/partial/adminPanel/permList.ejs index d0e9ce1..40bce34 100644 --- a/src/views/partial/adminPanel/permList.ejs +++ b/src/views/partial/adminPanel/permList.ejs @@ -20,10 +20,10 @@ along with this program. If not, see . %> <% Object.keys(permList).forEach((key)=>{ %> <% if(key != "channelOverrides"){ %> - - <%rankEnum.slice().reverse().forEach((rank)=>{ %> - + <% }); %> @@ -33,10 +33,10 @@ along with this program. If not, see . %> <% Object.keys(permList.channelOverrides).forEach((key)=>{ %> <% if(key != "channelOverrides"){ %> - - <%rankEnum.slice().reverse().forEach((rank)=>{ %> - + <% }); %> diff --git a/src/views/partial/adminPanel/userList.ejs b/src/views/partial/adminPanel/userList.ejs index d94142a..df54d21 100644 --- a/src/views/partial/adminPanel/userList.ejs +++ b/src/views/partial/adminPanel/userList.ejs @@ -41,42 +41,42 @@ along with this program. If not, see . %> <% userList.forEach((curUser) => { %> - + - - + + - - <%- curUser.id %> + + <%= curUser.id %> - - <%- curUser.user %> + + <%= unescape(curUser.user) %> <% if(rankEnum.indexOf(curUser.rank) < rankEnum.indexOf(user.rank)){%> - <%rankEnum.slice().reverse().forEach((rank)=>{ %> - + <% }); %> <% }else{ %> - <%- curUser.rank %> + <%= curUser.rank %> <% } %> - <%- curUser.email ? curUser.email : "N/A" %> + <%= unescape(curUser.email) ? curUser.email : "N/A" %> - <%- curUser.date.toUTCString() %> + <%= unescape(curUser.date.toUTCString()) %> <%# It's either this or add whitespce >:( %> - + <% }); %> diff --git a/src/views/partial/channelSettings/info.ejs b/src/views/partial/channelSettings/info.ejs index a2f073f..11aeafb 100644 --- a/src/views/partial/channelSettings/info.ejs +++ b/src/views/partial/channelSettings/info.ejs @@ -19,13 +19,13 @@ along with this program. If not, see . %>

Thumbnail:

- - + +

Description:

-

<%= channel.description %>

+

<%= unescape(channel.description) %>

\ No newline at end of file diff --git a/src/views/partial/channelSettings/permList.ejs b/src/views/partial/channelSettings/permList.ejs index 80ca3e4..0c9cb20 100644 --- a/src/views/partial/channelSettings/permList.ejs +++ b/src/views/partial/channelSettings/permList.ejs @@ -20,10 +20,11 @@ along with this program. If not, see . %> <% Object.keys(channel.permissions.toObject()).forEach((key)=>{ %> <% if(key != "channelOverrides"){ %> - - <%rankEnum.slice().reverse().forEach((rank)=>{ %> - + <% }); %> diff --git a/src/views/partial/channelSettings/settings.ejs b/src/views/partial/channelSettings/settings.ejs index 9ddc294..1ec464c 100644 --- a/src/views/partial/channelSettings/settings.ejs +++ b/src/views/partial/channelSettings/settings.ejs @@ -19,13 +19,13 @@ along with this program. If not, see . %> <% Object.keys(channel.settings).forEach((key) => { %> - + <% switch(typeof channel.settings[key]){ case "string": %> - + <% break; default: %> - checked <% } %>> + checked <% } %>> <% break; } %> diff --git a/src/views/partial/panels/profile.ejs b/src/views/partial/panels/profile.ejs index b5728c6..63fc487 100644 --- a/src/views/partial/panels/profile.ejs +++ b/src/views/partial/panels/profile.ejs @@ -18,16 +18,22 @@ along with this program. If not, see . %> <% if(profile == null){ %>

Profile not found!

<% }else{ %> - View Full Profile -

<%- profile.user %>

- <%- include('../profile/status', {profile, presence, auxClass:"panel"}); %> - -

Toke Count: <%- profile.tokeCount %>

- <% if(profile.pronouns != '' && profile.pronouns != null){ %> -

Pronouns: <%- profile.pronouns %>

- <% } %> -

Signature: <%- profile.signature %>

-

Bio:

-

<%- profile.bio %>

+ <% const splitBio = profile.bio.split('\n'); %> + View Full Profile +

<%= unescape(profile.user) %>

+ <%- include('../profile/status', {profile, presence, auxClass:"panel", unescape}); %> + +
+

Toke Count: <%= profile.tokeCount %>

+ <% if(profile.pronouns != '' && profile.pronouns != null){ %> +

Pronouns: <%= unescape(profile.pronouns) %>

+ <% } %> +

Signature: <%= unescape(profile.signature) %>

+

+ <% for(const line of splitBio){ %> + <%= unescape(line) %>
+ <% } %> +

+
<% } %>
\ No newline at end of file diff --git a/src/views/partial/profile/bio.ejs b/src/views/partial/profile/bio.ejs index 0cc620b..3d28720 100644 --- a/src/views/partial/profile/bio.ejs +++ b/src/views/partial/profile/bio.ejs @@ -15,11 +15,23 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see . %>

Bio:

+ <% + //Split bio by newline + const splitBio = profile.bio.split('\n'); + %> <% if(selfProfile){ %> <%# Make sure to convert newlines to br so they display proepr %> -

<%- profile.bio.replaceAll('\n','
') %>

+

+ <% for(const line of splitBio){ %> + <%= unescape(line) %>
+ <% } %> +

<% }else{ %> -

<%- profile.bio.replaceAll('\n','
') %>

+

+ <% for(const line of splitBio){ %> + <%= unescape(line) %>
+ <% } %> +

<% } %>
\ No newline at end of file diff --git a/src/views/partial/profile/date.ejs b/src/views/partial/profile/date.ejs index 27b21aa..55c175b 100644 --- a/src/views/partial/profile/date.ejs +++ b/src/views/partial/profile/date.ejs @@ -14,5 +14,5 @@ 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 . %> -

Joined: <%- profile.date.toLocaleDateString(); %>

+

Joined: <%= profile.date.toLocaleDateString(); %>

\ No newline at end of file diff --git a/src/views/partial/profile/image.ejs b/src/views/partial/profile/image.ejs index 7665509..30f4afe 100644 --- a/src/views/partial/profile/image.ejs +++ b/src/views/partial/profile/image.ejs @@ -14,7 +14,7 @@ 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 . %>
- + <% if(selfProfile){ %> <% } %> diff --git a/src/views/partial/profile/pronouns.ejs b/src/views/partial/profile/pronouns.ejs index 3d427f2..cd35da2 100644 --- a/src/views/partial/profile/pronouns.ejs +++ b/src/views/partial/profile/pronouns.ejs @@ -24,10 +24,10 @@ along with this program. If not, see . %> <% }else if(profile.pronouns != null && profile.pronouns != ""){ %> <% if(selfProfile){ %> -

Pronouns: <%- profile.pronouns %>

+

Pronouns: <%= unescape(profile.pronouns) %>

<% }else{ %> -

Pronouns: <%- profile.pronouns %>

+

Pronouns: <%= unescape(profile.pronouns) %>

<% } %>
<% } %> \ No newline at end of file diff --git a/src/views/partial/profile/settings.ejs b/src/views/partial/profile/settings.ejs index 84a0ac8..3135653 100644 --- a/src/views/partial/profile/settings.ejs +++ b/src/views/partial/profile/settings.ejs @@ -17,7 +17,7 @@ along with this program. If not, see . %> <% if(profile.email){ %> - + <% } %>