Compare commits

..

No commits in common. "main" and "0.1-indev" have entirely different histories.

202 changed files with 1849 additions and 19767 deletions

10
.gitignore vendored
View file

@ -1,12 +1,4 @@
node_modules/
log/*
www/doc/*/*
package-lock.json
config.json
config.json.old
state.json
chatexamples.txt
server.cert
server.key
www/nonfree/*
migration/*
state.json

View file

@ -1,4 +1,4 @@
All Original Canopy Code is Covered under the Gnu Affero General Public License v3 - 2024 - 2025 Rainbownapkin
All Original Canopy Code is Covered under the Gnu Affero General Public License v3 - 2024 Rainbownapkin
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007

View file

@ -1,44 +1,29 @@
Canopy
======
<img src="https://img.shields.io/badge/developed-while%20high-339933">
<img src="https://pride-badges.pony.workers.dev/static/v1?label=enbyware&labelColor=%23555&stripeWidth=8&stripeColors=FCF434%2CFFFFFF%2C9C59D1%2C2C2C2C">
<img src="https://pride-badges.pony.workers.dev/static/v1?label=trans%20rights&stripeWidth=6&stripeColors=5BCEFA,F5A9B8,FFFFFF,F5A9B8,5BCEFA">
<img src="https://pride-badges.pony.workers.dev/static/v1?label=Sponsored+by+the+Gay+Agenda&labelColor=%23555&stripeWidth=8&stripeColors=E40303%2CFF8C00%2CFFED00%2C008026%2C24408E%2C732982">
<a href="https://git.ourfore.st/rainbownapkin/canopy/issues" target="_blank"><img src="https://git.ourfore.st/rainbownapkin/canopy/badges/issues/open.svg"></a>
<a href="https://git.ourfore.st/rainbownapkin/canopy/issues" target="_blank"><img src="https://git.ourfore.st/rainbownapkin/canopy/badges/issues/closed.svg"></a>
<a href="https://www.gnu.org/licenses/agpl-3.0.en.html" target="_blank"><img src="https://img.shields.io/badge/License-AGPL_v3-663366.svg"></a>
0.1-Alpha
=========
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 argon2, 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.
Canopy - 0.1-INDEV
======
Canopy - /ˈkæ.nə.pi/:
- The upper layer of foliage and branches of a forest, containing the majority of animal life.
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 intends to be a simple node/express.js app. It leverages yt-dlp and the internet archive cli utility 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 session handling and password hashing are handled by industry-standard open source libraries such as express-sessions and bcrypt, however it IS hobbiest software, and it should be treated as such.
The Canopy codebase does not, and never will contain:
- Advertisements (targetted or otherwise)
- Proprietary Code
- 'Analytics/Telemtry' spyware
Thirdparty media providers may or may not contain all of the above atrocities :P, 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.
## 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.

View file

@ -1,25 +1,11 @@
{
"instanceName": "Canopy",
"verbose": false,
"port": 8443,
"proxied": true,
"protocol": "https",
"port": 8080,
"protocol": "http",
"domain": "localhost",
"ytdlpPath": "/home/canopy/.local/pipx/venvs/yt-dlp/bin/yt-dlp",
"migrate": false,
"dropLegacyTokes": false,
"debug": false,
"secrets":{
"passwordSecret": "CHANGE_ME",
"rememberMeSecret": "CHANGE_ME",
"sessionSecret": "CHANGE_ME",
"altchaSecret": "CHANGE_ME",
"ipSecret": "CHANGE_ME"
},
"ssl":{
"cert": "./server.cert",
"key": "./server.key"
},
"sessionSecret": "CHANGE_ME",
"altchaSecret": "CHANGE_ME",
"db":{
"address": "127.0.0.1",
"port": "27017",
@ -33,6 +19,5 @@
"secure": true,
"address": "toke@42069.weed",
"pass": "CHANGE_ME"
},
"aboutText":"<a href=\"https://ourfore.st/\">ourfore.st</a> 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."
}
}

View file

@ -1,70 +0,0 @@
//WARNING DO NOT USE THIS CONFIG, USE THE REGULAR EXAMPLE, CANOPY DOES NOT SUPPORT JSONC CONFIGS!
//This file merely exists to help you configure your actual config file
{
//Display name on the nav bar and a coupla other places
"instanceName": "Canopy",
//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": 8443,
//Lets the server know it's sitting behind a reverse-proxy
"proxied": true,
//Protocol (either HTTP or HTTPS)
"protocol": "http",
//Domain the server is available at, used for server-side link generation
"domain": "localhost",
//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",
//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,
//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,
//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
"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",
"key": "./server.key"
},
//DB configuration
"db":{
"address": "127.0.0.1",
"port": "27017",
"database": "canopy",
"user": "canopy",
"pass": "CHANGE_ME"
},
//maintainence email configuration
"mail":{
"host": "mail.42069.weed",
"port": 465,
"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":"<a href=\"https://ourfore.st/\">ourfore.st</a> 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."
}

View file

@ -252,6 +252,7 @@
"hooray",
"flambe",
"flambé",
"tenturnyourrainsoundsoff",
"jabroni",
"lame",
"yoke",

View file

@ -1,36 +1,25 @@
{
"name": "canopy-of-alpha",
"name": "canopy-of",
"version": "0.1",
"canopyDisplayVersion": "0.1-Alpha",
"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",
"cookie-parser": "^1.4.7",
"csrf-sync": "^4.0.3",
"ejs": "^3.1.10",
"express": "^4.18.2",
"express-session": "^1.18.0",
"express-validator": "^7.2.0",
"hls.js": "^1.6.2",
"mongoose": "^8.4.3",
"node-cron": "^3.0.3",
"nodemailer": "^7.0.9",
"socket.io": "^4.8.1",
"youtube-dl-exec": "^3.0.20"
"nodemailer": "^6.9.16",
"socket.io": "^4.8.1"
},
"scripts": {
"start": "node ./src/server.js",
"start:dev": "nodemon ./src/server.js",
"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": {
"jsdoc": "^4.0.4",
"nodemon": "^3.1.10"
"start:dev": "nodemon ./src/server.js"
}
}

View file

@ -1,88 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
//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, 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 null;
}
if(lite){
result = await socketUtils.authSocketLite(socket);
}else{
result = await socketUtils.authSocket(socket);
}
//If the socket wasn't authorized
if(result == null){
socket.disconnect();
return null;
}
return result;
}catch(err){
//Flip a table if something fucks up
return loggerUtils.socketCriticalExceptionHandler(socket, err);
}
}
defineListeners(socket){
}
}
module.exports = auxServer;

View file

@ -16,66 +16,18 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
//local imports
const connectedUser = require('./connectedUser');
const chatBuffer = require('./chatBuffer');
const queue = require('./media/queue');
const channelModel = require('../../schemas/channel/channelSchema');
const playlistHandler = require('./media/playlistHandler')
const flairModel = require('../../schemas/flairSchema');
const permissionModel = require('../../schemas/permissionSchema');
/**
* Class representing a single active channel
*/
class activeChannel{
/**
* Instantiates an activeChannel object
* @param {channelManager} server - Parent Server Object
* @param {Mongoose.Document} chanDB - chanDB to rehydrate buffer from
*/
module.exports = class{
constructor(server, chanDB){
/**
* Parent Server Object
*/
this.server = server;
/**
* Current Channel Name
*/
this.name = chanDB.name;
/**
* List of channel-wide toke commands
*/
this.tokeCommands = chanDB.tokeCommands;
/**
* List of connected users
*/
//Keeping these in a map was originally a vestige but it's more preformant than an array or object so :P
this.userList = new Map();
/**
* Child Queue Object
*/
this.queue = new queue(server, chanDB, this);
/**
* Child Playlist Handler Object
*/
this.playlistHandler = new playlistHandler(server, this);
/**
* Child Chat Buffer Object
*/
this.chatBuffer = new chatBuffer(server, chanDB, this);
}
/**
* Handles server-side initialization for new connections to the channel
* @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
var userObj = this.userList.get(userDB.user);
@ -100,30 +52,18 @@ class activeChannel{
//if everything looks good, admit the connection to the channel
socket.join(socket.chan);
//Define per-channel event listeners
this.queue.defineListeners(socket);
this.playlistHandler.defineListeners(socket);
//Hand off the connection initiation to it's user object
const activeUser = await userObj.handleConnection(userDB, chanDB, socket)
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;
}
/**
* Handles server-side initialization for disconnecting from the channel
* @param {Socket} socket - Requesting Socket
*/
handleDisconnect(socket){
//temporarily store userObj
let userObj = this.userList.get(socket.user.user);
handleDisconnect(socket, reason){
//If we have more than one active connection
if(userObj.sockets.length > 1){
if(this.userList.get(socket.user.user).sockets.length > 1){
//temporarily store userObj
var userObj = this.userList.get(socket.user.user);
//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;
@ -131,11 +71,7 @@ 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);
}
@ -144,9 +80,6 @@ class activeChannel{
this.broadcastUserList(socket.chan);
}
/**
* Broadcasts user list to all users
*/
broadcastUserList(){
//Create a userlist object with the tokebot user pre-loaded
var userList = [{
@ -166,10 +99,6 @@ class activeChannel{
this.server.io.in(this.name).emit("userList", userList);
}
/**
* Broadcasts channel emote list to connected users
* @param {Mongoose.Document} chanDB - Channnel Document Passthrough to save on DB Access
*/
async broadcastChanEmotes(chanDB){
//if we wherent handed a channel document
if(chanDB == null){
@ -183,6 +112,4 @@ class activeChannel{
//Broadcast that sumbitch
this.server.io.in(this.name).emit('chanEmotes', emoteList);
}
}
module.exports = activeChannel;
}

View file

@ -14,70 +14,35 @@ 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 <https://www.gnu.org/licenses/>.*/
//Config
const config = require('../../../config.json');
//Local Imports
const channelModel = require('../../schemas/channel/channelSchema');
const emoteModel = require('../../schemas/emoteSchema');
const socketUtils = require('../../utils/socketUtils');
const {userModel} = require('../../schemas/user/userSchema');
const userBanModel = require('../../schemas/user/userBanSchema');
const loggerUtils = require('../../utils/loggerUtils');
const presenceUtils = require('../../utils/presenceUtils');
const csrfUtils = require('../../utils/csrfUtils');
const activeChannel = require('./activeChannel');
const chatHandler = require('./chatHandler');
const queueBroadcastManager = require('./media/queueBroadcastManager');
/**
* Class containing global server-side channel connection management logic
*/
class channelManager{
/**
* Instantiates object containing global server-side channel conection management logic
* @param {Socket.io} io - Socket.io server instanced passed down from server.js
*/
module.exports = class{
constructor(io){
/**
* Socket.io server instance passed down from server.js
*/
//Set the socket.io server
this.io = io;
/**
* Map containing all active channels running on the server
*/
//Load
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
*/
//Load server components
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) );
}
/**
* Handles global server-side initialization for new connections to any channel
* @param {Socket} socket - Requesting Socket
*/
async handleConnection(socket){
try{
//ensure unbanned ip and valid CSRF token
if(!(await socketUtils.validateSocket(socket))){
if(!(await this.validateSocket(socket))){
socket.disconnect();
return;
}
@ -85,7 +50,7 @@ class channelManager{
//Prevent logged out connections and authenticate socket
if(socket.request.session.user != null){
//Authenticate socket
const userDB = await socketUtils.authSocket(socket);
const userDB = await this.authSocket(socket);
//Get the active channel based on the socket
var {activeChan, chanDB} = await this.getActiveChan(socket);
@ -103,48 +68,13 @@ class channelManager{
return;
}
//Connection accepted past this point
//Define listeners for inter-channel classes
//Define listeners
this.defineListeners(socket);
this.chatHandler.defineListeners(socket);
//Hand off the connection to it's given active channel object
//Connect the socket to it's given channel
//Lil' hacky to pass chanDB like that, but why double up on DB calls?
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);
}
}
activeChan.handleConnection(userDB, chanDB, socket);
}else{
//Toss out anon's
socket.emit("kick", {type: "disconnected", reason: "You must log-in to join this channel!"});
@ -155,20 +85,54 @@ class channelManager{
//Flip a table if something fucks up
return loggerUtils.socketCriticalExceptionHandler(socket, err);
}
}
}
async validateSocket(socket){
//Look for ban by IP
const ipBanDB = await userBanModel.checkBanByIP(socket.handshake.address);
//If this ip is randy bobandy
if(ipBanDB != null){
//tell it to fuck off
socket.emit("kick", {type: "kicked", reason: "The IP address you are trying to connect from has been banned!"});
return false;
}
//Check for Cross-Site Request Forgery
if(!csrfUtils.isRequestValid(socket.request)){
socket.emit("kick", {type: "disconnected", reason: "Invalid CSRF Token!"});
return false;
}
return true;
}
async authSocket(socket){
//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 new Error("User not found!");
}
//Set socket user and channel values
socket.user = {
id: userDB.id,
user: userDB.user,
};
return userDB;
}
/**
* Gets active channel from a given socket
* @param {Socket} socket - Socket to check
* @returns {Object} Object containing users active channel name and channel document object
*/
async getActiveChan(socket){
socket.chan = socketUtils.getChannelName(socket);
const chanDB = (await channelModel.findOne({name: socket.chan}));
socket.chan = socket.handshake.headers.referer.split('/c/')[1];
const chanDB = (await channelModel.findOne({name: socket.chan}))
//Check if channel exists
if(chanDB == null){
throw loggerUtils.exceptionSmith("Channel not found", "validation");
throw new Error("Channel not found!");
}
//Check if current channel is active
@ -182,73 +146,24 @@ class channelManager{
//Return whatever the active channel is (new or old)
return {activeChan, chanDB};
//return activeChan;
}
/**
* Define Global Server-Side socket event listeners
* @param {Socket} socket - Socket to check
*/
defineListeners(socket){
//Socket Listeners
socket.conn.on("close", (reason) => {this.handleDisconnect(socket, reason)});
}
/**
* Global server-side logic for handling disconncted sockets
* @param {Socket} socket - Socket to check
* @param {String} reason - Reason for disconnection
*/
handleDisconnect(socket, reason){
var activeChan = this.activeChannels.get(socket.chan);
activeChan.handleDisconnect(socket, reason);
}
/**
* Handles a disconnection event for a single active user within a given channel (when all sockets disconnect)
* @param {connectedUser} userObj - Connected user object to handle disconnection of
*/
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);
//Mark last disconnection as user activity, as they'll no longer be marked as streaming.
presenceUtils.handlePresence(userObj.user);
}
}
/**
* Pulls user information by socket
* @param {Socket} socket - Socket to check
* @return returns related user info
*/
getSocketInfo(socket){
const channel = this.activeChannels.get(socket.chan);
return channel.userList.get(socket.user.user);
}
/**
* Pulls user information by socket
* @param {Socket} socket - Socket to check
* @return returns related user info
*/
getConnectedChannels(socket){
//Create a list to hold connected channels
var chanList = [];
@ -268,52 +183,36 @@ class channelManager{
return chanList;
}
/**
* Iterates through connections by a given username, and runs them through a given callback function/method
* @param {String} user - Username to crawl connections against
* @param {Function} cb - Callback function to run active connections of a given user against
*/
crawlConnections(user, cb){
//Pull connection list from status map
const list = this.activeUsers.get(user);
//For each channel
this.activeChannels.forEach((channel) => {
//Check and see if the user is connected
const foundUser = channel.userList.get(user);
//If we have active connections
if(list != null){
//For each connection
for(let user of list){
//Run the callback against it
cb(user);
//If we found a user and this channel hasn't been added to the list
if(foundUser){
cb(foundUser);
}
}
});
}
/**
* 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){
const connections = this.activeUsers.get(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)});
//return connects
return connections;
}
/**
* Kicks a user from all channels by username
* @param {String} user - Username to kick from the server
* @param {String} reason - Reason for kick
*/
kickConnections(user, reason){
//crawl through connections and kick user
this.crawlConnections(user,(foundUser)=>{foundUser.disconnect(reason)});
}
/**
* Broadcast global emote list
*/
async broadcastSiteEmotes(){
//Get emote list from DB
const emoteList = await emoteModel.getEmotes();
@ -321,6 +220,4 @@ class channelManager{
//Broadcast that sumbitch
this.io.sockets.emit('siteEmotes', emoteList);
}
}
module.exports = channelManager;
}

View file

@ -1,34 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
//local imports
const chatMetadata = require("../chatMetadata");
/**
* Class representing a single chat message
*/
class chat extends chatMetadata{
/**
* Instantiates a chat message object
* @param {connectedUser} user - User who sent the message
*/
constructor(user, flair, highLevel, msg, type, links){
//Call derived constructor
super(user, flair, highLevel, msg, type, links);
}
}
module.exports = chat;

View file

@ -1,198 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
const config = require('../../../config.json');
const channelModel = require('../../schemas/channel/channelSchema');
/**
* Class representing a stored chat buffer
*/
class chatBuffer{
/**
* Instantiates a new chat buffer for a given channel
* @param {channelManager} server - Parent Server Object
* @param {Mongoose.Document} chanDB - chanDB to rehydrate buffer from
* @param {activeChannel} channel - Parent Channel Object
*/
constructor(server, chanDB, channel){
/**
* Parent Server Object
*/
this.server = server;
/**
* Parent CHannel Object
*/
this.channel = channel;
//If we have no chanDB.chatBuffer
if(chanDB == null || chanDB.chatBuffer == null){
/**
* RAM-Based buffer containing array of previous chats
*/
this.buffer = [];
//Otherwise
}else{
/**
* RAM-Based buffer containing array of previous chats
*/
this.buffer = chanDB.chatBuffer;
}
/**
* Inactivity Timer, goes off after x seconds of chat inactivity
*/
this.inactivityTimer = null;
/**
* Inactivity Timer Delay
*/
this.inactivityDelay = 10;
/**
* Goes off after x minutes of solid chatroom activity (no inactivityTimer call in x minutes)
*/
this.busyTimer = null;
/**
* Busy Timer Delay
*/
this.busyDelay = 5;
}
/**
* Adds a given chat to the chat buffer in RAM and sets any appropriate timers for DB transactions
* @param {chat} chat - Chat object to commit to buffer
*/
push(chat){
//push chat into RAM buffer
this.buffer.push(chat);
//clear existing inactivity timer
clearTimeout(this.inactivityTimer);
//reset inactivity timer
this.inactivityTimer = setTimeout(this.handleInactivity.bind(this), 1000 * this.inactivityDelay);
//If busy timer is unset
if(this.busyTimer == null){
this.busyTimer = setTimeout(this.handleBusyRoom.bind(this), 1000 * 60 * this.busyDelay);
}
}
/**
* Removes the oldest item from the chat buffer
*
* Was originally created in-case we needed to trigger timing functions
*
* Left here since it seems like good form anywho, since this would be a private, or at least protected member in another language
*/
shift(){
this.buffer.shift();
}
/**
* Called after 10 seconds of chat room inactivity
*/
handleInactivity(){
this.saveDB(`${this.inactivityDelay} seconds of inactivity.`);
}
/**
* Called after 5 minutes of solid activity
*/
handleBusyRoom(){
this.saveDB(`${this.busyDelay} minutes of activity.`);
}
/**
* Clears out buffer timers to prevent saving
*/
clearTimers(){
//clear existing timers
clearTimeout(this.inactivityTimer);
clearTimeout(this.busyTimer);
this.inactivityTimer = null;
this.busyTimer = null;
}
/**
* Clears RAM-Based chat buffer and saves the result to DB
* @param {String} name - Name of user to clear chats from. Left as null or an empty string, it will clear the entire buffer.
*/
async clearBuffer(name){
//Clear out DB Timers
this.clearTimers();
let reason = "clearing chat";
//If we have a null or empty string passed as name
if(name == null || name == ""){
//Nuke that fcker
this.buffer = [];
//Otherwise
}else{
reason = `clearing ${name}'s chats`
//Iterate through chat buffer by index
for(let chatIndex in this.buffer){
//If the current chat we're looking at was submitted by the given user
if(this.buffer[chatIndex].user.toLowerCase() == name.toLowerCase()){
//Splice that fcker out
this.buffer.splice(chatIndex, 1);
}
}
}
await this.saveDB(reason);
}
/**
* Saves RAM-Based buffer to Channel Document in DB
* @param {String} reason - Reason for DB save, formatted as 'x minutes/seconds of in/activity', used for logging purposes
* @param {Mongoose.Document} chanDB - Channel Doc to work with, can be left empty for method to auto-find through channel name.
*/
async saveDB(reason, chanDB){
//Clear out DB Timers
this.clearTimers();
//if the server is in screamy boi mode
if(config.verbose){
//This should eventually be replaced by a per-channel logging feature that provides access to chan admins via web front-end
console.log(`Saving chat buffer to channel ${this.channel.name} after ${reason}.`);
}
//If we wheren't handed a channel
if(chanDB == null){
//Now that everything is clean, we can take our time with the DB :P
chanDB = await channelModel.findOne({name:this.channel.name});
}
//If we couldn't find the channel
if(chanDB == null){
//FUCK
throw loggerUtils.exceptionSmith(`Unable to find channel document ${this.channel.name} while saving chat buffer!`, "chat");
}
//Set chan doc buffer to RAM buffer
chanDB.chatBuffer = this.buffer;
//save chan doc to DB.
await chanDB.save();
}
}
module.exports = chatBuffer;

View file

@ -14,51 +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 <https://www.gnu.org/licenses/>.*/
//NPM imports
const validator = require('validator')
//local imports
const chatPreprocessor = require('../chatPreprocessor');
const commandProcessor = require('./commandProcessor');
const tokebot = require('./tokebot');
const commandPreprocessor = require('./commandPreprocessor');
const loggerUtils = require('../../utils/loggerUtils');
const linkUtils = require('../../utils/linkUtils');
const emoteValidator = require('../../validators/emoteValidator');
const chat = require('./chat');
const {userModel} = require('../../schemas/user/userSchema');
/**
* Class containing global server-side chat relay logic
*/
class chatHandler{
/**
* Instantiates a chatHandler object
* @param {channelManager} server - Parent Server Object
*/
module.exports = class{
constructor(server){
/**
* Parent Server Object
*/
this.server = server;
/**
* Child Command Pre-Processor Object
*/
this.chatPreprocessor = new chatPreprocessor(
new commandProcessor(server, this),
new tokebot(server, this)
);
/**
* Max chat buffer message count
*/
this.chatBufferSize = 50;
this.commandPreprocessor = new commandPreprocessor(server, this)
}
/**
* Defines global server-side chat relay event listeners
* @param {Socket} socket - Requesting Socket
*/
defineListeners(socket){
socket.on("chatMessage", (data) => {this.handleChat(socket, data)});
socket.on("setFlair", (data) => {this.setFlair(socket, data)});
@ -67,33 +35,10 @@ class chatHandler{
socket.on("deletePersonalEmote", (data) => {this.deletePersonalEmote(socket, data)});
}
/**
* Handles incoming chat messages from client connections
* @param {Socket} socket - Socket we're receiving the request from
* @param {Object} data - Event payload
*/
async handleChat(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 something fucked up
}catch(err){
//Bitch and moan
return loggerUtils.socketExceptionHandler(socket, err);
}
handleChat(socket, data){
this.commandPreprocessor.preprocess(socket, data);
}
/**
* Handles incoming client request to change flair
* @param {Socket} socket - Socket we're receiving the request from
* @param {Object} data - Event payload
*/
async setFlair(socket, data){
var userDB = await userModel.findOne({user: socket.user.user});
@ -102,9 +47,11 @@ class chatHandler{
//We can take this data raw since our schema checks it against existing flairs, and mongoose sanatizes queries
const flairDB = await userDB.setFlair(data.flair);
//Crawl through users active connections
this.server.crawlConnections(socket.user.user, (conn)=>{
//Update flair
//GetConnects across all channels
const connections = this.server.getConnections(socket.user.user);
//For each connection
connections.forEach((conn) => {
conn.updateFlair(flairDB.name);
});
}catch(err){
@ -113,18 +60,13 @@ class chatHandler{
}
}
/**
* Handles incoming client request to change high level
* @param {Socket} socket - Socket we're receiving the request from
* @param {Object} data - Event payload
*/
async setHighLevel(socket, data){
var userDB = await userModel.findOne({user: socket.user.user});
if(userDB){
try{
//Floor input to an integer and set high level
userDB.highLevel = Math.floor(data.highLevel);
//Set high level
userDB.highLevel = data.highLevel;
//Save user DB Document
await userDB.save();
@ -141,11 +83,6 @@ class chatHandler{
}
}
/**
* Handles incoming client request to add a personal emote
* @param {Socket} socket - Socket we're receiving the request from
* @param {Object} data - Event payload
*/
async addPersonalEmote(socket, data){
//Sanatize and Validate input
const name = emoteValidator.manualName(data.name);
@ -176,11 +113,6 @@ class chatHandler{
}
}
/**
* Handles incoming client request to delete a personal emote
* @param {Socket} socket - Socket we're receiving the request from
* @param {Object} data - Event payload
*/
async deletePersonalEmote(socket, data){
//Get user doc from DB based on socket
const userDB = await userModel.findOne({user: socket.user.user});
@ -191,143 +123,39 @@ class chatHandler{
}
}
//Base chat functions
/**
* Creates a new chatObject and relays the resulting message to the given channel
* @param {String} user - Originating user
* @param {String} flair - Flair ID to mark chat with
* @param {Number} highLevel - High Level to mark chat with
* @param {String} msg - Message Text Content
* @param {String} type - Message Type, used for client-side chat post-processing.
* @param {String} chan - Channel to broadcast message within
* @param {Array} links - Array of URLs/Links to hand to the client-side chat post-processor to inject into the final message.
*/
relayChat(user, flair, highLevel, msg, type = 'chat', chan, links){
this.relayChatObject(chan, new chat(user, flair, highLevel, msg, type, links));
}
/**
* Relays an existing chat object to a channel
* @param {String} chan - Channel to broadcast message within
* @param {chat} chat - Chat Object representing the message to broadcast to the given channel
*/
relayChatObject(chan, chat){
//Send out chat
this.server.io.in(chan).emit("chatMessage", chat);
const channel = this.server.activeChannels.get(chan);
//If chat buffer length is over mandated size
if(channel.chatBuffer.buffer.length >= this.chatBufferSize){
//Take out oldest chat
channel.chatBuffer.shift();
}
//Add buffer to chat
channel.chatBuffer.push(chat);
}
/**
* Creates a new chatObject and relays the resulting message to the given socket
* @param {Socket} socket - Socket we're sending a message to (sounds menacing, huh?)
* @param {String} user - Originating user
* @param {String} flair - Flair ID to mark chat with
* @param {Number} highLevel - High Level to mark chat with
* @param {String} msg - Message Text Content
* @param {String} type - Message Type, used for client-side chat post-processing.
* @param {String} chan - Channel to broadcast message within
* @param {Array} links - Array of URLs/Links to hand to the client-side chat post-processor to inject into the final message.
*/
relayPrivateChat(socket, user, flair, highLevel, msg, type, links){
this.relayPrivateChatObject(socket , new chat(user, flair, highLevel, msg, type, links));
}
/**
* Handles incoming client request to delete a personal emote
* @param {Socket} socket - Socket we're receiving the request from
* @param {Object} data - Event payload
*/
relayPrivateChatObject(socket, chat){
socket.emit("chatMessage", chat);
}
/**
* Creates a new chatObject and relays the resulting message to the entire server
* @param {String} user - Originating user
* @param {String} flair - Flair ID to mark chat with
* @param {Number} highLevel - High Level to mark chat with
* @param {String} msg - Message Text Content
* @param {String} type - Message Type, used for client-side chat post-processing.
* @param {Array} links - Array of URLs/Links to hand to the client-side chat post-processor to inject into the final message.
*/
relayGlobalChat(user, flair, highLevel, msg, type = 'chat', links){
this.relayGlobalChatObject(new chat(user, flair, highLevel, msg, type, links));
}
/**
* Relays an existing chat object to the entire server
* @param {chat} chat - Chat Object representing the message to broadcast throughout the server
*/
relayGlobalChatObject(chat){
this.server.io.emit("chatMessage", chat);
}
//User Chat Functions
/**
* Relays a chat message from a user to the rest of the channel based on socket
* @param {Socket} socket - Socket we're receiving the request from
* @param {String} msg - Message Text Content
* @param {String} type - Message Type, used for client-side chat post-processing.
* @param {Array} links - Array of URLs/Links to hand to the client-side chat post-processor to inject into the final message.
*/
relayUserChat(socket, msg, type, links){
const user = this.server.getSocketInfo(socket);
this.relayChat(user.user, user.flair, user.highLevel, msg, type, socket.chan, links);
this.relayChat(user.user, user.flair, user.highLevel, msg, type, socket.chan, links)
}
relayChat(user, flair, highLevel, msg, type = 'chat', chan, links){
this.server.io.in(chan).emit("chatMessage", {user, flair, highLevel, msg, type, links});
}
relayServerWisper(socket, user, flair, highLevel, msg, type, links){
socket.emit("chatMessage", {user, flair, highLevel, msg, type, links});
}
relayGlobalChat(user, flair, highLevel, msg, type = 'chat', links){
this.server.io.emit("chatMessage", {user, flair, highLevel, msg, type, links});
}
//Toke Chat Functions
/**
* Broadcasts toke callout to the server
* @param {String} msg - Message Text Content
* @param {Array} links - Array of URLs/Links to hand to the client-side chat post-processor to inject into the final message.
*/
relayTokeCallout(msg, links){
this.relayGlobalChat("Tokebot", "", '∞', msg, "toke", links);
}
/**
* Broadcasts toke callout to the server
* @param {Socket} socket - Socket we're sending the whisper to
* @param {String} msg - Message Text Content
* @param {Array} links - Array of URLs/Links to hand to the client-side chat post-processor to inject into the final message.
*/
relayTokeWhisper(socket, msg, links){
this.relayPrivateChat(socket, "Tokebot", "", '∞', msg, "tokewhisper", links);
this.relayServerWisper(socket, "Tokebot", "", '∞', msg, "tokewhisper", links);
}
/**
* Broadcasts toke whisper to the server
* @param {String} msg - Message Text Content
* @param {Array} links - Array of URLs/Links to hand to the client-side chat post-processor to inject into the final message.
*/
relayGlobalTokeWhisper(msg, links){
this.relayGlobalChat("Tokebot", "", '∞', msg, "tokewhisper", links);
}
//Announcement Functions
/**
* Broadcasts announcement to the server
* @param {String} msg - Message Text Content
* @param {Array} links - Array of URLs/Links to hand to the client-side chat post-processor to inject into the final message.
*/
relayServerAnnouncement(msg, links){
this.relayGlobalChat("Server", "", '∞', msg, "announcement", links);
}
/**
* Broadcasts announcement to a given channel
* @param {String} msg - Message Text Content
* @param {Array} links - Array of URLs/Links to hand to the client-side chat post-processor to inject into the final message.
*/
relayChannelAnnouncement(chan, msg, links){
const activeChan = this.server.activeChannels.get(chan);
@ -337,12 +165,6 @@ class chatHandler{
}
}
//Misc Functions
/**
* Clears chat for a given channel, targets specified user or entire channel if none found/specified.
* @param {String} user - User chats to clear
* @param {String} chan - Channel to broadcast message within
*/
clearChat(chan, user){
const activeChan = this.server.activeChannels.get(chan);
@ -352,14 +174,8 @@ class chatHandler{
//If no user was entered OR the user was found
if(user == null || target != null){
//Send command out to browsers to drop chats from buffer
this.server.io.in(chan).emit("clearChat", {user});
//Clear serverside buffer, down to the DB
activeChan.chatBuffer.clearBuffer(user);
}
}
}
}
module.exports = chatHandler;
}

View file

@ -0,0 +1,268 @@
/*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 <https://www.gnu.org/licenses/>.*/
//NPM Imports
const validator = require('validator');//No express here, so regular validator it is!
//Local Imports
const tokebot = require('./tokebot');
const linkUtils = require('../../utils/linkUtils');
const permissionModel = require('../../schemas/permissionSchema');
const channelModel = require('../../schemas/channel/channelSchema');
module.exports = class commandPreprocessor{
constructor(server, chatHandler){
this.server = server;
this.chatHandler = chatHandler;
this.commandProcessor = new commandProcessor(server, chatHandler);
this.tokebot = new tokebot(server, chatHandler);
}
async preprocess(socket, data){
//Set socket, data, and sendFlag
this.socket = socket;
this.sendFlag = true;
this.rawData = data;
this.chatType = 'chat';
//If we don't pass sanatization/validation turn this car around
if(!this.sanatizeCommand()){
return;
}
//split the command
this.splitCommand();
//Process the command
await this.processServerCommand();
//If we're going to relay this command as a message, continue on to chat processing
if(this.sendFlag){
//Prep the message
await this.prepMessage();
//Send the chat
this.sendChat();
}
}
sanatizeCommand(){
//Trim and Sanatize for XSS
this.command = validator.trim(validator.escape(this.rawData.msg));
//nuke the message if its empty or huge
return (validator.isLength(this.command, {min: 1, max: 255}));
}
splitCommand(){
//Split string by words
this.commandArray = this.command.split(/\b/g);//Split by word-borders
this.argumentArray = this.command.match(/\b\w+\b/g);//Match by words surrounded by borders
}
async processServerCommand(){
//If the raw message starts with '!' (skip commands that start with whitespace so people can send example commands in chat)
if(this.rawData.msg[0] == '!'){
//if it isn't just an exclimation point, and we have a real command
if(this.argumentArray != null){
if(this.commandProcessor[this.argumentArray[0].toLowerCase()] != null){
//Process the command and use the return value to set the sendflag (true if command valid)
this.sendFlag = await this.commandProcessor[this.argumentArray[0].toLowerCase()](this);
}else{
//Process as toke command if we didnt get a match from the standard server-side command processor
this.sendFlag = await this.tokebot.tokeProcessor(this);
}
}
}
}
async markLinks(){
//Empty out the links array
this.links = [];
//For each link sent from the client
//this.rawData.links.forEach((link) => {
for (const link of this.rawData.links){
//Add a marked link object to our links array
this.links.push(await linkUtils.markLink(link));
}
}
async prepMessage(){
//Create message from commandArray
this.message = this.commandArray.join('').trimStart();
//Validate links and mark them by embed type
await this.markLinks();
}
sendChat(){
//FUCKIN' SEND IT!
this.chatHandler.relayUserChat(this.socket, this.message, this.chatType, this.links);
}
}
class commandProcessor{
constructor(server, chatHandler){
this.server = server;
this.chatHandler = chatHandler;
}
//Command keywords get run through .toLowerCase(), so we should use lowercase method names for command methods
whisper(preprocessor){
//splice out our command
preprocessor.commandArray.splice(0,2);
//Mark out the current message as a whisper
preprocessor.chatType = 'whisper';
//Make sure to throw the send flag
return true
}
async announce(preprocessor){
//Get the current channel from the database
const chanDB = await channelModel.findOne({name: preprocessor.socket.chan});
//Check if the user has permission, and publicly shame them if they don't (lmao)
if(chanDB != null && await chanDB.permCheck(preprocessor.socket.user, 'announce')){
//splice out our command
preprocessor.commandArray.splice(0,2);
//Prep the message using pre-processor functions chat-handling
await preprocessor.prepMessage();
//send it
this.chatHandler.relayChannelAnnouncement(preprocessor.socket.chan, preprocessor.message, preprocessor.links);
//throw send flag
return false;
}
//throw send flag
return true;
}
async serverannounce(preprocessor){
//Check if the user has permission, and publicly shame them if they don't (lmao)
if(await permissionModel.permCheck(preprocessor.socket.user, 'announce')){
//splice out our command
preprocessor.commandArray.splice(0,2);
//Prep the message using pre-processor functions for chat-handling
await preprocessor.prepMessage();
//send it
this.chatHandler.relayServerAnnouncement(preprocessor.message, preprocessor.links);
//throw send flag
return false;
}
//throw send flag
return true;
}
async clear(preprocessor){
//Get the current channel from the database
const chanDB = await channelModel.findOne({name: preprocessor.socket.chan});
//Check if the user has permission, and publicly shame them if they don't (lmao)
if(await chanDB.permCheck(preprocessor.socket.user, 'clearChat')){
//Send off the command
this.chatHandler.clearChat(preprocessor.socket.chan, preprocessor.argumentArray[1]);
//throw send flag
return false;
}
//throw send flag
return true;
}
async kick(preprocessor){
//Get the current channel from the database
const chanDB = await channelModel.findOne({name: preprocessor.socket.chan});
//Check if the user has permission, and publicly shame them if they don't (lmao)
if(await chanDB.permCheck(preprocessor.socket.user, 'kickUser')){
//Get username from argument array
const username = preprocessor.argumentArray[1];
//Get channel
const channel = this.server.activeChannels.get(preprocessor.socket.chan);
//get initiator and target user objects
const initiator = channel.userList.get(preprocessor.socket.user.user);
const target = channel.userList.get(username);
//get initiator and target override abilities
const override = await permissionModel.overrideCheck(preprocessor.socket.user, 'kickUser');
const targetOverride = await permissionModel.overrideCheck(target, 'kickUser');
//If there is no user
if(target == null){
//silently drop the command
return false;
}
//If the user is capable of overriding this permission based on site permissions
if(override || targetOverride){
//If the site rank is equal
if(permissionModel.rankToNum(initiator.rank) == permissionModel.rankToNum(target.rank)){
//compare chan rank
if(permissionModel.rankToNum(initiator.chanRank) <= permissionModel.rankToNum(target.chanRank)){
//shame the person running it
return true;
}
//otherwise
}else{
//compare site rank
if(permissionModel.rankToNum(initiator.rank) <= permissionModel.rankToNum(target.rank)){
//shame the person running it
return true;
}
}
}else{
//If the target has a higher chan rank than the initiator
if(permissionModel.rankToNum(initiator.chanRank) <= permissionModel.rankToNum(target.chanRank)){
//shame the person running it
return true;
}
}
//Splice out kick
preprocessor.commandArray.splice(0,4)
//Get collect reason
var reason = preprocessor.commandArray.join('');
//If no reason was given
if(reason == ''){
//Fill in a generic reason
reason = "You have been kicked from the channel!";
}
//Kick the user
target.disconnect(reason);
//throw send flag
return false;
}
//throw send flag
return true;
}
}

View file

@ -1,294 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
//Local Imports
const permissionModel = require('../../schemas/permissionSchema');
const channelModel = require('../../schemas/channel/channelSchema');
/**
* Class representing global server-side chat/command processing logic
*/
class commandProcessor{
/**
* Instantiates a commandProcessor object
* @param {channelManager} server - Parent Server Object
* @param {chatHandler} chatHandler - Parent Chat Handler Object
*/
constructor(server, chatHandler){
this.server = server;
this.chatHandler = chatHandler;
}
//Command keywords get run through .toLowerCase(), so we should use lowercase method names for command methods
/**
* Command Processor method to handle the '!whisper' command
* @param {Object} commandObj - Object representing a single given command/chat request
* @returns {Boolean} True to enable send flag
*/
whisper(commandObj){
//splice out our command
commandObj.commandArray.splice(0,2);
//Mark out the current message as a whisper
commandObj.chatType = 'whisper';
//Make sure to throw the send flag
return true
}
/**
* Command Processor method to handle the '!spoiler' command
* @param {Object} commandObj - Object representing a single given command/chat request
* @returns {Boolean} True to enable send flag
*/
spoiler(commandObj){
//splice out our command
commandObj.commandArray.splice(0,2);
//Mark out the current message as a spoiler
commandObj.chatType = 'spoiler';
//Make sure to throw the send flag
return true
}
/**
* Command Processor method to handle the '!strikethrough' command
* @param {Object} commandObj - Object representing a single given command/chat request
* @returns {Boolean} True to enable send flag
*/
strikethrough(commandObj){
//splice out our command
commandObj.commandArray.splice(0,2);
//Mark out the current message as a spoiler
commandObj.chatType = 'strikethrough';
//Make sure to throw the send flag
return true
}
/**
* Command Processor method to handle the '!bold' command
* @param {Object} commandObj - Object representing a single given command/chat request
* @returns {Boolean} True to enable send flag
*/
bold(commandObj){
//splice out our command
commandObj.commandArray.splice(0,2);
//Mark out the current message as a spoiler
commandObj.chatType = 'bold';
//Make sure to throw the send flag
return true
}
/**
* Command Processor method to handle the '!italics' command
* @param {Object} commandObj - Object representing a single given command/chat request
* @returns {Boolean} True to enable send flag
*/
italics(commandObj){
//splice out our command
commandObj.commandArray.splice(0,2);
//Mark out the current message as a spoiler
commandObj.chatType = 'italics';
//Make sure to throw the send flag
return true
}
/**
* Command Processor method to handle the '!announce' command
* @param {Object} commandObj - Object representing a single given command/chat request
* @returns {Boolean} True to enable send flag on un-authorized call to shame the user
*/
async announce(commandObj, preprocessor){
//Get the current channel from the database
const chanDB = await channelModel.findOne({name: commandObj.socket.chan});
//Check if the user has permission, and publicly shame them if they don't (lmao)
if(chanDB != null && await chanDB.permCheck(commandObj.socket.user, 'announce')){
//splice out our command
commandObj.commandArray.splice(0,2);
//Prep the message using pre-processor functions chat-handling
await preprocessor.prepMessage(commandObj);
//send it
this.chatHandler.relayChannelAnnouncement(commandObj.socket.chan, commandObj.message, commandObj.links);
//throw send flag
return false;
}
//throw send flag
return true;
}
/**
* Command Processor method to handle the '!serverannounce' command
* @param {Object} commandObj - Object representing a single given command/chat request
* @returns {Boolean} True to enable send flag on un-authorized call to shame the user
*/
async serverannounce(commandObj, preprocessor){
//Check if the user has permission, and publicly shame them if they don't (lmao)
if(await permissionModel.permCheck(commandObj.socket.user, 'announce')){
//splice out our command
commandObj.commandArray.splice(0,2);
//Prep the message using pre-processor functions for chat-handling
await preprocessor.prepMessage(commandObj);
//send it
this.chatHandler.relayServerAnnouncement(commandObj.message, commandObj.links);
//disble send flag
return false;
}
//throw send flag
return true;
}
/**
* Command Processor method to handle the '!resettoke' command
* @param {Object} commandObj - Object representing a single given command/chat request
* @returns {Boolean} True to enable send flag on un-authorized call to shame the user
*/
async resettoke(commandObj, preprocessor){
//Check if the user has permission, and publicly shame them if they don't (lmao)
if(await permissionModel.permCheck(commandObj.socket.user, 'resetToke')){
//Acknowledge command
this.chatHandler.relayTokeWhisper(commandObj.socket, 'Toke cooldown reset.');
//Tell tokebot to reset the toke
preprocessor.tokebot.resetToke();
//disable send flag
return false;
}
//throw send flag
return true;
}
/**
* Command Processor method to handle the '!clear' command
* @param {Object} commandObj - Object representing a single given command/chat request
* @returns {Boolean} True to enable send flag on un-authorized call to shame the user
*/
async clear(commandObj){
//Get the current channel from the database
const chanDB = await channelModel.findOne({name: commandObj.socket.chan});
//Check if the user has permission, and publicly shame them if they don't (lmao)
if(await chanDB.permCheck(commandObj.socket.user, 'clearChat')){
//Send off the command
this.chatHandler.clearChat(commandObj.socket.chan, commandObj.argumentArray[1]);
//disable send flag
return false;
}
//throw send flag
return true;
}
/**
* Command Processor method to handle the '!kick' command
* @param {Object} commandObj - Object representing a single given command/chat request
* @returns {Boolean} True to enable send flag on un-authorized call to shame the user
*/
async kick(commandObj){
//Get the current channel from the database
const chanDB = await channelModel.findOne({name: commandObj.socket.chan});
//Check if the user has permission, and publicly shame them if they don't (lmao)
if(await chanDB.permCheck(commandObj.socket.user, 'kickUser')){
//Get username from argument array
const username = commandObj.argumentArray[1];
//Get channel
const channel = this.server.activeChannels.get(commandObj.socket.chan);
//get initiator and target user objects
const initiator = channel.userList.get(commandObj.socket.user.user);
const target = channel.userList.get(username);
//get initiator and target override abilities
const override = await permissionModel.overrideCheck(commandObj.socket.user, 'kickUser');
const targetOverride = await permissionModel.overrideCheck(target, 'kickUser');
//If there is no user
if(target == null){
//silently drop the command
return false;
}
//If the user is capable of overriding this permission based on site permissions
if(override || targetOverride){
//If the site rank is equal
if(permissionModel.rankToNum(initiator.rank) == permissionModel.rankToNum(target.rank)){
//compare chan rank
if(permissionModel.rankToNum(initiator.chanRank) <= permissionModel.rankToNum(target.chanRank)){
//shame the person running it
return true;
}
//otherwise
}else{
//compare site rank
if(permissionModel.rankToNum(initiator.rank) <= permissionModel.rankToNum(target.rank)){
//shame the person running it
return true;
}
}
}else{
//If the target has a higher chan rank than the initiator
if(permissionModel.rankToNum(initiator.chanRank) <= permissionModel.rankToNum(target.chanRank)){
//shame the person running it
return true;
}
}
//Splice out kick
commandObj.commandArray.splice(0,4)
//Get collect reason
var reason = commandObj.commandArray.join('');
//If no reason was given
if(reason == ''){
//Fill in a generic reason
reason = "You have been kicked from the channel!";
}
//Kick the user
target.disconnect(reason);
//throw send flag
return false;
}
//throw send flag
return true;
}
}
module.exports = commandProcessor;

View file

@ -15,117 +15,37 @@ You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.*/
//local imports
const config = require('../../../config.json');
const channelModel = require('../../schemas/channel/channelSchema');
const permissionModel = require('../../schemas/permissionSchema');
const flairModel = require('../../schemas/flairSchema');
const emoteModel = require('../../schemas/emoteSchema');
const { userModel } = require('../../schemas/user/userSchema');
/**
* Class representing a single user connected to a channel
*/
class connectedUser{
/**
* Instantiates a connectedUser object
* @param {Mongoose.Document} userDB - User document to re-hydrate user from
* @param {PemissionModel.chanRank} chanRank - Enum representing user channel rank
* @param {String} - Channel the user is connecting to
* @param {Socket} socket - Socket associated with the users connection
*/
module.exports = class{
constructor(userDB, chanRank, channel, socket){
/**
* User ID Number
*/
this.id = userDB.id;
/**
* User Name
*/
this.user = userDB.user;
/**
* User Rank
*/
this.rank = userDB.rank;
/**
* User High-Level
*/
this.highLevel = userDB.highLevel;
//Check to make sure users flair entry from DB is good
if(userDB.flair != null){
//Set flair from DB
/**
* User Flair
*/
this.flair = userDB.flair.name;
//Otherwise
}else{
//Gracefully default to classic
/**
* User Flair
*/
this.flair = 'classic';
}
/**
* User Channel-Rank
*/
this.flair = userDB.flair.name;
this.chanRank = chanRank;
/**
* Connected Channel
*/
this.channel = channel;
/**
* List of active sockets to current channel
*/
this.sockets = [socket.id];
}
/**
* Handles server-side initialization for new connections from a specific user
* @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
this.sendClientMetadata();
await this.sendClientMetadata();
//Send out emotes
this.sendSiteEmotes();
this.sendChanEmotes(chanDB);
this.sendPersonalEmotes(userDB);
await this.sendSiteEmotes();
await this.sendChanEmotes(chanDB);
await this.sendPersonalEmotes(userDB);
//Send out used tokes
this.sendUsedTokes(userDB);
//Send out the currently playing item
this.channel.queue.sendMedia(socket);
//If we're proxied
if(config.proxied){
//Tattoo hashed IP address from reverse proxy to user account for seven days
await userDB.tattooIPRecord(socket.handshake.headers['x-forwarded-for']);
}else{
//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;
//Tattoo hashed IP address to user account for seven days
await userDB.tattooIPRecord(socket.handshake.address);
}
/**
* Iterates through all known connections for a given user, running them through a supplied callback function
* @param {Function} cb - Callback to call against found sockets for a given user
*/
socketCrawl(cb){
//Crawl through user's sockets (lol)
this.sockets.forEach((sockid) => {
@ -136,70 +56,27 @@ class connectedUser{
});
}
/**
* Emits an event to all known sockets for a given user
*
* My brain keeps going back to using dynamic per-user namespaces for this
* but everytime i look into it I come to the conclusion that it's a bad idea, then I toy with making chans namespaces
* and using per-user channels for this, but what of gold or mod-only features? or games?
* No matter what it'd probably end up hacky, as namespaces where meant for splitting app logic not user comms (like rooms).
* at the end of the day there has to be some penance for decent multi-session handling on-top of a library that doesn't do it.
* Having to crawl through these sockets is that. Because the other ways seem more gross somehow.
* @param {String} eventName - Event name to emit to client sockets
* @param {Object} data - Data to emit to client sockets
*/
emit(eventName, data){
this.socketCrawl((socket)=>{
//Ensure our socket is initialized
if(socket != null){
socket.emit(eventName, data);
}
});
//My brain keeps going back to using dynamic per-user namespaces for this
//but everytime i look into it I come to the conclusion that it's a bad idea, then I toy with making chans namespaces
//and using per-user channels for this, but what of gold or mod-only features? or games?
//No matter what it'd probably end up hacky, as namespaces where meant for splitting app logic not user comms (like rooms).
//at the end of the day there has to be some penance for decent multi-session handling on-top of a library that doesn't do it.
//Having to crawl through these sockets is that. Because the other ways seem more gross somehow.
emit(eventName, args){
this.socketCrawl((socket)=>{socket.emit(eventName, args)});
}
/**
* Disconnects all sockets for a given user
* @param {String} reason - Reason for being disconnected
* @param {String} type - Disconnection Type
*/
//generic disconnect function, defaults to kick
disconnect(reason, type = "Disconnected"){
this.emit("kick",{type, reason});
this.socketCrawl((socket)=>{socket.disconnect()});
}
//This is the big first push upon connection
//It should only fire once, so things that only need to be sent once can be slapped into here
/**
* Sends glut of required initial metadata to the client upon a new connection
* @param {Mongoose.Document} userDB - User Document Passthrough to save on DB Access
* @param {Mongoose.Document} chanDB - Channnel Document Passthrough to save on DB Access
*/
async sendClientMetadata(userDB, chanDB){
async sendClientMetadata(){
//Get flairList from DB and setup flairList array
const flairListDB = await flairModel.find({});
var flairList = [];
//if we wherent handed a user document
if(userDB == null){
//Pull it based on user name
userDB = await userModel.findOne({user: this.user});
}
//if we wherent handed a channel document
if(chanDB == null){
//Pull it based on channel name
chanDB = await channelModel.findOne({name: this.channel.name});
}
//If our perm map is un-initiated
//can't set this in constructor easily since it's asyncornous
//need to wait for it to complete before sending this off, but shouldnt re-do the wait for later connections
if(this.permMap == null){
//Grab perm map
this.permMap = await chanDB.getPermMapByUserDoc(userDB);
}
//Setup our userObj
const userObj = {
id: this.id,
@ -207,11 +84,7 @@ class connectedUser{
rank: this.rank,
chanRank: this.chanRank,
highLevel: this.highLevel,
permMap: {
site: Array.from(this.permMap.site),
chan: Array.from(this.permMap.chan),
},
flair: this.flair,
flair: this.flair
}
//For each flair listed in the Database
@ -226,19 +99,10 @@ class connectedUser{
}
});
//Get schedule lock status
const queueLock = this.channel.queue.locked;
//Get chat buffer
const chatBuffer = this.channel.chatBuffer.buffer;
//Send off the metadata to our user's clients
this.emit("clientMetadata", {user: userObj, flairList, queueLock, chatBuffer});
this.emit("clientMetadata", {user: userObj, flairList});
}
/**
* Send copy of site emotes to the user
*/
async sendSiteEmotes(){
//Get emote list from DB
const emoteList = await emoteModel.getEmotes();
@ -247,10 +111,6 @@ class connectedUser{
this.emit('siteEmotes', emoteList);
}
/**
* Send copy of channel emotes to the user
* @param {Mongoose.Document} chanDB - Channnel Document Passthrough to save on DB Access
*/
async sendChanEmotes(chanDB){
//if we wherent handed a channel document
if(chanDB == null){
@ -265,14 +125,10 @@ class connectedUser{
this.emit('chanEmotes', emoteList);
}
/**
* Send copy of channel emotes to the user
* @param {Mongoose.Document} userDB - User Document Passthrough to save on DB Access
*/
async sendPersonalEmotes(userDB){
//if we wherent handed a user document
//if we wherent handed a channel document
if(userDB == null){
//Pull it based on user name
//Pull it based on channel name
userDB = await userModel.findOne({user: this.user});
}
@ -283,27 +139,6 @@ class connectedUser{
this.emit('personalEmotes', emoteList);
}
/**
* Send copy of channel emotes to the user
* @param {Mongoose.Document} userDB - User Document Passthrough to save on DB Access
*/
async sendUsedTokes(userDB){
//if we wherent handed a user document
if(userDB == null){
//Pull it based on user name
userDB = await userModel.findOne({user: this.user});
}
//Create array of used toks from toke map and send it out to the user
this.emit('usedTokes',{
tokes: Array.from(userDB.tokes.keys())
});
}
/**
* Set flair for a given user and broadcast update to clients
* @param {String} flair - Flair string to update user's flair to
*/
updateFlair(flair){
this.flair = flair;
@ -311,10 +146,6 @@ class connectedUser{
this.sendClientMetadata();
}
/**
* Set high level for a given user and broadcast update to clients
* @param {Number} highLevel - Number to update user's high-level to
*/
updateHighLevel(highLevel){
this.highLevel = highLevel;
@ -322,6 +153,4 @@ class connectedUser{
this.channel.broadcastUserList();
this.sendClientMetadata();
}
}
module.exports = connectedUser;
}

View file

@ -1,77 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
/**
* Object representing a piece of media
*/
class media{
/**
* Creates a new media object from scraped information
* @param {String} title - Chosen title of media
* @param {String} fileName - Original filename/title of media provided by source
* @param {String} url - Original URL to file
* @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 - 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){
/**
* Chosen title of media
*/
this.title = title;
/**
* Original filename/title of media provided by source
*/
this.fileName = fileName
/**
* Original URL to file
*/
this.url = url;
/**
* Video ID from source (IE: youtube watch code/archive.org file path)
*/
this.id = id;
/**
* Original video source
*/
this.type = type;
/**
* Length of media in seconds
*/
this.duration = duration;
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;
}
}
}
module.exports = media;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,94 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
//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 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}));
//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);
//Send the queue down to our newly connected user
activeChannel.queue.emitQueue(chanDB, socket);
//Define listeners
this.defineListeners(socket);
}
}
defineListeners(socket){
super.defineListeners(socket);
}
}
module.exports = queueBroadcastManager;

View file

@ -1,158 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
//Local Imports
const media = require('./media');
/**
* Class extending media which represents a queued piece of media
* @extends media
*/
class queuedMedia extends media{
/**
* Creates a new queued media object
* @param {Number} startTime - JS Epoch representing start time
* @param {Number} startTimeStamp - Media start time stamp in seconds (relative to duration)
* @param {Number} earlyEnd - Media end timestamp in seconds (relative to duration)
* @param {String} uuid - Media object's unique identifier
*/
constructor(title, fileName, url, id, type, duration, rawLink, startTime, startTimeStamp = 0, earlyEnd, uuid){
//Call derived constructor
super(title, fileName, url, id, type, duration, rawLink);
/**
* JS Epoch (millis) representing start time
*/
this.startTime = startTime;
/**
* Media start time stamp in seconds (relative to duration)
*/
this.startTimeStamp = startTimeStamp;
/**
* Media ent timestamp in seconds (relative to duration)
*/
this.earlyEnd = earlyEnd;
/**
* Media status type
*/
this.status = 'queued';
//If we have a null uuid (can't use default argument because of 'this')
if(uuid == null){
//Generate id unique to this specific entry of this specific file within this specific channel's queue
//That way even if we have six copies of the same video queued, we can still uniquely idenitify each instance
this.genUUID();
}else{
/**
* Media object's unique identifier
*/
this.uuid = uuid;
}
}
//statics
/**
* Creates a queuedMedia object from a media object
* @param {media} media - Media object to queue
* @param {Number} startTime - Start time formatted as a JS Epoch
* @param {Number} startTimeStamp - Start time stamp in seconds
* @returns {queuedMedia} queuedMedia object created from given media object
*/
static fromMedia(media, startTime, startTimeStamp){
//Create and return queuedMedia object from given media object and arguments
return new this(
media.title,
media.fileName,
media.url,
media.id,
media.type,
media.duration,
media.rawLink,
startTime,
startTimeStamp);
}
/**
* Converts array of media objects into array of queuedMedia objects
* @param {Array} mediaList - Array of media objects to queue
* @param {Number} start - Start time formatted as JS Epoch
* @returns Array of converted queued media objects
*/
static fromMediaArray(mediaList, start){
//Queued Media List
const queuedMediaList = [];
//Start Time Offset
let startOffset = 0;
for(let media of mediaList){
//Convert mediaObj to queuedMedia and push to the back of the list
queuedMediaList.push(this.fromMedia(media, start + startOffset, 0));
//Set start offset to end of the current item
startOffset += (media.duration * 1000) + 5;
}
return queuedMediaList;
}
//methods
/**
* Generates new unique identifier for queued media
*/
genUUID(){
this.uuid = crypto.randomUUID();
}
/**
* Generates a unique clone of a given media object
* @returns unique clone of media object
*/
clone(){
return new queuedMedia(
this.title,
this.fileName,
this.url,
this.id,
this.type,
this.duration,
this.rawLink,
this.startTime,
this.startTimeStamp,
this.earlyEnd
);
}
/**
* return the end time of a given queuedMedia object
* @param {boolean} fullTime - Overrides early ends
* @returns end time of given queuedMedia object
*/
getEndTime(fullTime = false){
//If we have an early ending
if(this.earlyEnd == null || fullTime){
//Calculate our ending
return this.startTime + ((this.duration - this.startTimeStamp) * 1000);
}else{
//Return our early end
return this.startTime + (this.earlyEnd * 1000);
}
}
}
module.exports = queuedMedia;

View file

@ -16,102 +16,46 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
//Local Imports
const tokeCommandModel = require('../../schemas/tokebot/tokeCommandSchema');
const tokeModel = require('../../schemas/tokebot/tokeSchema');
const {userModel} = require('../../schemas/user/userSchema');
const statModel = require('../../schemas/statSchema');
const statSchema = require('../../schemas/statSchema');
/**
* Class containing global server-side tokebot logic
*/
class tokebot{
/**
* Instantiates a tokebot object
* @param {channelManager} server - Parent Server Object
* @param {chatHandler} chatHandler - Parent Chat Handler Object
*/
module.exports = class tokebot{
constructor(server, chatHandler){
/**
* Parent Server Object
*/
//Set parents
this.server = server;
/**
* Parent Chat Handler
*/
this.chatHandler = chatHandler;
/**
* Toke Timer
*/
//Set timeouts to null
this.tokeTimer = null;
/**
* Cooldown Timer
*/
this.cooldownTimer = null;
/**
* Toke time
*/
//Set start times
this.tokeTime = 60;
/**
* Cooldown Time
*/
this.cooldownTime = 120;
/**
* Toke Counter
*/
//Create counter variable
this.tokeCounter = 0;
/**
* Cooldown Counter
*/
this.cooldownCounter = 0;
/**
* List of current tokers
*/
//Create tokers list
this.tokers = new Map();
//Load in toke commands from the DB
this.refreshCommands();
}
/**
* Reloads toke commands from DB into RAM-based toke command store
*/
async refreshCommands(){
//Pull Command Strings from DB
this.tokeCommands = await tokeCommandModel.getCommandStrings();
}
/**
* Processes toke commands from Command Pre-Processor
* @param {Object} commandObj - Object representing a single given command/chat request, passed down from the Command Pre-Processor
* @returns {Boolean} True if the toke is an invalid toke command (tells Command Pre-Processor to send command as chat)
*/
tokeProcessor(commandObj){
//Check for site-wide toke commands
if(this.tokeCommands.indexOf(commandObj.argumentArray[0].toLowerCase()) != -1){
//Seems lame to set a bool in an if statement but this would've made a really ugly turinary
var foundToke = true;
}else if(commandObj.argumentArray[0].toLowerCase() == 'r'){
//Find the users active channel
const activeChan = this.server.activeChannels.get(commandObj.socket.chan);
//Combile site-wide and channel tokes into one list
const tokeList = this.tokeCommands.concat(activeChan.tokeCommands);
//Pick a random number between 0 and one less than the number of tokes
const foundIndex = Math.round(Math.random() * (tokeList.length - 1));
//Set override command argument 0 w/ the found toke
commandObj.argumentArray[0] = tokeList[foundIndex];
//throw toke flag
var foundToke = true;
}else{
//Find the users active channel
const activeChan = this.server.activeChannels.get(commandObj.socket.chan);
@ -160,17 +104,14 @@ class tokebot{
this.chatHandler.relayTokeWhisper(commandObj.socket, `Please wait ${this.cooldownCounter} seconds before starting a new group toke.`);
}
//Toke command found, and there isn't any extra text, don't send as chat (re-create fore.st tokebot behaviour)
return (commandObj.command != `!${commandObj.argumentArray[0]}` && commandObj.command != '!r');
//Toke command found, don't send as chat
return false;
}else{
//No toke found, send it down the line, because shaming the user is funny
return true;
}
}
/**
* Called each second during the toke. Handles decrementing the timer variable, and countdown end logic.
*/
countdown(){
//If we're in the last three seconds
if(this.tokeCounter <= 3 && this.tokeCounter > 0){
@ -190,8 +131,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 toke statistics collection
tokeModel.tattooToke(this.tokers);
//Do the same for the global stat schema
statSchema.tattooToke(this.tokers);
//Set the toke cooldown
this.cooldownCounter = this.cooldownTime;
@ -213,21 +154,6 @@ class tokebot{
this.tokeTimer = setTimeout(this.countdown.bind(this), 1000)
}
/**
* This method seems to be a vestage from a bygone era. We should remove it after documenting shit.
* I would now, but I don't want to break shit in a comment-only commit.
*/
async asyncFinisher(){
//Grab a copy of the tokers map before it gets cleared out
const tokers = this.tokers;
//we need to wait for this so we don't send used tokes pre-maturely
await userModel.tattooToke(tokers);
}
/**
* Runs every second for 60 seconds after a toke
*/
cooldown(){
//If the cooldown timer isn't over
if(this.cooldownCounter > 0){
@ -242,17 +168,4 @@ class tokebot{
}
}
/**
* Resets toke cooldowns early upon authorized request
*/
resetToke(){
//Set cooldown to 0
this.cooldownCounter = 0;
//Null out the timer
this.cooldownTimer = null;
}
}
module.exports = tokebot;

View file

@ -1,63 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
/**
* Class representing a the metadata of a single message
*/
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(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
*/
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;

View file

@ -1,153 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
//NPM Imports
const validator = require('validator');//No express here, so regular validator it is!
//Local Imports
const linkUtils = require('../utils/linkUtils');
const commandProcessor = require('./channel/commandProcessor');
/**
* Class containing global server-side chat/command pre-processing logic
*/
class chatPreprocessor{
/**
* Instantiates a commandPreprocessor object
* @param {commandProcessor} - Child Command Processor Object. Contains functions named after commands.
*/
constructor(commandProcessor, tokebot){
/**
* 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
return commandObj;
}
return false;
}
/**
* 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 != 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 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);
}
}
}
}
/**
* 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
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);
}
}
module.exports = chatPreprocessor;

View file

@ -1,39 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
//localImports
const chatMetadata = require("../chatMetadata");
/**
* Class representing a single chat message
*/
class message extends chatMetadata{
/**
* @param {String} user - Name of user who sent the message
* @param {Array} recipients - Array of usernames who are supposed to receive the message
*/
constructor(user, recipients, flair, highLevel, msg, type, links){
//Call derived constructor
super(user, flair, highLevel, msg, type, links);
/**
* Array of usernames who are supposed to receive the message
*/
this.recipients = recipients;
}
}
module.exports = message;

View file

@ -1,159 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
//local includes
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
*/
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){
super(io, chanServer, "/pm");
this.chatPreprocessor = new chatPreprocessor(null, null);
}
/**
* Handles global server-side initialization for new connections to the private messaging system
* @param {Socket} socket - Requesting Socket
*/
async handleConnection(socket){
//Check if we're properly authorized
const authorized = await super.handleConnection(socket);
//If we're authorized
if(authorized != null){
//Throw the user into their own unique channel
socket.join(socket.user.user);
//Define listeners
this.defineListeners(socket);
}
}
defineListeners(socket){
super.defineListeners(socket);
socket.on("pm", (data)=>{this.handlePM(data, socket)});
}
async handlePM(data, socket){
try{
//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 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);
//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){
//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.user).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;
}
checkRecipients(input, socket){
//Create empty recipients array
let recipients = [];
//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);
}
}
//return recipients
return recipients;
}
}
module.exports = pmHandler;

View file

@ -1,28 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
//Config
const config = require('../../config.json');
const package = require('../../package.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, version: package.canopyDisplayVersion, csrfToken: csrfUtils.generateToken(req)});
}

View file

@ -17,9 +17,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
//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');
@ -48,8 +45,7 @@ module.exports.get = async function(req, res){
chanGuide: chanGuide,
userList: userList,
permList: permList,
csrfToken: csrfUtils.generateToken(req),
unescape: validator.unescape
csrfToken: csrfUtils.generateToken(req)
});
}catch(err){

View file

@ -33,14 +33,17 @@ module.exports.post = async function(req, res){
const data = matchedData(req);
//make sure we're not bullshitting ourselves here.
if(user == null || user.user == null){
return errorHandler(res, 'You must be logged in to delete your account!', 'unauthorized');
if(user == null){
res.status(400);
return res.send('Invalid Session! Cannot delete account while logged out!');
}
const userDB = await userModel.findOne({user: user.user});
const userDB = await userModel.findOne(user);
if(!userDB){
return errorHandler(res, 'User not found!', 'unauthorized');
res.status(400);
return res.send('Invalid User! Account must exist in order to delete!');
}
await userDB.nuke(data.pass);

View file

@ -38,12 +38,9 @@ module.exports.post = async function(req, res){
//Get sanatized/validated data
const {email, pass} = matchedData(req);
//If we're proxied use passthrough IP
const ip = config.proxied ? req.headers['x-forwarded-for'] : req.ip;
//Check to make sure the user is logged in
if(req.session.user == null){
return errorHandler(res, "Invalid user!");
errorHandler(res, "Invalid user!");
}
//Authenticate and find user model from DB
@ -51,26 +48,15 @@ module.exports.post = async function(req, res){
//If we have an invalid user
if(userDB == null){
return errorHandler(res, "Invalid user!");
errorHandler(res, "Invalid user!");
}
if(userDB.email == 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!");
errorHandler(res, "Cannot set current email!");
}
//Generate the password reset link
const requestDB = await emailChangeModel.create({user: userDB._id, newEmail: email, ipHash: ip});
const requestDB = await emailChangeModel.create({user: userDB._id, newEmail: email, ipHash: req.ip});
//Don't wait on mailer to get back to the browser
res.sendStatus(200);

View file

@ -21,11 +21,10 @@ const config = require('../../../../config.json');
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');
const altchaUtils = require('../../../utils/altchaUtils');
const session = require('express-session');
//api account functions
module.exports.post = async function(req, res){
@ -36,45 +35,10 @@ module.exports.post = async function(req, res){
//if we don't have errors
if(validResult.isEmpty()){
//Pull sanatzied/validated data
const data = matchedData(req);
//try to authenticate the session, throwing an error and breaking the current code block if user is un-authorized
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){
//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(userDB, data.pass);
//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));
//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
const {user, pass} = matchedData(req);
//try to authenticate the session, and return a successful code if it works
await sessionUtils.authenticateSession(user, pass, req);
return res.sendStatus(200);
}else{
res.status(400);
@ -87,35 +51,21 @@ module.exports.post = async function(req, res){
//if we don't have errors
if(validResult.isEmpty()){
//Get login attempts for current user
const {user, pass} = matchedData(req);
//Look for the username in the migration DB
const migrationDB = await migrationModel.findOne({user});
//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 {user} = matchedData(req);
const attempts = sessionUtils.getLoginAttempts(user)
//if we've gone over max attempts
if(attempts != null && attempts.count > sessionUtils.throttleAttempts){
//if we've gone over max attempts and
if(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()})
}
//
return exceptionHandler(res, err);
}
}

View file

@ -15,36 +15,13 @@ You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.*/
//local imports
const rememberMeModel = require('../../../schemas/user/rememberMeSchema');
const sessionUtils = require('../../../utils/sessionUtils');
const {exceptionHandler} = require('../../../utils/loggerUtils');
const {validationResult, matchedData} = require('express-validator');
const accountUtils = require('../../../utils/sessionUtils');
const {exceptionHandler, errorHandler} = require('../../../utils/loggerUtils');
module.exports.post = async function(req, res){
if(req.session.user){
try{
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 != null && data.rememberme.id != null){
//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
accountUtils.killSession(req.session);
return res.sendStatus(200);
}catch(err){
return exceptionHandler(res, err);

View file

@ -1,96 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
//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('<br>'), '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);
}
}

View file

@ -40,9 +40,6 @@ module.exports.post = async function(req, res){
//Verify Altcha Payload
const verified = await altchaUtils.verify(req.body.verification);
//If we're proxied use passthrough IP
const ip = config.proxied ? req.headers['x-forwarded-for'] : req.ip;
//If altcha verification failed
if(!verified){
return errorHandler(res, 'Altcha verification failed, Please refresh the page!', 'unauthorized');
@ -66,7 +63,7 @@ module.exports.post = async function(req, res){
}
//Generate the password reset link
const requestDB = await passwordResetModel.create({user: userDB._id, ipHash: ip});
const requestDB = await passwordResetModel.create({user: userDB._id, ipHash: req.ip});
//Send the reset url via email
const mailInfo = await mailUtils.mailem(

View file

@ -43,10 +43,6 @@ module.exports.post = async function(req, res){
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;
//Would prefer to stick this in userModel.statics.register() but we end up with circular dependencies >:(
const nukedBans = await userBanModel.checkProcessedBans(user.user);
@ -57,44 +53,22 @@ module.exports.post = async function(req, res){
}
//Look for ban by IP
const ipBanDB = await userBanModel.checkBanByIP(ip);
const ipBanDB = await userBanModel.checkBanByIP(req.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 register an account from has been permanently banned.',
'Your cleartext IP has been saved to the database.',
`Any associated accounts 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 register 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 :)'
];
}
//Make the code and message look pretty (kinda) at the same time
const banMsg = [
'The IP address you are trying to register an account from has been banned.',
'If you beleive this to be an error feel free to reach out to your server administrator.',
'Otherwise, fuck off :)'
];
//tell it to fuck off
return errorHandler(res, banMsg.join('<br>'), 'unauthorized');
}
//Register off of given IP
await userModel.register(user, ip);
await userModel.register(user, req.ip);
return res.sendStatus(200);
}else{
res.status(400);

View file

@ -46,13 +46,7 @@ module.exports.post = async function(req, res){
const {field, change} = data;
const {user} = req.session;
//If the user is null
if(user == null || user.user == null){
//BEFORE YOU BREAK MY HEART!!!
return errorHandler(res, 'You must be logged in to preform this action!', 'unauthorized');
}
const userDB = await userModel.findOne({user: user.user});
const userDB = await userModel.findOne(user);
const update = {};
@ -92,7 +86,8 @@ module.exports.post = async function(req, res){
res.status(200);
return res.send(update);
}else{
return errorHandler(res, 'User not found!', 'unauthorized');
res.status(400);
return res.send({errors: [{msg:"User not found!"}]});
}
}else{
res.status(400);

View file

@ -14,9 +14,6 @@ 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 <https://www.gnu.org/licenses/>.*/
//config
const config = require('../../../../config.json');
//npm imports
const {validationResult, matchedData} = require('express-validator');
@ -37,9 +34,6 @@ module.exports.post = async function(req, res){
//Find user from input
const userDB = await userModel.findOne({user});
//If we're proxied use passthrough IP
const ip = config.proxied ? req.headers['x-forwarded-for'] : req.ip;
//If there is no user
if(userDB == null){
//Scream
@ -47,7 +41,7 @@ module.exports.post = async function(req, res){
}
//Generate the password reset link
const requestDB = await passwordResetModel.create({user: userDB._id, ipHash: ip});
const requestDB = await passwordResetModel.create({user: userDB._id, ipHash: req.ip});
//send URL
res.status(200);

View file

@ -31,7 +31,7 @@ module.exports.post = async function(req, res){
const channel = await channelModel.findOne({name: data.chanName});
if(channel == null){
throw loggerUtils.exceptionSmith("Chanenl does not exist!", "validation");
throw new Error("Chanenl does not exist!");
}
await channel.nuke(data.confirm);

View file

@ -1,90 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
//NPM Imports
const {validationResult, matchedData} = require('express-validator');
//local imports
const channelModel = require('../../../schemas/channel/channelSchema');
const {exceptionHandler} = require('../../../utils/loggerUtils');
//get thumby
module.exports.get = async function(req, res){
try{
//Pull validated result
const validResult = validationResult(req);
//if everything validated proper
if(validResult.isEmpty()){
//Get matched data
const data = matchedData(req);
//pull channel
const chanDB = await channelModel.findOne({name: data.chanName});
//Null check channel
if(chanDB == null){
throw loggerUtils.exceptionSmith("Channel not found.", "validation");
}
//return thumby
res.status(200);
return res.send({description: chanDB.description});
}else{
res.status(400);
res.send({errors: validResult.array()})
}
}catch(err){
exceptionHandler(res, err);
}
}
//Post function
module.exports.post = async function(req, res){
try{
//Pull validated result
const validResult = validationResult(req);
//if everything validated proper
if(validResult.isEmpty()){
//Get matched data
const data = matchedData(req);
//pull channel
const chanDB = await channelModel.findOne({name: data.chanName});
//Null check channel
if(chanDB == null){
throw loggerUtils.exceptionSmith("Channel not found.", "validation");
}
//Set thumbnail
chanDB.description = data.description;
//Save channel doc
await chanDB.save();
//return thumby
res.status(200);
return res.send({description: chanDB.description});
}else{
res.status(400);
res.send({errors: validResult.array()})
}
}catch(err){
exceptionHandler(res, err);
}
}

View file

@ -33,7 +33,7 @@ module.exports.get = async function(req, res){
if(channel == null){
throw loggerUtils.exceptionSmith("Channel not found.", "validation");
throw new Error("Channel not found.");
}
res.status(200);
@ -64,7 +64,7 @@ module.exports.post = async function(req, res){
var permError = null;
if(chanDB == null){
throw loggerUtils.exceptionSmith("Channel not found.", "validation");
throw new Error("Channel not found.");
}
//For each permission submitted

View file

@ -31,7 +31,7 @@ module.exports.get = async function(req, res){
const channel = await channelModel.findOne({name: data.chanName});
if(channel == null){
throw loggerUtils.exceptionSmith("Channel not found.", "validation");
throw new Error("Channel not found.");
}
res.status(200);
@ -56,7 +56,7 @@ module.exports.post = async function(req, res){
const settingsMap = new Map(Object.entries(data.settingsMap));
if(channel == null){
throw loggerUtils.exceptionSmith("Channel not found.", "validation");
throw new Error("Channel not found.");
}
res.status(200);

View file

@ -1,90 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
//NPM Imports
const {validationResult, matchedData} = require('express-validator');
//local imports
const channelModel = require('../../../schemas/channel/channelSchema');
const {exceptionHandler} = require('../../../utils/loggerUtils');
//get thumby
module.exports.get = async function(req, res){
try{
//Pull validated result
const validResult = validationResult(req);
//if everything validated proper
if(validResult.isEmpty()){
//Get matched data
const data = matchedData(req);
//pull channel
const chanDB = await channelModel.findOne({name: data.chanName});
//Null check channel
if(chanDB == null){
throw loggerUtils.exceptionSmith("Channel not found.", "validation");
}
//return thumby
res.status(200);
return res.send({thumbnail: chanDB.thumbnail});
}else{
res.status(400);
res.send({errors: validResult.array()})
}
}catch(err){
exceptionHandler(res, err);
}
}
//Post function
module.exports.post = async function(req, res){
try{
//Pull validated result
const validResult = validationResult(req);
//if everything validated proper
if(validResult.isEmpty()){
//Get matched data
const data = matchedData(req);
//pull channel
const chanDB = await channelModel.findOne({name: data.chanName});
//Null check channel
if(chanDB == null){
throw loggerUtils.exceptionSmith("Channel not found.", "validation");
}
//Set thumbnail
chanDB.thumbnail = data.thumbnail;
//Save channel doc
await chanDB.save();
//return thumby
res.status(200);
return res.send({thumbnail: chanDB.thumbnail});
}else{
res.status(400);
res.send({errors: validResult.array()})
}
}catch(err){
exceptionHandler(res, err);
}
}

View file

@ -1,31 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
//local imports
const {exceptionHandler, errorHandler} = require('../../utils/loggerUtils');
const csrfUtils = require('../../utils/csrfUtils');
//api account functions
module.exports.get = async function(req, res){
try{
//Set status to 200
res.status(200);
//Generate and send token based on the request
res.send({token: csrfUtils.generateToken(req)});
}catch(err){
return exceptionHandler(res, err);
}
}

View file

@ -17,9 +17,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
//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,10 +36,10 @@ module.exports.get = async function(req, res){
delete chanDB.permissions._doc._id;
if(chanDB == null){
throw loggerUtils.exceptionSmith("Channel not found.", "queue");
throw new Error("Channel not found.");
}
return res.render('channelSettings', {instance: config.instanceName, user: req.session.user, channel: chanDB, reqRank, rankEnum: permissionModel.rankEnum, csrfToken: csrfUtils.generateToken(req), unescape: validator.unescape});
return res.render('channelSettings', {instance: config.instanceName, user: req.session.user, channel: chanDB, reqRank, rankEnum: permissionModel.rankEnum, csrfToken: csrfUtils.generateToken(req)});
}catch(err){
return exceptionHandler(res, err);
}

View file

@ -17,9 +17,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
//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');
@ -29,7 +26,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), unescape: validator.unescape});
return res.render('index', {instance: config.instanceName, user: req.session.user, chanGuide: chanGuide, csrfToken: csrfUtils.generateToken(req)});
}catch(err){
return exceptionHandler(res, err);
}

View file

@ -1,31 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
//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)});
}

View file

@ -1,20 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
//root index functions
module.exports.get = async function(req, res){
res.render('partial/panels/pm', {});
}

View file

@ -1,49 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
//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');
const {exceptionHandler, errorHandler} = require('../../utils/loggerUtils');
//root index functions
module.exports.get = async function(req, res){
try{
const validResult = validationResult(req);
if(validResult.isEmpty()){
const data = matchedData(req);
const profile = await userModel.findProfile({user: data.user});
//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, unescape: validator.unescape});
}else{
res.status(400);
return res.send({errors: validResult.array()})
}
}catch(err){
return exceptionHandler(res, err);
}
}

View file

@ -1,20 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
//root index functions
module.exports.get = async function(req, res){
res.render('partial/panels/settings', {});
}

View file

@ -16,5 +16,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
//root index functions
module.exports.get = async function(req, res){
res.render('partial/panels/queue', {});
try{
res.render(`partial/popup${req.url}`, {});
}catch(err){
return res.sendStatus(400);
}
}

View file

@ -17,12 +17,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
//Local Imports
const {userModel} = require('../schemas/user/userSchema');
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');
@ -38,17 +34,12 @@ 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),
unescape: validator.unescape
csrfToken: csrfUtils.generateToken(req)
});
}else{
res.render('profile', {
@ -56,9 +47,7 @@ module.exports.get = async function(req, res){
user: req.session.user,
profile: null,
selfProfile: false,
presence: null,
csrfToken: csrfUtils.generateToken(req),
unescape: validator.unescape
csrfToken: csrfUtils.generateToken(req)
});
}
}catch(err){

View file

@ -16,7 +16,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
//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');
@ -35,7 +34,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(), unescape: validator.unescape});
return res.render('partial/tooltip/altList', {alts: await userDB.getAltProfiles()});
}else{
res.status(400);
return res.send({errors: validResult.array()})

View file

@ -1,45 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
//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');
//root index functions
module.exports.get = async function(req, res){
try{
const validResult = validationResult(req);
if(validResult.isEmpty()){
const data = matchedData(req);
const profile = await userModel.findProfile({user: data.user});
return res.render('partial/tooltip/profile', {profile, unescape: validator.unescape});
}else{
res.status(400);
return res.send({errors: validResult.array()});
}
}catch(err){
return exceptionHandler(res, err);
}
}

View file

@ -1,34 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
//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;

View file

@ -17,17 +17,16 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
//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);

View file

@ -22,7 +22,6 @@ 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");
@ -39,32 +38,18 @@ 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);
//migrate legacy profile
router.post('/migrate',
accountValidator.user(),
accountValidator.pass('oldPass'),
accountValidator.securePass('newPass'),
accountValidator.pass('passConfirm'),
migrationController.post);
accountValidator.email(), registerController.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);

View file

@ -22,7 +22,7 @@ const { Router } = require('express');
//Models
const permissionModel = require("../../schemas/permissionSchema");
const channelModel = require("../../schemas/channel/channelSchema");
//Validators
//Valudators
const channelValidator = require("../../validators/channelValidator");
const accountValidator = require("../../validators/accountValidator");
const {channelPermissionValidator} = require("../../validators/permissionsValidator");
@ -30,8 +30,6 @@ const tokebotValidator = require("../../validators/tokebotValidator");
const emoteValidator = require("../../validators/emoteValidator");
//Controllers
const registerController = require("../../controllers/api/channel/registerController");
const thumbnailController = require("../../controllers/api/channel/thumbnailController");
const descriptionController = require("../../controllers/api/channel/descriptionController");
const listController = require("../../controllers/api/channel/listController");
const settingsController = require("../../controllers/api/channel/settingsController");
const permissionsController = require("../../controllers/api/channel/permissionsController")
@ -44,10 +42,8 @@ const emoteController = require('../../controllers/api/channel/emoteController')
//globals
const router = Router();
//Set validator functions
//user authentication middleware
router.use("/register",permissionModel.reqPermCheck("registerChannel"));
router.use("/thumbnail",channelValidator.name("chanName"));
router.use("/description",channelValidator.name("chanName"));
router.use("/settings", channelValidator.name('chanName'));
router.use("/permissions", channelValidator.name('chanName'));
router.use("/rank", channelValidator.name('chanName'));
@ -59,12 +55,6 @@ router.use("/emote", channelValidator.name('chanName'));
//routing functions
//register
router.post('/register', channelValidator.name(), channelValidator.description(), channelValidator.thumbnail(), registerController.post);
//Thumbnail
router.get('/thumbnail', thumbnailController.get);
router.post('/thumbnail', channelValidator.thumbnail(), thumbnailController.post);
//Description
router.get('/description', descriptionController.get);
router.post('/description', channelValidator.description(), descriptionController.post);
//list
router.get('/list', channelModel.reqPermCheck("manageChannel"), listController.get);
//settings

View file

@ -18,19 +18,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
const { Router } = require('express');
//local imports
const csrfUtil = require('../utils/csrfUtils');
const accountRouter = require("./api/accountRouter");
const channelRouter = require("./api/channelRouter");
const adminRouter = require("./api/adminRouter");
const refreshCSRFTokenController = require("../controllers/api/refreshCSRFTokenController");
const csrfUtil = require('../utils/csrfUtils');
//globals
const router = Router();
//CSRF token request controller
router.get('/refreshToken', refreshCSRFTokenController.get);
//Apply Cross-Site Request Forgery protection to API calls
router.use(csrfUtil.csrfSynchronisedProtection);

View file

@ -22,7 +22,6 @@ 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();
@ -30,9 +29,6 @@ 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);

View file

@ -20,14 +20,10 @@ 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);

View file

@ -21,7 +21,6 @@ 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();
@ -29,9 +28,6 @@ const router = Router();
//user authentication middleware
router.use("/",permissionSchema.reqPermCheck("registerChannel"));
//Use presence middleware
router.use(presenceUtils.presenceMiddleware);
//routing functions
router.get('/', newChannelController.get);

View file

@ -22,12 +22,6 @@ const { Router } = require('express');
const placeholderController = require("../controllers/panel/placeholderController");
const emoteController = require("../controllers/panel/emoteController");
const popoutContainerController = require("../controllers/panel/popoutContainerController");
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");
//globals
const router = Router();
@ -36,9 +30,5 @@ const router = Router();
router.get('/placeholder', placeholderController.get);
router.get('/emote', emoteController.get);
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;

View file

@ -19,12 +19,12 @@ const { Router } = require('express');
//local imports
const migrateController = require("../controllers/migrateController");
const popupController = require("../controllers/popupController");
//globals
const router = Router();
//routing functions
router.get('/', migrateController.get);
router.get('/*', popupController.get);
module.exports = router;

View file

@ -19,19 +19,14 @@ const { Router } = require('express');
//local imports
//DB Models
const permissionModel = require("../schemas/permissionSchema");
//Validators
const accountValidator = require("../validators/accountValidator");
//controllers
const altListController = require("../controllers/tooltip/altListController");
const profileController = require("../controllers/tooltip/profileController");
const permissionSchema = require("../schemas/permissionSchema");
const accountValidator = require("../validators/accountValidator");
//globals
const router = Router();
//routing functions
router.get('/altList', accountValidator.user(), permissionModel.reqPermCheck("adminPanel"), altListController.get);
router.get('/profile', accountValidator.user(), profileController.get);
router.get('/altList', accountValidator.user(), permissionSchema.reqPermCheck("adminPanel"), altListController.get);
module.exports = router;

View file

@ -17,9 +17,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
//NPM Imports
const {mongoose} = require('mongoose');
/**
* DB Schema for Documents representing a user ban from a single channel
*/
const channelBanSchema = new mongoose.Schema({
user: {
type: mongoose.SchemaTypes.ObjectID,
@ -44,10 +41,6 @@ const channelBanSchema = new mongoose.Schema({
});
//methods
/**
* Calculates days until ban expiration
* @returns {Number} Days until the given ban expires
*/
channelBanSchema.methods.getDaysUntilExpiration = function(){
//Get ban date
const expirationDate = new Date(this.banDate);

View file

@ -17,17 +17,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
//NPM Imports
const {mongoose} = require('mongoose');
/**
* Rank Enum, lists all known permission ranks from lowest to highest.
*
* This originally belonged to the permissionSchema, but this avoids circular dependencies.
*/
//This originally belonged to the permissionSchema, but this avoids circular dependencies.
const rankEnum = ["anon", "user", "gold", "bot", "mod", "admin"];
//Since this is intended to be used as a child schema for multiple parent schemas, we won't export it as a model
/**
* DB Schema for Sub-Document representing permission structure for a single channel
*/
const channelPermissionSchema = new mongoose.Schema({
manageChannel: {
type: mongoose.SchemaTypes.String,
@ -89,36 +82,6 @@ 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,
default: "admin",
required: true
},
clearSchedule:{
type: mongoose.SchemaTypes.String,
enum: rankEnum,
default: "admin",
required: true
},
scheduleAdmin:{
type: mongoose.SchemaTypes.String,
enum: rankEnum,
default: "admin",
required: true
},
editChannelPlaylists:{
type: mongoose.SchemaTypes.String,
enum: rankEnum,
default: "admin",
required: true
},
deleteChannel: {
type: mongoose.SchemaTypes.String,
enum: rankEnum,

View file

@ -29,15 +29,9 @@ const emoteModel = require('../emoteSchema');
//DB Schemas
const channelPermissionSchema = require('./channelPermissionSchema');
const channelBanSchema = require('./channelBanSchema');
const queuedMediaSchema = require('./media/queuedMediaSchema');
const playlistSchema = require('./media/playlistSchema');
const chatSchema = require('./chatSchema');
//Utils
const { exceptionHandler, errorHandler } = require('../../utils/loggerUtils');
/**
* DB Schema for Documents containing de-hydrated representations of Canopy Stream/Chat Channels
*/
const channelSchema = new mongoose.Schema({
id: {
type: mongoose.SchemaTypes.Number,
@ -46,21 +40,19 @@ const channelSchema = new mongoose.Schema({
name: {
type: mongoose.SchemaTypes.String,
required: true,
//Calculate max length by the validator max length and the size of an escaped character
maxLength: 50 * 6,
maxLength: 50,
default: 0
},
description: {
type: mongoose.SchemaTypes.String,
required: true,
//Calculate max length by the validator max length and the size of an escaped character
maxLength: 1000 * 6,
maxLength: 1000,
default: 0
},
thumbnail: {
type: mongoose.SchemaTypes.String,
required: true,
default: "/nonfree/johnny.png"
default: "/img/johnny.png"
},
settings: {
hidden: {
@ -68,10 +60,6 @@ const channelSchema = new mongoose.Schema({
required: true,
default: true
},
streamURL: {
type: mongoose.SchemaTypes.String,
default: ''
}
},
permissions: {
type: channelPermissionSchema,
@ -110,34 +98,18 @@ const channelSchema = new mongoose.Schema({
default: emoteModel.typeEnum[0]
}
}],
media: {
nowPlaying: queuedMediaSchema,
scheduled: [queuedMediaSchema],
//We should consider moving archived media and channel playlists to their own collections/models for preformances sake
archived: [queuedMediaSchema],
playlists: [playlistSchema],
liveRemainder: {
type: mongoose.SchemaTypes.UUID,
required: false
}
},
//Thankfully we don't have to keep track of alts, ips, or deleted users so this should be a lot easier than site-wide bans :P
banList: [channelBanSchema],
chatBuffer: [chatSchema]
banList: [channelBanSchema]
});
/**
* Channel pre-save function. Ensures name requirements (for some reason, we should move that to the schema probably), kicks users after rank change, and handles housekeeping after adding tokes/emotes
*/
channelSchema.pre('save', async function (next){
if(this.isModified("name")){
if(this.name.match(/^[a-z0-9_\-.]+$/i) == null){
throw loggerUtils.exceptionSmith("Username must only contain alpha-numerics and the following symbols: '-_.'", "validation");
throw new Error("Username must only contain alpha-numerics and the following symbols: '-_.'");
}
}
//This entire block is just about finding users after rank-change and making sure they get kicked
//Getting the affected user would be a million times easier elsewhere
//But this ensures it happens every time channel rank gets changed no matter what
if(this.isModified('rankList') && this.rankList != null){
@ -234,18 +206,13 @@ channelSchema.pre('save', async function (next){
});
//statics
/**
* Registers a new channel to the DB
* @param {Object} channelObj - Channel Object from Browser to register
* @param {Mongoose.Document} ownerObj - DB Docuement representing user
*/
channelSchema.statics.register = async function(channelObj, ownerObj){
const {name, description, thumbnail} = channelObj;
const chanDB = await this.findOne({ name });
if(chanDB){
throw loggerUtils.exceptionSmith("Channel name already taken!", "validation");
throw new Error("Channel name already taken!");
}else{
const id = await statModel.incrementChannelCount();
const rankList = [{
@ -253,28 +220,10 @@ channelSchema.statics.register = async function(channelObj, ownerObj){
rank: "admin"
}];
const newChannelObj = {
id,
name,
description,
thumbnail,
rankList,
media: {
nowPlaying: null,
scheduledMedia: [],
archived: []
}
};
const newChannel = await this.create(newChannelObj);
const newChannel = await this.create((thumbnail ? {id, name, description, thumbnail, rankList} : {id, name, description, rankList}));
}
}
/**
* Generates Network-Friendly Browser-Digestable list of channels
* @param {Boolean} includeHidden - Whether or not to include hidden channels within the list
* @returns {Array} List of Network-Friendly Browser-Digestable Objects representing channels on the server
*/
channelSchema.statics.getChannelList = async function(includeHidden = false){
const chanDB = await this.find({});
var chanGuide = [];
@ -297,16 +246,9 @@ channelSchema.statics.getChannelList = async function(includeHidden = false){
}
//Middleware for rank checks
/**
* Configurable Express Middleware for Per-Channel Endpoint Authorization
*
* Man, it would be really nice if express middleware actually supported async functions, you know, as if it where't still 2015 >:(
* Also holy shit, sharing a function between two middleware functions is a nightmare
* I'd rather just have this check chanField for '/c/' to handle channels in URL, fuck me this was obnoxious to write
* @param {String} - Permission to check against
* @param {String} - Name of channel to authorize against
* @returns {Function} Express middleware function with arguments injected into logic
*/
//Man, it would be really nice if express middleware actually supported async functions, you know, as if it where't still 2015 >:(
//Also holy shit, sharing a function between two middleware functions is a nightmare
//I'd rather just have this check chanField for '/c/' to handle channels in URL, fuck me this was obnoxious to write
channelSchema.statics.reqPermCheck = function(perm, chanField = "chanName"){
return (req, res, next)=>{
try{
@ -351,41 +293,26 @@ channelSchema.statics.reqPermCheck = function(perm, chanField = "chanName"){
}
}
/**
* Schedulable Function for Processing and Deleting Expired Channel-level User Bans
*/
channelSchema.statics.processExpiredBans = async function(){
const chanDB = await this.find({});
for(let chanIndex in chanDB){
//Pull channel from channels by index
const channel = chanDB[chanIndex];
//channel.banList.forEach(async (ban, banIndex) => {
for(let banIndex in channel.banList){
//Pull ban from channel ban list
const ban = channel.banList[banIndex];
chanDB.forEach((channel) => {
channel.banList.forEach(async (ban, i) => {
//ignore permanent and non-expired bans
if(ban.expirationDays >= 0 && ban.getDaysUntilExpiration() <= 0){
//Get the index of the ban
channel.banList.splice(banIndex,1);
await channel.save();
channel.banList.splice(i,1);
return await channel.save();
}
}
}
})
});
}
//methods
/**
* Updates settings map for a given channel document
* @param {Map} settingsMap - Map of settings updates to apply against channel document
* @returns {Map} Map of all channel settings
*/
channelSchema.methods.updateSettings = async function(settingsMap){
settingsMap.forEach((value, key) => {
if(this.settings[key] == null){
throw loggerUtils.exceptionSmith("Invalid channel setting.", "validation");
throw new Error("Invalid channel setting.");
}
this.settings[key] = value;
@ -396,11 +323,6 @@ channelSchema.methods.updateSettings = async function(settingsMap){
return this.settings;
}
/**
* Crawls through channel rank and runs a callback against the requested user's rank sub-doc
* @param {Mongoose.Document} userDB - User DB Document to run the callback against
* @param {Function} cb - Callback Function to call against the given users rank sub-doc
*/
channelSchema.methods.rankCrawl = async function(userDB,cb){
//Crawl through channel rank list
//TODO: replace this with rank check function shared with setRank
@ -413,12 +335,6 @@ channelSchema.methods.rankCrawl = async function(userDB,cb){
});
}
/**
* Sets users rank by User Doc
* @param {Mongoose.Document} userDB - DB Document of user's channel rank to change
* @param {String} rank - Channel rank to set user to
* @returns {Array} Channel Rank List
*/
channelSchema.methods.setRank = async function(userDB,rank){
//Create variable to store found ranks
var foundRankIndex = null;
@ -452,10 +368,6 @@ channelSchema.methods.setRank = async function(userDB,rank){
return this.rankList;
}
/**
* Generates Network-Friendly Browser-Digestable channel rank list
* @returns {Array} Network-Friendly Browser-Digestable channel rank list
*/
channelSchema.methods.getRankList = async function(){
//Create an empty array to hold the user list
const rankList = new Map()
@ -504,11 +416,6 @@ channelSchema.methods.getRankList = async function(){
return rankList;
}
/**
* Gets channel rank by user document
* @param {Mongoose.Document} userDB - DB Document of User to pull Channel Rank of
* @returns {String} Channel rank of requested user
*/
channelSchema.methods.getChannelRankByUserDoc = async function(userDB = null){
var foundRank = null;
@ -535,39 +442,11 @@ channelSchema.methods.getChannelRankByUserDoc = async function(userDB = null){
}
}
/**
* Gets channel rank by username
* @param {String} user - Username of user to pull channel rank of
* @returns {String} Channel rank of requested user
*/
channelSchema.methods.getChannelRank = async function(user){
const userDB = await userModel.findOne({user: user.user});
return await this.getChannelRankByUserDoc(userDB);
}
/**
* Calculates a permission check against a specific channel permission for a given user by username
* @param {String} user - Username of user to check against
* @param {String} perm - Name of channel Permission to check against
* @returns {Boolean} Whether or not the given user passes the given channel perm check
*/
channelSchema.methods.permCheck = async function (user, perm){
//Set userDB to null if we wheren't passed a real user
if(user != null){
var userDB = await userModel.findOne({user: user.user});
}else{
var userDB = null;
}
return await this.permCheckByUserDoc(userDB, perm)
}
/**
* Calculates a permission check against a specific channel permission for a given user by DB Document
* @param {Mongoose.Document} userDB - DB Document of user to check against
* @param {String} perm - Name of channel Permission to check against
* @returns {Boolean} Whether or not the given user passes the given channel perm check
*/
channelSchema.methods.permCheckByUserDoc = async function(userDB, perm){
//Get site-wide rank as number, default to anon for anonymous users
const rank = userDB ? permissionModel.rankToNum(userDB.rank) : permissionModel.rankToNum("anon");
@ -585,37 +464,17 @@ channelSchema.methods.permCheckByUserDoc = async function(userDB, perm){
return (permCheck || overrideCheck);
}
/**
* Generates channel-wide permission map for a given user by user doc
* @param {Mongoose.Document} userDB - DB Document representing a single user account
* @returns {Object} Object containing two maps, one for channel perms, another for site-wide perms
*/
channelSchema.methods.getPermMapByUserDoc = async function(userDB){
//Grap site-wide permissions
const sitePerms = await permissionModel.getPerms();
const siteMap = sitePerms.getPermMapByUserDoc(userDB);
//Pull chan permissions keys
let permTree = channelPermissionSchema.tree;
let permMap = new Map();
channelSchema.methods.permCheck = async function (user, perm){
//Set userDB to null if we wheren't passed a real user
if(user != null){
var userDB = await userModel.findOne({user: user.user});
}else{
var userDB = null;
}
//For each object in the temporary permissions object
for(let perm of Object.keys(permTree)){
//Check the current permission
permMap.set(perm, await this.permCheckByUserDoc(userDB, perm));
}
//return perm map
return {
site: siteMap.site,
chan: permMap
};
return await this.permCheckByUserDoc(userDB, perm)
}
/**
* Checks if a specific user has been issued a channel-specific ban by DB doc
* @param {Mongoose.Document} userDB - DB Document representing a single user account
* @returns {Object} Found ban, if one exists
*/
channelSchema.methods.checkBanByUserDoc = async function(userDB){
var foundBan = null;
@ -644,10 +503,6 @@ channelSchema.methods.checkBanByUserDoc = async function(userDB){
return foundBan;
}
/**
* Generates Network-Friendly Browser-Digestable list of channel emotes
* @returns {Array} Network-Friendly Browser-Digestable list of channel emotes
*/
channelSchema.methods.getEmotes = function(){
//Create an empty array to hold our emote list
const emoteList = [];
@ -666,81 +521,6 @@ channelSchema.methods.getEmotes = function(){
return emoteList;
}
/**
* Generates Network-Friendly Browser-Digestable list of channel playlists
* @returns {Array} Network-Friendly Browser-Digestable list of channel playlists
*/
channelSchema.methods.getPlaylists = function(){
//Create an empty array to hold our emote list
const playlists = [];
//For each channel emote
for(let playlist of this.media.playlists){
//Push an object with select information from the emote to the emote list
playlists.push(playlist.dehydrate());
}
//return the emote list
return playlists;
}
/**
* Crawls through channel playlists, running a given callback function against each one
* @param {Function} cb - Callback function to run against channel playlists
*/
channelSchema.methods.playlistCrawl = function(cb){
for(let listIndex in this.media.playlists){
//Grab the associated playlist
playlist = this.media.playlists[listIndex];
//Call the callback with the playlist and list index as arguments
cb(playlist, listIndex);
}
}
/**
* Finds channel playlist by playlist name
* @param {String} name - name of given playlist to find
* @returns {Mongoose.Document} - Sub-Document representing a single playlist
*/
channelSchema.methods.getPlaylistByName = function(name){
//Create null value to hold our found playlist
let foundPlaylist = null;
//Crawl through active playlists
this.playlistCrawl((playlist, listIndex) => {
//If we found a match based on name
if(playlist.name == name){
//Keep it
foundPlaylist = playlist;
//Pass down the list index
foundPlaylist.listIndex = listIndex;
}
});
//return the given playlist
return foundPlaylist;
}
/**
* Deletes channel playlist by playlist name
* @param {String} name - name of given playlist to Delete
*/
channelSchema.methods.deletePlaylistByName = async function(name){
//Find the playlist
let playlist = this.getPlaylistByName(name);
//splice out the given playlist
this.media.playlists.splice(playlist.listIndex, 1);
//save the channel document
await this.save();
}
/**
* Generates Network-Friendly Browser-Digestable list of Channel-Wide user bans
* @returns {Array} Network-Friendly Browser-Digestable list of Channel-Wide user bans
*/
channelSchema.methods.getChanBans = async function(){
//Create an empty list to hold our found bans
var banList = [];
@ -781,22 +561,16 @@ channelSchema.methods.getChanBans = async function(){
return banList;
}
/**
* Issues channel-wide ban to user based on user DB document
* @param {Mongoose.Document} userDB - DB Document representing a single user account to ban
* @param {Number} expirationDays - Days until ban expiration
* @param {Boolean} banAlts - Whether or not to ban alts
*/
channelSchema.methods.banByUserDoc = async function(userDB, expirationDays, banAlts){
//Throw a shitfit if the user doesn't exist
if(userDB == null){
throw loggerUtils.exceptionSmith("Cannot ban non-existant user!", "validation");
throw new Error("Cannot ban non-existant user!");
}
const foundBan = await this.checkBanByUserDoc(userDB);
if(foundBan != null){
throw loggerUtils.exceptionSmith("User already banned!", "validation");
throw new Error("User already banned!");
}
//Create a new ban document based on input
@ -824,33 +598,21 @@ channelSchema.methods.banByUserDoc = async function(userDB, expirationDays, banA
await this.save();
}
/**
* Syntatic sugar for banning users by username
* @param {String} user - Username of user to ban
* @param {Number} expirationDays - Days until ban expiration
* @param {Boolean} banAlts - Whether or not to ban alts
* @returns {Promise} promise from this.banByUserDoc
*/
channelSchema.methods.ban = async function(user, expirationDays, banAlts){
const userDB = await userModel.find({user});
return await this.banByUserDoc(userDB, expirationDays, banAlts);
}
/**
* Un-Bans user by DB Document
* @param {Mongoose.Document} userDB - DB Document representing a single user account to un-ban
* @returns {Mongoose.Document} Saved channel document
*/
channelSchema.methods.unbanByUserDoc = async function(userDB){
//Throw a shitfit if the user doesn't exist
if(userDB == null){
throw loggerUtils.exceptionSmith("Cannot ban non-existant user!", "validation");
throw new Error("Cannot ban non-existant user!");
}
const foundBan = await this.checkBanByUserDoc(userDB);
if(foundBan == null){
throw loggerUtils.exceptionSmith("User already unbanned!", "validation");
throw new Error("User already unbanned!");
}
//You know I can't help but feel like an asshole for looking for the index of something I just pulled out of an array using forEach...
@ -861,32 +623,23 @@ channelSchema.methods.unbanByUserDoc = async function(userDB){
return await this.save();
}
/**
* Syntatic sugar for un-banning by username
* @param {String} user - Username of user to un-ban
* @returns {Mongoose.Document} Saved channel document
*/
channelSchema.methods.unban = async function(user){
const userDB = await userModel.find({user});
return await this.unbanByUserDoc(userDB);
}
/**
* Nukes channel upon channel-admin request
* @param {String} confirm - Channel name to confirm deletion of channel
*/
channelSchema.methods.nuke = async function(confirm){
if(confirm == "" || confirm == null){
throw loggerUtils.exceptionSmith("Empty Confirmation String!", "validation");
throw new Error("Empty Confirmation String!");
}else if(confirm != this.name){
throw loggerUtils.exceptionSmith("Bad Confirmation String!", "validation");
throw new Error("Bad Confirmation String!");
}
//Annoyingly there isnt a good way to do this from 'this'
var oldChan = await this.deleteOne();
if(oldChan.deletedCount == 0){
throw loggerUtils.exceptionSmith("Server Error: Unable to delete channel! Please report this error to your server administrator, and with timestamp.", "internal");
throw new Error("Server Error: Unable to delete channel! Please report this error to your server administrator, and with timestamp.");
}
}

View file

@ -1,56 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
//NPM Imports
const {mongoose} = require('mongoose');
const linkSchema = new mongoose.Schema({
link: mongoose.SchemaTypes.String,
type: mongoose.SchemaTypes.String
});
/**
* DB Schema for documents representing a single chat message
*/
const chatSchema = new mongoose.Schema({
user: {
type: mongoose.SchemaTypes.String,
required: true,
},
flair: {
type: mongoose.SchemaTypes.String,
//Leave this as unreq'd for internal type chats that have no flair
},
highLevel: {
type: mongoose.SchemaTypes.Number,
default: 0,
required: true,
},
msg: {
type: mongoose.SchemaTypes.String,
required: true,
},
type: {
type: mongoose.SchemaTypes.String,
required: true,
},
links: {
type: [linkSchema],
required: true,
},
});
module.exports = chatSchema;

View file

@ -1,55 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
//NPM Imports
const {mongoose} = require('mongoose');
/**
* DB Schema representing a single piece of media
*/
const mediaSchema = new mongoose.Schema({
title: {
type: mongoose.SchemaTypes.String,
required: true,
},
fileName: {
type: mongoose.SchemaTypes.String,
required: true,
},
url: {
type: mongoose.SchemaTypes.String,
required: true,
},
id: {
type: mongoose.SchemaTypes.String,
required: true,
},
type: {
type: mongoose.SchemaTypes.String,
required: true,
},
duration: {
type: mongoose.SchemaTypes.Number,
required: true,
},
},
{
discriminatorKey: 'status'
}
);
module.exports = mediaSchema;

View file

@ -1,83 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
//NPM Imports
const {mongoose} = require('mongoose');
//Local Imports
const mediaSchema = require('./mediaSchema');
const media = require('../../../app/channel/media/media');
/**
* DB Schema for documents represnting a piece of media held in a playlist
*/
const playlistMediaProperties = new mongoose.Schema({
uuid: {
type: mongoose.SchemaTypes.UUID,
required:true,
default: crypto.randomUUID()
}
},
{
discriminatorKey: 'status'
});
//Schema Middleware
/**
* Pre-save function for playlist meda, ensures unique UUID
*/
playlistMediaProperties.pre('save', async function (next){
//If the UUID was modified in anyway
if(this.isModified("uuid")){
//Throw that shit out and make a new one since it's probably either null or a leftover from some channel queue
this.uuid = crypto.randomUUID();
}
//Keep it moving
next();
});
//methods
/**
* Rehydrate to a full phat media object
* @returns {media} A full phat media object, re-hydrated from the DB
*/
playlistMediaProperties.methods.rehydrate = function(){
//Return item as a full phat, standard media object
return new media(
this.title,
this.fileName,
this.url,
this.id,
this.type,
this.duration
);
}
/**
* Dehydrate to minified flat network-friendly object
* @returns {Object} Network-Friendly Browser-Digestable object representing media from a playlist
*/
playlistMediaProperties.methods.dehydrate = function(){
return {
title: this.title,
url: this.url,
duration: this.duration,
uuid: this.uuid.toString()
};
}
module.exports = mediaSchema.discriminator('saved', playlistMediaProperties);

View file

@ -1,133 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
//NPM Imports
const {mongoose} = require('mongoose');
//Local Imports
const playlistMediaSchema = require('./playlistMediaSchema');
/**
* DB Schema for Documents representing playlists full of media
*/
const playlistSchema = new mongoose.Schema({
name: {
type: mongoose.SchemaTypes.String,
required: true,
},
media: [playlistMediaSchema],
defaultTitles:[{
type: mongoose.SchemaTypes.String,
required: false,
default: []
}]
});
//methods
/**
* Dehydrate to minified flat network-friendly object
* @returns {Object} Network-Friendly Browser-Digestable object representing media from a playlist
*/
playlistSchema.methods.dehydrate = function(){
//Create empty array to hold media
const mediaArray = [];
//Fill media array
for(let media of this.media){
mediaArray.push(media.dehydrate());
}
//return dehydrated playlist
return {
name: this.name,
media: mediaArray,
defaultTitles: this.defaultTitles
}
}
/**
* Add media to the given playlist Document
* @param {Array} mediaList - Array of media Objects to add to playlist
*/
playlistSchema.methods.addMedia = function(mediaList){
//For every piece of media in the list
for(let media of mediaList){
//Set media status schema discriminator
media.status = 'saved';
//Add the media to the playlist
this.media.push(media);
}
}
/**
* Gets Media from a playlist by UUID
* @param {String} uuid - UUID of media to pull
* @returns {media} media with matching UUID
*/
playlistSchema.methods.findMediaByUUID = function(uuid){
//For every piece of media in the current playlist
for(let media of this.media){
//If we found our match
if(media.uuid.toString() == uuid){
//return it
return media;
}
}
}
/**
* Deletes media from a given playlist
* @param {String} uuid - UUID of media to delete
*/
playlistSchema.methods.deleteMedia = function(uuid){
//Create new array to hold list of media to be kept
const keptMedia = [];
//For every piece of media in the current playlist
for(let media of this.media){
//It isn't the media to be deleted
if(media.uuid.toString() != uuid){
//Add it to the list to be kept
keptMedia.push(media);
}
}
//Set playlist media from keptMedia
this.media = keptMedia;
}
/**
* Pick title based on default title's list and media's given title
* @param {String} title - Title to use if there are no default titles.
* @returns {String} Chosen title based on result of function
*/
playlistSchema.methods.pickDefaultTitle = function(title){
//If we don't have default titles in this playlist
if(this.defaultTitles.length <= 0){
//If we wheren't handed an original title
if(title == null || title == ''){
return 'Unnamed Media'
}else{
return title;
}
}else{
//Grab a random default title and return it
return this.defaultTitles[Math.floor(Math.random() * this.defaultTitles.length)];
}
}
module.exports = playlistSchema;

View file

@ -1,72 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
//NPM Imports
const {mongoose} = require('mongoose');
//Local Imports
const mediaSchema = require('./mediaSchema');
const queuedMedia = require('../../../app/channel/media/queuedMedia');
/**
* DB Schema for documents representing a queued media object
*/
const queuedProperties = new mongoose.Schema({
startTime: {
type: mongoose.SchemaTypes.Number,
required: true,
},
startTimeStamp: {
type: mongoose.SchemaTypes.Number,
required: false,
},
earlyEnd: {
type: mongoose.SchemaTypes.Number,
required: false,
},
uuid: {
type: mongoose.SchemaTypes.UUID,
required: true,
}
},
{
discriminatorKey: 'status'
});
//Methods
/**
* Rehydrate to a full phat queued media object
* @returns {queuedMedia} A full phat queued media object, re-hydrated from the DB
*/
queuedProperties.methods.rehydrate = function(){
return new queuedMedia(
this.title,
this.fileName,
this.url,
this.id,
this.type,
this.duration,
//We don't save raw links that are stored seperate from the standard URL as they tend to expire.
undefined,
this.startTime,
this.startTimeStamp,
this.earlyEnd,
this.uuid.toString()
);
}
//Export schema under the 'queued' discriminator of mediaSchema
module.exports = mediaSchema.discriminator('queued', queuedProperties);

View file

@ -21,19 +21,12 @@ const {mongoose} = require('mongoose');
const defaultEmote = require("../../defaultEmotes.json");
const server = require('../server');
/**
* "Enum" for emote type property
*/
const typeEnum = ["image", "video"];
/**
* DB Schema for documents represnting site-wide emotes
*/
const emoteSchema = new mongoose.Schema({
name:{
type: mongoose.SchemaTypes.String,
required: true,
maxLength: 14,
required: true
},
link:{
type: mongoose.SchemaTypes.String,
@ -47,29 +40,19 @@ const emoteSchema = new mongoose.Schema({
}
});
/**
* Post-Save function, ensures all new emotes are broadcastes to actively connected clients
*/
//post-save function
emoteSchema.post('save', async function (next){
//Ensure the channel manager is actually up
if(server.channelManager != null){
//broadcast updated emotes
server.channelManager.broadcastSiteEmotes();
}
//broadcast updated emotes
server.channelManager.broadcastSiteEmotes();
});
/**
* Post-Delete function, ensures all deleted emotes are removed from actively connected clients
*/
//post-delete function (document not query)
emoteSchema.post('deleteOne', {document: true}, async function (next){
//broadcast updated emotes
server.channelManager.broadcastSiteEmotes();
});
//statics
/**
* Loads un-loaded emotes from defaultEmotes.json
*/
emoteSchema.statics.loadDefaults = async function(){
//Make sure registerEmote function is happy
const _this = this;
@ -100,10 +83,6 @@ emoteSchema.statics.loadDefaults = async function(){
}
}
/**
* Generates a network-friendly browser-digestable list of emotes
* @returns {Object} - network-friendly browser-digestable list of emotes
*/
emoteSchema.statics.getEmotes = async function(){
//Create an empty array to hold our emote list
const emoteList = [];

View file

@ -21,9 +21,6 @@ const {mongoose} = require('mongoose');
const permissionModel = require("./permissionSchema");
const defaultFlair = require("../../defaultFlair.json");
/**
* DB Schema for documents representing chat flair
*/
const flairSchema = new mongoose.Schema({
name:{
type: mongoose.SchemaTypes.String,
@ -41,9 +38,6 @@ const flairSchema = new mongoose.Schema({
}
});
/**
* Function which runs on server startup to load un-loaded flairs from defaultFlair.json into the DB
*/
flairSchema.statics.loadDefaults = async function(){
//Make sure registerFlair function is happy
const _this = this;

View file

@ -26,9 +26,6 @@ const {errorHandler} = require('../utils/loggerUtils');
//We could update all references but quite honestly I that would be uglier, this should have a copy too...
const rankEnum = channelPermissionSchema.statics.rankEnum;
/**
* DB Schema for the singular site-wide permission document
*/
const permissionSchema = new mongoose.Schema({
adminPanel: {
type: mongoose.SchemaTypes.String,
@ -54,12 +51,6 @@ const permissionSchema = new mongoose.Schema({
default: "admin",
required: true
},
resetToke: {
type: mongoose.SchemaTypes.String,
enum: rankEnum,
default: "admin",
required: true
},
editTokeCommands: {
type: mongoose.SchemaTypes.String,
enum: rankEnum,
@ -100,21 +91,11 @@ const permissionSchema = new mongoose.Schema({
type: channelPermissionSchema,
default: () => ({})
},
debug: {
type: mongoose.SchemaTypes.String,
enum: rankEnum,
default: "admin",
required: true
},
});
//Statics
permissionSchema.statics.rankEnum = rankEnum;
/**
* Returns the server's singular permission document from the DB
* @returns {Mongoose.Document} - The server's singular permission document
*/
permissionSchema.statics.getPerms = async function(){
//Not sure why 'this' didn't work from here when calling this, I'm assuming it's because I'm doing it from middleware
//which is probably binding shit to this function, either way this works :P
@ -139,21 +120,10 @@ permissionSchema.statics.getPerms = async function(){
}
}
/**
* Converts rank name to number
* @param {String} rank - rank to check
* @returns {Number} Rank level
*/
permissionSchema.statics.rankToNum = function(rank){
return rankEnum.indexOf(rank);
}
/**
* Check users rank against a given permission by username
* @param {String} user - Username of the user to check against a perm
* @param {String} perm - Permission to check user against
* @returns {Boolean} Whether or not the user is authorized for the permission in question
*/
permissionSchema.statics.permCheck = async function(user, perm){
//Check if the user is null
if(user != null){
@ -166,25 +136,31 @@ permissionSchema.statics.permCheck = async function(user, perm){
}
}
/**
* Syntatic sugar for perms.CheckByUserDoc so we don't have to get the document ourselves
* @param {Mongoose.Document} user - User document to check perms against
* @param {String} perm - Permission to check user against
* @returns {Boolean} Whether or not the user is authorized for the permission in question
*/
permissionSchema.statics.permCheckByUserDoc = async function(user, perm){
//Get permission list
const perms = await this.getPerms();
//Call the perm check method
return perms.permCheckByUserDoc(user, perm);
//Set user to anon rank if no rank was found for the given user
if(user == null || user.rank == null){
user ={
rank: "anon"
};
}
//Check if this permission exists
if(perms[perm] != null){
//if so get required rank as a number
requiredRank = this.rankToNum(perms[perm])
//if so get user rank as a number
userRank = user ? this.rankToNum(user.rank) : 0;
//return whether or not the user is equal to or higher than the required rank for this permission
return (userRank >= requiredRank);
}else{
//if not scream and shout
throw new Error(`Permission check '${perm}' not found!`);
}
}
/**
* Check users rank by a given permission by username
* @param {String} user - Username of the user to check against a perm
* @param {String} perm - Permission to check user against
* @returns {Boolean} Whether or not the user is authorized for the permission in question
*/
permissionSchema.statics.overrideCheck = async function(user, perm){
//Check if the user is null
if(user != null){
@ -197,26 +173,32 @@ permissionSchema.statics.overrideCheck = async function(user, perm){
}
}
/**
* Syntatic sugar for perms.overrideCheckByUSerDoc so we don't have to seperately get the perm doc
* Checks channel perm override against a given user by username
* @param {String} user - Username of the user to check against a perm
* @param {String} perm - Permission to check user against
* @returns {Boolean} Whether or not the user is authorized for the permission in question
*/
permissionSchema.statics.overrideCheckByUserDoc = async function(user, perm){
//Get permission list
const perms = await this.getPerms();
//Call the perm check method
return perms.overrideCheckByUserDoc(user, perm);
const perms = (await this.getPerms()).channelOverrides;
//Set user to anon rank if no rank was found for the given user
if(user == null || user.rank == null){
user ={
rank: "anon"
};
}
//Check if this permission exists
if(perms[perm] != null){
//if so get required rank as a number
requiredRank = this.rankToNum(perms[perm])
//if so get user rank as a number
userRank = user ? this.rankToNum(user.rank) : 0;
//return whether or not the user is equal to or higher than the required rank for this permission
return (userRank >= requiredRank);
}else{
//if not scream and shout
throw new Error(`Permission check '${perm}' not found!`);
}
}
//Middleware for rank checks
/**
* Configurable express middleware which checks user's request against a given permission
* @param {String} perm - Permission to check
* @returns {Function} Express middlewhere function with given permission injected into it
*/
permissionSchema.statics.reqPermCheck = function(perm){
return (req, res, next)=>{
permissionSchema.statics.permCheck(req.session.user, perm).then((access) => {
@ -229,93 +211,4 @@ permissionSchema.statics.reqPermCheck = function(perm){
}
}
//methods
//these are good to have even for single-doc collections since we can loop through them without finding them in the database each time
/**
* Checks permission against a single user by document
* @param {Mongoose.Document} userDB - User doc to rank check against
* @param {String} perm - Permission to check user doc against
* @returns {Boolean} True if authorized
*/
permissionSchema.methods.permCheckByUserDoc = function(userDB, perm){
//Set user to anon rank if no rank was found for the given user
if(userDB == null || userDB.rank == null){
userDB ={
rank: "anon"
};
}
//Check if this permission exists
if(this[perm] != null){
//if so get required rank as a number
requiredRank = this.model().rankToNum(this[perm])
//if so get user rank as a number
userRank = userDB ? this.model().rankToNum(userDB.rank) : 0;
//return whether or not the user is equal to or higher than the required rank for this permission
return (userRank >= requiredRank);
}else{
//if not scream and shout
throw loggerUtils.exceptionSmith(`Permission check '${perm}' not found!`, "Validation");
}
}
/**
* Checks channel override permission against a single user by document
* @param {Mongoose.Document} userDB - User doc to rank check against
* @param {String} perm - Channel Override Permission to check user doc against
* @returns {Boolean} True if authorized
*/
permissionSchema.methods.overrideCheckByUserDoc = function(userDB, perm){
//Set user to anon rank if no rank was found for the given user
if(userDB == null || userDB.rank == null){
userDB ={
rank: "anon"
};
}
//Check if this permission exists
if(this.channelOverrides[perm] != null){
//if so get required rank as a number
requiredRank = this.model().rankToNum(this.channelOverrides[perm])
//if so get user rank as a number
userRank = userDB ? this.model().rankToNum(userDB.rank) : 0;
//return whether or not the user is equal to or higher than the required rank for this permission
return (userRank >= requiredRank);
}else{
//if not scream and shout
throw loggerUtils.exceptionSmith(`Permission check '${perm}' not found!`, "validation");
}
}
/**
* Returns entire permission map marked with booleans
* @param {Mongoose.Document} userDB - User Doc to generate perm map against
* @returns {Map} Permission map containing booleans for each permission's authorization for a given user doc
*/
permissionSchema.methods.getPermMapByUserDoc = function(userDB){
//Pull permissions keys
let permTree = this.schema.tree;
let overrideTree = channelPermissionSchema.tree;
let permMap = new Map();
let overrideMap = new Map();
//For each object in the temporary permissions object
for(let perm of Object.keys(permTree)){
//Check the current permission
permMap.set(perm, this.permCheckByUserDoc(userDB, perm));
}
//For each object in the temporary permissions object
for(let perm of Object.keys(overrideTree)){
//Check the current permission
overrideMap.set(perm, this.overrideCheckByUserDoc(userDB, perm));
}
//return the auto-generated schema
return {
site: permMap,
channelOverrides: overrideMap
}
}
module.exports = mongoose.model("permissions", permissionSchema);

View file

@ -19,12 +19,7 @@ const {mongoose} = require('mongoose');
//Local Imports
const config = require('./../../config.json');
const tokeSchema = require('./tokebot/tokeSchema');
const loggerUtils = require('./../utils/loggerUtils');
/**
* DB Schema for single document for keeping track of server stats
*/
const statSchema = new mongoose.Schema({
//This does NOT handle deleted accounts/channels. Use userModel.estimatedDocumentCount() for number of active users.
userCount: {
@ -46,24 +41,26 @@ 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
*/
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?)
@ -81,9 +78,6 @@ statSchema.statics.getStats = async function(){
}
}
/**
* Increments Lunach count upon server launch and prints out amount of launches since server initialization
*/
statSchema.statics.incrementLaunchCount = async function(){
//get our statistics document
const stats = await this.getStats();
@ -92,17 +86,11 @@ statSchema.statics.incrementLaunchCount = async function(){
stats.launchCount++;
stats.save();
//Cache first launch
this.firstLaunch = stats.firstLaunch;
//print bootup message to console.
loggerUtils.welcomeWagon(stats.launchCount, stats.firstLaunch, tokeSchema.count);
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}.`);
}
/**
* Increments user count upon new user registration
* @returns {Number} Number of users before count was incremented
*/
statSchema.statics.incrementUserCount = async function(){
//get our statistics document
const stats = await this.getStats();
@ -117,10 +105,6 @@ statSchema.statics.incrementUserCount = async function(){
return oldCount;
}
/**
* Increments channel count upon new channel registration
* @returns {Number} Number of channels before count was incremented
*/
statSchema.statics.incrementChannelCount = async function(){
//get our statistics document
const stats = await this.getStats();
@ -135,4 +119,51 @@ statSchema.statics.incrementChannelCount = async function(){
return oldCount;
}
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();
}
statSchema.statics.getTokeCount = async function(){
//get stats doc
const stats = await this.getStats();
//return toke count
return stats.tokes.length;
}
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);
}
});
});
//return the toke command count
return count;
}
module.exports = mongoose.model("statistics", statSchema);

View file

@ -20,11 +20,7 @@ const {mongoose} = require('mongoose');
//Local Imports
const defaultTokes = require("../../../defaultTokes.json");
const server = require('../../server');
const loggerUtils = require('../../utils/loggerUtils');
/**
* Mongoose Schema representing a toke command
*/
const tokeCommandSchema = new mongoose.Schema({
command:{
type: mongoose.SchemaTypes.String,
@ -32,25 +28,15 @@ const tokeCommandSchema = new mongoose.Schema({
}
});
/**
* Pre-Save middleware, ensures tokebot receives all new toke commands
*/
tokeCommandSchema.pre('save', async function (next){
//if the channel manager, chat handler, and chat post-processor are all loaded up...
//if the command was changed
if(this.isModified("command")){
if(server.channelManager != null &&
server.channelManager.chatHandler != null &&
server.channelManager.chatHandler.chatPreprocessor != null){
//Get server tokebot object
const tokebot = server.channelManager.chatHandler.commandPreprocessor.tokebot;
//Get server tokebot object
const tokebot = server.channelManager.chatHandler.chatPreprocessor.tokebot;
//If tokebot is up and running
if(tokebot != null && tokebot.tokeCommands != null){
//Pop the command on to the end
tokebot.tokeCommands.push(this.command);
}
}
//Pop the command on to the end
tokebot.tokeCommands.push(this.command);
}
@ -58,12 +44,9 @@ tokeCommandSchema.pre('save', async function (next){
next();
});
/**
* Pre-Delete middleware, ensures tokebot removes all old toke commands
*/
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.chatPreprocessor.tokebot;
const tokebot = server.channelManager.chatHandler.commandPreprocessor.tokebot;
//Get the index of the command within tokeCommand and splice it out
tokebot.tokeCommands.splice(tokebot.tokeCommands.indexOf(this.command),1);
@ -72,10 +55,6 @@ tokeCommandSchema.pre('deleteOne', {document: true}, async function (next){
next();
});
/**
* Pulls command strings from DB and reports back
* @returns {Array} Array of toke commands pulled from the DB
*/
tokeCommandSchema.statics.getCommandStrings = async function(){
//Get all toke commands in the DB
const tokeDB = await this.find({});
@ -92,16 +71,12 @@ tokeCommandSchema.statics.getCommandStrings = async function(){
return tokeArray;
}
/**
* Loads default tokes into the DB from flat file upon launch
*/
tokeCommandSchema.statics.loadDefaults = async function(){
//Make sure registerToke function is happy
const _this = this;
//Ensure default comes first (.bind(this) doesn't seem to work here...)
await registerToke(defaultTokes.default);
//For each entry in the defaultTokes.json file
defaultTokes.array.forEach(registerToke);
@ -118,9 +93,9 @@ tokeCommandSchema.statics.loadDefaults = async function(){
}catch(err){
if(toke != null){
loggerUtils.dumpError(err);
console.log(`Error loading toke command: '!${toke}'`);
}else{
loggerUtils.consoleWarn("Error, null toke!");
console.log("Error, null toke!");
}
}
}

View file

@ -1,219 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
//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 ${this.count} tokes, averaging ${(this.commandCount/this.count).toFixed(3)} tokers per toke.`);
}
}
/**
* 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);

View file

@ -30,14 +30,8 @@ const {mongoose} = require('mongoose');
const hashUtil = require('../../utils/hashUtils');
const mailUtils = require('../../utils/mailUtils');
/**
* Email change token retention time
*/
const daysToExpire = 7;
/**
* DB Schema for Document representing a single email change request
*/
const emailChangeSchema = new mongoose.Schema({
user: {
type: mongoose.SchemaTypes.ObjectID,
@ -52,7 +46,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(32).toString('hex')}
default: ()=>{return crypto.randomBytes(16).toString('hex')}
},
ipHash: {
type: mongoose.SchemaTypes.String,
@ -66,9 +60,7 @@ const emailChangeSchema = new mongoose.Schema({
});
/**
* Pre-Save function, ensures IP's are hashed and previous requests are deleted upon request creation
*/
//Presave function
emailChangeSchema.pre('save', async function (next){
//If we're saving an ip
if(this.isModified('ipHash')){
@ -84,31 +76,22 @@ emailChangeSchema.pre('save', async function (next){
next();
});
/**
* Schedulable function for processing expired email change requests
*/
//statics
emailChangeSchema.statics.processExpiredRequests = async function(){
//Pull all requests 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 requestDB = 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 requestIndex in requestDB){
//Pull request from requestDB by index
const request = requestDB[requestIndex];
requestDB.forEach(async (request) => {
//If the request hasn't been processed and it's been expired
if(request.getDaysUntilExpiration() <= 0){
//Delete the request
await this.deleteOne({_id: request._id});
}
}
});
}
/**
* Consumes email change token, changing email address on a given user
*/
//methods
emailChangeSchema.methods.consume = async function(){
//Populate the user reference
await this.populate('user');
@ -149,13 +132,9 @@ emailChangeSchema.methods.consume = async function(){
}
/**
* Generates email change URL from a given token
* @returns {String} Email change URL generated from token
*/
emailChangeSchema.methods.getChangeURL = function(){
//Check for default port based on protocol
if((config.protocol == 'http' && config.port == 80) || (config.protocol == 'https' && config.port == 443 || config.proxied)){
if((config.protocol == 'http' && config.port == 80) || (config.protocol == 'https' && config.port == 443)){
//Return path
return `${config.protocol}://${config.domain}/emailChange?token=${this.token}`;
}else{
@ -164,10 +143,6 @@ emailChangeSchema.methods.getChangeURL = function(){
}
}
/**
* Calculates days until token expiration
* @returns {Number} Days until token expiration
*/
emailChangeSchema.methods.getDaysUntilExpiration = function(){
//Get request date
const expirationDate = new Date(this.date);

View file

@ -1,389 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
//Node Imports
const fs = require('node:fs/promises');
//NPM Imports
const {mongoose} = require('mongoose');
const validator = require('validator');
//local imports
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
*/
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.Number,
default: 0,
}
});
//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(){
try{
//If migration is disabled
if(!config.migrate){
await tokeModel.dropLegacyTokes();
//BAIL!
return;
}
//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');
//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
//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
await this.ingestTokeMaps(tokeMaps);
//Pass toke logs over to the stat model for further ingestion
await tokeModel.ingestLegacyTokes(tokeLogs);
loggerUtils.consoleWarn(`Legacy Server Migration Completed at: ${new Date().toLocaleString()}`);
}catch(err){
return loggerUtils.localExceptionHandler(err);
}
}
/**
* 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){
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;
}
//Pull rank, dropping over-ranked users down to current enum length
let rank = Math.min(Math.max(0, profileArray[3]), permissionModel.rankEnum.length - 1);
//If this user was a mod on the old site
if(rank == 2){
//Set them up as a mod here
rank = permissionModel.rankEnum.length - 2;
}
//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,
email: validator.normalizeEmail(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 : 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
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);
}
}
/**
* 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);
}
}
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());
}
}
}
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);
}
//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} passConfirm - Confirmation for the new pass
*/
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 && validator.isEmail(this.email)){
//Generate new email change request
const requestDB = await emailChangeModel.create({user: newUser._id, newEmail: this.email, ipHash: ip});
//Send tokenized confirmation email
mailUtils.sendAddressVerification(requestDB, newUser, this.email, false, true);
}
//Nuke out miration entry
await this.deleteOne();
}
module.exports = mongoose.model("migration", migrationSchema);

View file

@ -27,17 +27,10 @@ const crypto = require("node:crypto");
const {mongoose} = require('mongoose');
//Local Imports
const hashUtil = require('../../utils/hashUtils.js');
const loggerUtils = require('../../utils/loggerUtils.js')
const hashUtil = require('../../utils/hashUtils');
/**
* Password reset token retention time
*/
const daysToExpire = 7;
/**
* DB Schema for documents containing a single expiring password reset token
*/
const passwordResetSchema = new mongoose.Schema({
user: {
type: mongoose.SchemaTypes.ObjectID,
@ -48,7 +41,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(32).toString('hex')}
default: ()=>{return crypto.randomBytes(16).toString('hex')}
},
ipHash: {
type: mongoose.SchemaTypes.String,
@ -62,9 +55,7 @@ const passwordResetSchema = new mongoose.Schema({
});
/**
* Pre-save function, ensures IP's are hashed before saving
*/
//Presave function
passwordResetSchema.pre('save', async function (next){
//If we're saving an ip
if(this.isModified('ipHash')){
@ -72,47 +63,29 @@ passwordResetSchema.pre('save', async function (next){
this.ipHash = hashUtil.hashIP(this.ipHash);
}
//If the user was modified (usually only on document initialization)
if(this.isModified('user')){
//Delete previous requests for the given user
const requests = await this.model().deleteMany({user: this.user._id});
}
next();
});
/**
* Schedulable function for processing expired reset requests
*/
//statics
passwordResetSchema.statics.processExpiredRequests = async function(){
//Pull all requests 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 requestDB = 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 requestIndex in requestDB){
//pull request from requestDB by index
const request = requestDB[requestIndex];
requestDB.forEach(async (request) => {
//If the request hasn't been processed and it's been expired
if(request.getDaysUntilExpiration() <= 0){
//Delete the request
await this.deleteOne({_id: request._id});
}
}
});
}
//methods
/**
* Resets password and consumes token
* @param {String} pass - New password to set
* @param {String} confirmPass - Confirmation String to ensure new pass is correct
*/
passwordResetSchema.methods.consume = async function(pass, confirmPass){
//Check confirmation pass
if(pass != confirmPass){
throw loggerUtils.exceptionSmith("Confirmation password does not match!", "validation");
throw new Error("Confirmation password does not match!");
}
//Populate the user reference
@ -131,13 +104,9 @@ passwordResetSchema.methods.consume = async function(pass, confirmPass){
await this.deleteOne();
}
/**
* Generates password reset URL off of the token object
* @returns {String} Reset URL
*/
passwordResetSchema.methods.getResetURL = function(){
//Check for default port based on protocol
if((config.protocol == 'http' && config.port == 80) || (config.protocol == 'https' && config.port == 443) || config.proxied){
if((config.protocol == 'http' && config.port == 80) || (config.protocol == 'https' && config.port == 443)){
//Return path
return `${config.protocol}://${config.domain}/passwordReset?token=${this.token}`;
}else{
@ -146,10 +115,6 @@ passwordResetSchema.methods.getResetURL = function(){
}
}
/**
* Returns number of days until token expiration
* @returns {Number} Number of days until token expiration
*/
passwordResetSchema.methods.getDaysUntilExpiration = function(){
//Get request date
const expirationDate = new Date(this.date);

View file

@ -1,198 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
//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 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){
//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 = await hashUtil.hashRememberMeToken(this.token);
}
//All is good, continue on saving.
next();
});
//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
//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
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 for a non-login reason
}catch(err){
return loggerUtils.localExceptionHandler(err);
}
}
/**
* 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();
}
//Populate the user field
await tokenDB.populate('user');
//Check our password is correct
if(await tokenDB.checkToken(token)){
//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
badLogin();
}
//standardize bad login response so it's unknown which is bad for security reasons.
function badLogin(){
throw loggerUtils.exceptionSmith(failLine, "unauthorized");
}
}
/**
* 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);

View file

@ -18,13 +18,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
const {mongoose} = require('mongoose');
//Local Imports
const hashUtil = require('../../utils/hashUtils.js');
const {userModel} = require('./userSchema.js');
const loggerUtils = require('../../utils/loggerUtils.js');
const hashUtil = require('../../utils/hashUtils');
const {userModel} = require('./userSchema');
/**
* DB Schema for Documents representing a single user's ban
*/
const userBanSchema = new mongoose.Schema({
user: {
type: mongoose.SchemaTypes.ObjectID,
@ -67,12 +63,9 @@ const userBanSchema = new mongoose.Schema({
}
});
/**
* Checks ban by IP
* @param {String} ip - IP Address check for bans
* @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
@ -104,7 +97,7 @@ userBanSchema.statics.checkBanByIP = async function(ip){
const curHash = ban.ips.hashed[ipIndex];
//Check the current hash against the given hash
if(hashUtil.compareIPHash(ip, curHash)){
if(ipHash == curHash){
//If it matches we found the ban
foundBan = ban;
@ -133,11 +126,6 @@ userBanSchema.statics.checkBanByIP = async function(ip){
return foundBan;
}
/**
* Checks for bans by user DB doc
* @param {Mongoose.Document} userDB - User Doc to check
* @returns {Mongoose.Document} Found ban document for given user doc
*/
userBanSchema.statics.checkBanByUserDoc = async function(userDB){
const banDB = await this.find({});
var foundBan = null;
@ -167,21 +155,11 @@ userBanSchema.statics.checkBanByUserDoc = async function(userDB){
return foundBan;
}
/**
* Checks for ban by username
* @param {String} user - User to check for bans
* @returns {Mongoose.Document} Found User Ban DB Document
*/
userBanSchema.statics.checkBan = async function(user){
const userDB = await userModel.findOne({user: user.user});
return this.checkBanByUserDoc(userDB);
}
/**
* Looks through processed bans by user
* @param {String} user - user to check against for bans
* @returns {Mongoose.Document} Spent User Ban Document
*/
userBanSchema.statics.checkProcessedBans = async function(user){
//Pull banlist and create empty variable to hold any found ban
const banDB = await this.find({});
@ -203,32 +181,24 @@ userBanSchema.statics.checkProcessedBans = async function(user){
return foundBan;
}
/**
* Bans a given user by their user Document
* @param {Mongoose.Document} userDB - DB Doc of the user to ban
* @param {Boolean} permanent - Whether or not it's permanant
* @param {Number} expirationDays - Days to expire
* @param {Boolean} ipBan - Whether or not we're banning by IP
* @returns {Mongoose.Document} A freshly created User Ban DB Document :)
*/
userBanSchema.statics.banByUserDoc = async function(userDB, permanent, expirationDays, ipBan = false){
//Prevent missing users
if(userDB == null){
throw loggerUtils.exceptionSmith("User not found", "validation");
throw new Error("User not found")
}
//Ensure the user isn't already banned
if(await this.checkBanByUserDoc(userDB) != null){
throw loggerUtils.exceptionSmith("User already banned", "validation");
throw new Error("User already banned");
}
//Verify time to expire/delete depending on action
if(expirationDays < 0){
throw loggerUtils.exceptionSmith("Expiration Days must be a positive integer!", "validation");
throw new Error("Expiration Days must be a positive integer!");
}else if(expirationDays < 30 && permanent){
throw loggerUtils.exceptionSmith("Permanent bans must be given at least 30 days before automatic account deletion!", "validation");
throw new Error("Permanent bans must be given at least 30 days before automatic account deletion!");
}else if(expirationDays > 185){
throw loggerUtils.exceptionSmith("Expiration/Deletion date cannot be longer than half a year out from the original ban date.", "validation");
throw new Error("Expiration/Deletion date cannot be longer than half a year out from the original ban date.");
}
await banSessions(userDB);
@ -287,35 +257,22 @@ userBanSchema.statics.banByUserDoc = async function(userDB, permanent, expiratio
}
}
/**
* Bans user by username
* @param {String} user - Username of user to ban
* @param {Boolean} permanent - Whether or not it's permanant
* @param {Number} expirationDays - Days to expire
* @param {Boolean} ipBan - Whether or not we're banning by IP
* @returns {Mongoose.Document} A freshly created User Ban DB Document :)
*/
userBanSchema.statics.ban = async function(user, permanent, expirationDays, ipBan){
const userDB = await userModel.findOne({user: user.user});
return this.banByUserDoc(userDB, permanent, expirationDays, ipBan);
}
/**
* Unbans users by user doc
* @param {Mongoose.Document} userDB - User DB Document to unban
* @returns {Mongoose.Document} Old, deleted ban document
*/
userBanSchema.statics.unbanByUserDoc = async function(userDB){
//Prevent missing users
if(userDB == null){
throw loggerUtils.exceptionSmith("User not found", "validation");
throw new Error("User not found")
}
const banDB = await this.checkBanByUserDoc(userDB);
if(!banDB){
throw loggerUtils.exceptionSmith("User already un-banned", "validation");
throw new Error("User already un-banned");
}
//Use _id in-case mongoose wants to be a cunt
@ -323,28 +280,17 @@ userBanSchema.statics.unbanByUserDoc = async function(userDB){
return oldBan;
}
/**
* Unban deleted user
* Can't bring back accounts, but will re-allow re-use of old usernames, and new accounts/connections from banned IP's
* @param {String} user - Username of deleted account to unban
* @returns {Mongoose.Document} Old, deleted ban document
*/
userBanSchema.statics.unbanDeleted = async function(user){
const banDB = await this.checkProcessedBans(user);
if(!banDB){
throw loggerUtils.exceptionSmith("User already un-banned", "validation");
throw new Error("User already un-banned");
}
const oldBan = await this.deleteOne({_id: banDB._id});
return oldBan;
}
/**
* Unbans user by username
* @param {String} user - Username of user to unban
* @returns Old, deleted ban document
*/
userBanSchema.statics.unban = async function(user){
//Find user in DB
const userDB = await userModel.findOne({user: user.user});
@ -359,10 +305,6 @@ userBanSchema.statics.unban = async function(user){
}
}
/**
* Generates Network-Friendly Browser-Digestable list of bans for the admin panel
* @returns {Object} Network-Friendly Browser-Digestable list of bans for the admin panel
*/
userBanSchema.statics.getBans = async function(){
//Get the ban, populating users and alts
const banDB = await this.find({}).populate('user').populate('alts');
@ -409,18 +351,11 @@ userBanSchema.statics.getBans = async function(){
return bans;
}
/**
* Scheduable function for processing expired user bans
*/
userBanSchema.statics.processExpiredBans = async function(){
//Channel ban expirations may vary so there's no way to search for expired bans
const banDB = await this.find({});
//Firem all off all at once seperately without waiting for one another
for(let banIndex in banDB){
//Pull ban from banlist by index
const ban = banDB[banIndex];
banDB.forEach(async (ban) => {
//This ban was already processed, and it's user has been deleted. There is no more to be done...
if(ban.user == null){
return;
@ -458,14 +393,10 @@ userBanSchema.statics.processExpiredBans = async function(){
await this.deleteOne({_id: ban._id});
}
}
}
});
}
//methods
/**
* Calculates days until ban expiration
* @returns {Number} Days until ban expiration
*/
userBanSchema.methods.getDaysUntilExpiration = function(){
//Get ban date
const expirationDate = new Date(this.banDate);

View file

@ -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 <https://www.gnu.org/licenses/>.*/
//Node Imports
const { profile } = require('console');
//NPM Imports
const {mongoose} = require('mongoose');
@ -22,22 +25,15 @@ 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');
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');
const loggerUtils = require('../../utils/loggerUtils')
/**
* Mongoose Schema for a document representing a single canopy user
*/
const userSchema = new mongoose.Schema({
id: {
type: mongoose.SchemaTypes.Number,
@ -62,11 +58,6 @@ const userSchema = new mongoose.Schema({
required: true,
default: new Date()
},
lastActive: {
type: mongoose.SchemaTypes.Date,
required: true,
default: new Date(0)
},
rank: {
type: mongoose.SchemaTypes.String,
required: true,
@ -81,27 +72,25 @@ const userSchema = new mongoose.Schema({
img: {
type: mongoose.SchemaTypes.String,
required: true,
default: "/nonfree/johnny.png"
default: "/img/johnny.png"
},
//These should be larger than validator values to make room for escaped characters
bio: {
type: mongoose.SchemaTypes.String,
required: true,
//Calculate max length by the validator max length and the size of an escaped character
maxLength: 1000 * 6,
maxLength: 2000,
default: "Bio not set!"
},
pronouns:{
type: mongoose.SchemaTypes.String,
optional: true,
//Calculate max length by the validator max length and the size of an escaped character
maxLength: 15 * 6,
maxLength: 50,
default: ""
},
signature: {
type: mongoose.SchemaTypes.String,
required: true,
//Calculate max length by the validator max length and the size of an escaped character
maxLength: 25 * 6,
maxLength: 300,
default: "Signature not set!"
},
highLevel: {
@ -152,20 +141,16 @@ const userSchema = new mongoose.Schema({
alts:[{
type: mongoose.SchemaTypes.ObjectID,
ref: "user"
}],
playlists: [playlistSchema]
}]
});
//This is one of those places where you really DON'T want to use an arrow function over an anonymous one!
/**
* Pre-Save function for user document, handles password hashing, flair updates, emote refreshes, and kills sessions upon rank change
*/
userSchema.pre('save', async function (next){
//If the password was changed
if(this.isModified("pass")){
//Hash that sunnovabitch, no questions asked.
this.pass = await hashUtil.hashPassword(this.pass);
this.pass = hashUtil.hashPassword(this.pass);
}
//If the flair was changed
@ -174,7 +159,7 @@ userSchema.pre('save', async function (next){
await this.populate('flair');
if(permissionModel.rankToNum(this.rank) < permissionModel.rankToNum(this.flair.rank)){
throw loggerUtils.exceptionSmith(`User '${this.user}' does not have a high enough rank for flair '${this.flair.displayName}'!`, "unauthorized");
throw new Error(`User '${this.user}' does not have a high enough rank for flair '${this.flair.displayName}'!`);
}
}
@ -186,11 +171,6 @@ 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.");
}
@ -208,9 +188,7 @@ userSchema.pre('save', async function (next){
next();
});
/**
* Pre-Delete function for user accounts, drops connections and rips it self out from alt accounts
*/
//post-delete function (document not query)
userSchema.post('deleteOne', {document: true}, async function (){
//Kill any active sessions
await this.killAllSessions("If you're seeing this, your account has been deleted. So long, and thanks for all the fish! <3");
@ -232,55 +210,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
* @param {String} ip - IP Address of connection registering the account
*/
userSchema.statics.register = async function(userObj, ip){
//Pull values from user object
const {user, pass, passConfirm, email} = userObj;
//Check password confirmation matches
if(pass == passConfirm){
//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(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());
}
var userDB = await this.findOne({user: new RegExp(user, 'i')});
//If the user is found or someones trying to impersonate tokeboi
if(userDB || needsMigration || user.toLowerCase() == "tokebot"){
throw loggerUtils.exceptionSmith("User name/email already taken!", "validation");
if(userDB || user.toLowerCase() == "tokebot"){
throw new Error("User name/email already taken!");
}else{
//Increment the user count, pulling the id to tattoo to the user
const id = await statModel.incrementUserCount();
@ -293,33 +234,24 @@ 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});
//Send tokenized confirmation link to users email address
mailUtil.sendAddressVerification(requestDB, newUser, email, true);
await mailUtil.sendAddressVerification(requestDB, newUser, email)
}
}
}else{
throw loggerUtils.exceptionSmith("Confirmation password doesn't match!", "validation");
throw new Error("Confirmation password doesn't match!");
}
}
/**
* Authenticates a username and password pair
* @param {String} user - Username of account to login as
* @param {String} pass - Password to authenticat account
* @param {String} failLine - Line to paste into custom error upon login failure
* @returns {Mongoose.Document} - User DB Document upon success
*/
userSchema.statics.authenticate = async function(user, pass, failLine = "Bad Username or Password."){
//check for missing pass
if(!user || !pass){
throw loggerUtils.exceptionSmith("Missing user/pass.", "validation");
throw new Error("Missing user/pass.");
}
//get the user if it exists
const userDB = await this.findOne({ user: new RegExp(user, 'i')});
const userDB = await this.findOne({ user });
//if not scream and shout
if(!userDB){
@ -327,25 +259,19 @@ userSchema.statics.authenticate = async function(user, pass, failLine = "Bad Use
}
//Check our password is correct
if(await userDB.checkPass(pass)){
if(userDB.checkPass(pass)){
return userDB;
}else{
//if not scream and shout
badLogin();
}
//standardize bad login response so it's unknown which is bad for security reasons.
//standardize bad login response so it's unknowin which is bad for security reasons.
function badLogin(){
throw loggerUtils.exceptionSmith(failLine, "unauthorized");
throw new Error(failLine);
}
}
/**
* Gets profile by username
* @param {String} user - name of user to find
* @param {Boolean} includeEmail - Whether or not to include email in the final result
* @returns {Object} A network-friendly browser-digestable Object representing a single user profile
*/
userSchema.statics.findProfile = async function(user, includeEmail = false){
//Catch null users
if(user == null || user.user == null){
@ -356,11 +282,10 @@ userSchema.statics.findProfile = async function(user, includeEmail = false){
const profile = {
id: -420,
user: "Tokebot",
//Look ma, no DB calls!
date: statModel.firstLaunch,
tokes: tokeModel.tokeMap,
tokeCount: tokeModel.count,
img: "/nonfree/johnny.png",
date: (await statModel.getStats()).firstLaunch,
tokes: await statModel.getTokeCommandCounts(),
tokeCount: await statModel.getTokeCount(),
img: "/img/johnny.png",
signature: "!TOKE",
bio: "!TOKE OR DIE!"
};
@ -382,10 +307,6 @@ userSchema.statics.findProfile = async function(user, includeEmail = false){
}
/**
* Tattoos a single toke callout to all the users within it
* @param {Map} tokemap - Map containing list of users and the toke command they used to join the toke
*/
userSchema.statics.tattooToke = function(tokemap){
//For each toke, asynchronously:
tokemap.forEach(async (toke, user) => {
@ -411,22 +332,10 @@ userSchema.statics.tattooToke = function(tokemap){
//Save the user doc to the database
await userDB.save();
//Would rather do this inside of tokebot but then our boi would have to wait for DB or pass a nasty-looking callback function
//Crawl through active connections
server.channelManager.crawlConnections(userDB.user, (conn)=>{
//Update used toke list
conn.sendUsedTokes(userDB);
});
}
});
}
/**
* Acquires a list of the entire userbase
* @param {Boolean} fullList - Determines whether or not we're listing rank and email
* @returns {Array} Array of objects containing user metadata
*/
userSchema.statics.getUserList = async function(fullList = false){
var userList = [];
//Get all of our users
@ -461,51 +370,35 @@ userSchema.statics.getUserList = async function(fullList = false){
return userList;
}
/**
* Process and Deletes Aged IP Records
*/
userSchema.statics.processAgedIPRecords = async function(){
//Pull full userlist
const users = await this.find({});
//Not a fan of iterating through the DB but there doesn't seem to be a way to search for a doc based on the properties of it's subdoc
for(let userIndex in users){
//Pull user record from users by index
const userDB = users[userIndex];
//for every user
users.forEach((userDB) => {
//For every recent ip within the user
for(let recordIndex in userDB.recentIPs){
//Pull record from recent IPs by index
const record = userDB.recentIPs[recordIndex];
userDB.recentIPs.forEach((record, recordI) => {
//Check how long it's been since we've last seen the IP
const daysSinceLastUse = ((new Date() - record.lastLog) / (1000 * 60 * 60 * 24)).toFixed(1);
//If it's been more than a week
if(daysSinceLastUse >= 7){
//Splice out the IP record
userDB.recentIPs.splice(recordIndex, 1);
userDB.recentIPs.splice(recordI, 1);
//No reason to wait on this since we're done with this user
await userDB.save();
userDB.save();
}
}
}
});
});
}
//methods
/**
* Checks password against a user document
* @param {String} pass - Password to authenticate
* @returns {Boolean} True if authenticated
*/
userSchema.methods.checkPass = async function(pass){
return await hashUtil.comparePassword(pass, this.pass)
userSchema.methods.checkPass = function(pass){
return hashUtil.comparePassword(pass, this.pass)
}
/**
* Lists authenticated sessions for a given user document
* @returns {Promise} Promise containing an array of active user sessions for a given user
*/
userSchema.methods.getAuthenticatedSessions = async function(){
var returnArr = [];
@ -536,18 +429,12 @@ userSchema.methods.getAuthenticatedSessions = async function(){
}
/**
* Generates a network-friendly browser-digestable profile object for a sepcific user document
* @param {Boolean} includeEmail - Whether or not to include the email address in the complete profile object
* @returns {Object} Network-Friendly Browser-Digestable object containing profile metadata
*/
userSchema.methods.getProfile = function(includeEmail = false){
//Create profile hashtable
const profile = {
id: this.id,
user: this.user,
date: this.date,
lastActive: this.lastActive,
tokes: this.tokes,
tokeCount: this.getTokeCount(),
img: this.img,
@ -565,10 +452,6 @@ userSchema.methods.getProfile = function(includeEmail = false){
return profile;
}
/**
* Gets list of profiles of alt accounts related to the given user document
* @returns {Array} List of alternate profiles
*/
userSchema.methods.getAltProfiles = async function(){
//Create an empty list to hold alt profiles
const profileList = [];
@ -586,11 +469,6 @@ userSchema.methods.getAltProfiles = async function(){
return profileList;
}
/**
* Sets flair for current user documetn
* @param {String} flair - flair to set
* @returns {Mongoose.Document} returns found flair document from DB
*/
userSchema.methods.setFlair = async function(flair){
//Find flair by name
const flairDB = await flairModel.findOne({name: flair});
@ -602,10 +480,6 @@ userSchema.methods.setFlair = async function(flair){
return flairDB;
}
/**
* Gets number of tokes for a given user document
* @returns {Number} Number of tokes recorded for the given user document
*/
userSchema.methods.getTokeCount = function(){
//Set tokeCount to 0
var tokeCount = 0;
@ -620,10 +494,6 @@ userSchema.methods.getTokeCount = function(){
return tokeCount;
}
/**
* Gets number of emotes for a given user document
* @returns {Array} Array of objects representing emotes for the given user document
*/
userSchema.methods.getEmotes = function(){
//Create an empty array to hold our emote list
const emoteList = [];
@ -642,10 +512,6 @@ userSchema.methods.getEmotes = function(){
return emoteList;
}
/**
* Deletes an emote from the user Document
* @param {String} name - Name of emote to delete
*/
userSchema.methods.deleteEmote = async function(name){
//Get index by emote name
const emoteIndex = this.emotes.findIndex(checkName);
@ -662,81 +528,6 @@ userSchema.methods.deleteEmote = async function(name){
}
}
/**
* Gets list of user playlists
* @returns {Array} Array of objects represnting a playlist containing media objects
*/
userSchema.methods.getPlaylists = function(){
//Create an empty array to hold our emote list
const playlists = [];
//For each channel emote
for(let playlist of this.playlists){
//Push an object with select information from the emote to the emote list
playlists.push(playlist.dehydrate());
}
//return the emote list
return playlists;
}
/**
* Iterates through user playlists and calls a given callback function against them
* @param {Function} cb - Callback function to call against user playlists
*/
userSchema.methods.playlistCrawl = function(cb){
for(let listIndex in this.playlists){
//Grab the associated playlist
playlist = this.playlists[listIndex];
//Call the callback with the playlist and list index as arguments
cb(playlist, listIndex);
}
}
/**
* Returns playlist by name
* @param {String} name - Playlist to get by name
* @returns {Object} Object representing the requested playlist
*/
userSchema.methods.getPlaylistByName = function(name){
//Create null value to hold our found playlist
let foundPlaylist = null;
//Crawl through active playlists
this.playlistCrawl((playlist, listIndex) => {
//If we found a match based on name
if(playlist.name == name){
//Keep it
foundPlaylist = playlist;
//Pass down the list index
foundPlaylist.listIndex = listIndex;
}
});
//return the given playlist
return foundPlaylist;
}
/**
* Deletes a playlist from the user document by name
* @param {String} name - Name of playlist to delete
*/
userSchema.methods.deletePlaylistByName = async function(name){
//Find the playlist
const playlist = this.getPlaylistByName(name);
//splice out the given playlist
this.playlists.splice(playlist.listIndex, 1);
//save the channel document
await this.save();
}
/**
* Salts, Hashes and Tattoo's IP address to user document in a privacy respecting manner
* @param {String} ip - Plaintext IP Address to Salt, Hash and Tattoo
*/
userSchema.methods.tattooIPRecord = async function(ip){
//Hash the users ip
const ipHash = hashUtil.hashIP(ip);
@ -757,6 +548,8 @@ 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
@ -764,7 +557,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(hashUtil.compareIPHash(ip, curRecord.ipHash)){
if(curRecord.ipHash == ipHash){
//Check if we've already marked the user as an alt
const foundAlt = this.alts.indexOf(curUser._id);
@ -801,19 +594,12 @@ userSchema.methods.tattooIPRecord = async function(ip){
//Look for matching ip record
function checkHash(ipRecord){
//return matching records
return hashUtil.compareIPHash(ip, ipRecord.ipHash);
return ipRecord.ipHash == ipHash;
}
}
//note: if you gotta call this from a request authenticated by it's user, make sure to kill that session first!
/**
* Kills all sessions for a given user
* @param {String} reason - Reason to kill user sessions
*/
userSchema.methods.killAllSessions = async function(reason = "A full log-out from all devices was requested for your account."){
//Nuke all related remember me tokens
await rememberMeModel.deleteMany({user: this._id});
//get authenticated sessions
var sessions = await this.getAuthenticatedSessions();
@ -826,12 +612,8 @@ userSchema.methods.killAllSessions = async function(reason = "A full log-out fro
server.channelManager.kickConnections(this.user, reason);
}
/**
* Changes password for a given user document
* @param {Object} passChange - passChange object handed down from Browser
*/
userSchema.methods.changePassword = async function(passChange){
if(await this.checkPass(passChange.oldPass)){
if(this.checkPass(passChange.oldPass)){
if(passChange.newPass == passChange.confirmPass){
//Note: We don't have to worry about hashing here because the schema is written to do it auto-magically
this.pass = passChange.newPass;
@ -843,20 +625,15 @@ userSchema.methods.changePassword = async function(passChange){
await this.killAllSessions("Your password has been reset.");
}else{
//confirmation pass doesn't match
throw loggerUtils.exceptionSmith("Mismatched confirmation password!", "validation");
throw new Error("Mismatched confirmation password!");
}
}else{
//Old password wrong
throw loggerUtils.exceptionSmith("Incorrect Password!", "validation");
throw new Error("Incorrect Password!");
}
}
/**
* Checks another user document against this one to see if they're alts
* @param {Mongoose.Document} userDB - User document to check for alts against
* @returns {Boolean} True if alt
*/
userSchema.methods.altCheck = async function(userDB){
//Found alt flag
let foundAlt = false;
@ -872,24 +649,20 @@ userSchema.methods.altCheck = async function(userDB){
return foundAlt;
}
/**
* Nukes user account from the face of the planet upon said user's request
* @param {String} pass - Password to authenticate against before deleting
*/
userSchema.methods.nuke = async function(pass){
//Check we have a confirmation password
if(pass == "" || pass == null){
//scream and shout
throw loggerUtils.exceptionSmith("No confirmation password!", "validation");
throw new Error("No confirmation password!");
}
//Check that the password is correct
if(await this.checkPass(pass)){
if(this.checkPass(pass)){
//delete the user
var oldUser = await this.deleteOne();
}else{
//complain about a bad pass
throw loggerUtils.exceptionSmith("Bad pass.", "unauthorized");
throw new Error("Bad pass.");
}
}

View file

@ -14,47 +14,35 @@ 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 <https://www.gnu.org/licenses/>.*/
//Define global crypto variable for altcha
globalThis.crypto = require('node:crypto').webcrypto;
//Define NODE imports
const https = require('https');
const fs = require('fs');
//Define NPM imports
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');
const mongoose = require('mongoose');
//Define global crypto variable for altcha
globalThis.crypto = require('node:crypto').webcrypto;
//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');
const {errorMiddleware} = require('./utils/loggerUtils');
const sessionUtils = require('./utils/sessionUtils');
//Validator
const accountValidator = require('./validators/accountValidator');
//DB Model
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
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');
@ -63,11 +51,10 @@ 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
//const popupRouter = require('./routers/popupRouter');
const popupRouter = require('./routers/popupRouter');
//Tooltip
const tooltipRouter = require('./routers/tooltipRouter');
//Api
@ -75,10 +62,12 @@ const apiRouter = require('./routers/apiRouter');
//Define Config variables
const config = require('../config.json');
const package = require('../package.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();
@ -87,52 +76,15 @@ module.exports.store = mongoStore.create({mongoUrl: dbUrl});
//define sessionMiddleware
const sessionMiddleware = session({
secret: config.secrets.sessionSecret,
secret: config.sessionSecret,
resave: false,
saveUninitialized: false,
store: module.exports.store,
cookie: {
sameSite: "strict",
secure: config.protocol.toLowerCase() == "https"
}
store: module.exports.store
});
//Declare web server
let webServer = null;
//If we're using HTTPS
if(config.protocol.toLowerCase() == "https"){
try{
//Read key/cert files and store contents
const httpsOptions = {
key: fs.readFileSync(config.ssl.key),
cert: fs.readFileSync(config.ssl.cert)
};
//Start HTTPS Server
webServer = https.createServer(httpsOptions, app);
}catch(err){
//If the error has a path
if(err.path != null && err.path != ""){
//Tell the user to fix their shit
console.log(`Error opening key/cert file: ${err.path}`);
//Otherwise
}else{
//Shit our pants
console.log("Unknown error occured while starting HTTPS server! Bailing out!");
console.log(err);
}
//and run for the hills
process.exit();
}
//Otherwise
}else{
//Default to HTTP
webServer = createServer(app)
}
const io = new Server(webServer, {});
//Define http and socket.io servers
const httpServer = createServer(app);
const io = new Server(httpServer, {});
//Connect mongoose to the database
mongoose.set("sanitizeFilter", true).connect(dbUrl).then(() => {
@ -143,14 +95,6 @@ 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');
@ -158,9 +102,7 @@ app.set('views', __dirname + '/views');
//Middlware
//Enable Express
app.use(express.json());
//Enable Express Ccokie-Parser
app.use(cookieParser());
//app.use(express.urlencoded());
//Enable Express-Sessions
app.use(sessionMiddleware);
@ -168,17 +110,9 @@ 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());
//Use remember me middleware
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);
@ -187,63 +121,49 @@ 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);
//Popup
app.use('/popup', popupRouter);
//tooltip
app.use('/tooltip', tooltipRouter);
//Bot-Ready
app.use('/api', apiRouter);
//Static File Server
//Serve bootstrap icons
app.use('/lib/bootstrap-icons',express.static(path.join(__dirname, '../node_modules/bootstrap-icons')));
app.use('/lib/altcha',express.static(path.join(__dirname, '../node_modules/altcha/dist_external')));
//Server public 'www' folder
app.use(express.static(path.join(__dirname, '../www')));
//Handle error checking
app.use(errorMiddleware);
//Basic 404 handler
app.use(fileNotFoundController);
asyncKickStart();
/*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 ${package.canopyDisplayVersion}) is booting up!`);
//Run legacy migration
await migrationModel.ingestLegacyDump();
//Increment launch counter
statModel.incrementLaunchCount();
//Build migration cache
await migrationModel.buildMigrationCache();
//Load default flairs
flairModel.loadDefaults();
//Calculate Toke Map
await tokeModel.calculateTokeMap();
//Load default emotes
emoteModel.loadDefaults();
//Load default toke commands
await tokeCommandModel.loadDefaults();
//Load default toke commands
tokeCommandModel.loadDefaults();
//Load default flairs
await flairModel.loadDefaults();
//Kick off scheduled-jobs
scheduler.kickoff();
//Load default emotes
await emoteModel.loadDefaults();
//Hand over general-namespace socket.io connections to the channel manager
module.exports.channelManager = new channelManager(io)
//Kick off scheduled-jobs
scheduler.kickoff();
//Check for insecure config
configCheck.securityCheck();
//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 \x1b[4m\x1b[35m${port}\x1b[0m!\n`);
});
}
//Listen Function
httpServer.listen(port, () => {
console.log(`Opening port ${port}`);
});

View file

@ -20,21 +20,11 @@ const config = require('../../config.json');
//NPM imports
const { createChallenge, verifySolution } = require('altcha-lib');
/**
* Create empty array to hold cache of spent payloads to protect against replay attacks
*/
//Create empty array to hold cache of spent payloades to protect against replay attacks
const spent = [];
/**
* Captcha lifetime in minutes
*/
//Captcha lifetime in minutes
const lifetime = 2;
/**
* Generates captcha challenges to send down to the browser
* @param {Number} difficulty - Challange Difficulty (x100K internally)
* @param {String} uniqueSecret - Secret to salt the challange hash with
* @returns {String} Altcha Challenge hash
*/
module.exports.genCaptcha = async function(difficulty = 2, uniqueSecret = ''){
//Set altcha expiration date
const expiration = new Date();
@ -44,20 +34,14 @@ module.exports.genCaptcha = async function(difficulty = 2, uniqueSecret = ''){
//Generate Altcha Challenge
return await createChallenge({
hmacKey: [config.secrets.altchaSecret, uniqueSecret].join(''),
hmacKey: [config.altchaSecret, uniqueSecret].join(''),
maxNumber: 100000 * difficulty,
expires: expiration
});
}
/**
* Verifies completed altcha challenges handed over from the user
* @param {String} payload - Completed Altcha Payload
* @param {String} uniqueSecret - Server-side Unique Secret to verify payload came from server-generated challenge
* @returns {boolean} True if payload is a valid and unique altcha challenge which originated from this server
*/
module.exports.verify = async function(payload, uniqueSecret = ''){
//If this payload is already spent
//If we already checked this payload
if(spent.indexOf(payload) != -1){
//Fuck off and die
return false;
@ -73,5 +57,5 @@ module.exports.verify = async function(payload, uniqueSecret = ''){
setTimeout(() => {spent.splice(payloadIndex,1);}, lifetime * 60 * 1000);
//Return verification results
return await verifySolution(payload, [config.secrets.altchaSecret, uniqueSecret].join(''));
return await verifySolution(payload, [config.altchaSecret, uniqueSecret].join(''));
}

View file

@ -23,13 +23,10 @@ const loggerUtil = require('./loggerUtils');
//NPM Imports
const validator = require('validator');//We need validators for express-less code too!
/**
* Basic security check which runs on startup.
* Warns server admin against unsafe config options.
*/
module.exports.securityCheck = function(){
//Check Protocol
if(config.protocol.toLowerCase() != 'https'){
if(config.protocol == 'http'){
//If it's insecure then warn the admin
loggerUtil.consoleWarn("Starting in HTTP mode. This server should be used for development purposes only!");
}
@ -40,31 +37,16 @@ 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.secrets.sessionSecret) || config.secrets.sessionSecret == "CHANGE_ME"){
if(!validator.isStrongPassword(config.sessionSecret) || config.sessionSecret == "CHANGE_ME"){
loggerUtil.consoleWarn("Insecure Session Secret! Change Session Secret!");
}
//check altcha secret
if(!validator.isStrongPassword(config.secrets.altchaSecret) || config.secrets.altchaSecret == "CHANGE_ME"){
if(!validator.isStrongPassword(config.altchaSecret) || config.altchaSecret == "CHANGE_ME"){
loggerUtil.consoleWarn("Insecure Altcha Secret! Change Altcha Secret!");
}
//check ipHash secret
if(!validator.isStrongPassword(config.secrets.ipSecret) || config.secrets.ipSecret == "CHANGE_ME"){
loggerUtil.consoleWarn("Insecure IP Hashing Secret! Change IP Hashing Secret!");
}
//check DB pass
if(!validator.isStrongPassword(config.db.pass) || config.db.pass == "CHANGE_ME" || config.db.pass == config.db.user){
loggerUtil.consoleWarn("Insecure Database Password! Change Database password!");
@ -75,8 +57,4 @@ module.exports.securityCheck = function(){
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!");
}
}

View file

@ -17,6 +17,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
//NPM Imports
const { csrfSync } = require('csrf-sync');
//Local Imports
const {errorHandler} = require('./loggerUtils');
//Pull needed methods from csrfSync
const {generateToken, revokeToken, csrfSynchronisedProtection, isRequestValid} = csrfSync();

View file

@ -14,113 +14,28 @@ 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 <https://www.gnu.org/licenses/>.*/
//Config
const config = require('../../config.json');
//Node Imports
const crypto = require('node:crypto');
//NPM Imports
const argon2 = require('argon2');
const bcrypt = require('bcrypt');
/**
* Sitewide function for hashing passwords
* @param {String} pass - Password to hash
* @returns {String} Hashed/Salted password
*/
module.exports.hashPassword = async function(pass){
//Hash password with argon2id
return await argon2.hash(pass, {secret: Buffer.from(config.secrets.passwordSecret)});
module.exports.hashPassword = function(pass){
const salt = bcrypt.genSaltSync();
return bcrypt.hashSync(pass, salt);
}
/**
* Sitewide method for authenticating/comparing passwords agianst hashes
* @param {String} pass - Plaintext Password
* @param {String} hash - Salty Hash
* @returns {Boolean} True if authentication success
*/
module.exports.comparePassword = async function(pass, hash){
//Verify password against argon2 hash
return await argon2.verify(hash, pass, {secret: Buffer.from(config.secrets.passwordSecret)});
}
/**
* 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){
module.exports.comparePassword = function(pass, hash){
return bcrypt.compareSync(pass, hash);
}
/**
* Site-wide IP hashing/salting function
*
* Provides a basic level of privacy by only logging salted hashes of IP's
* @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 {String} Hashed/Peppered/Salted IP Address
*/
module.exports.hashIP= function(ip, salt){
module.exports.hashIP = function(ip){
//Create hash object
const hashObj = crypto.createHash('sha512');
const hashObj = crypto.createHash('md5');
//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');
}
//add IP to the hash
hashObj.update(ip);
//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}`;
}
/**
* 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("$");
//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;
}
/**
* Site-wide remember-me token hashing function
* @param {String} token - Token to hash
* @returns {String} - Hashed token
*/
module.exports.hashRememberMeToken = async function(token){
//hash token with argon2id
return await argon2.hash(token, {secret: Buffer.from(config.secrets.rememberMeSecret)});
}
/**
* 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, {secret: Buffer.from(config.secrets.rememberMeSecret)});
//return the IP hash as a string
return hashObj.digest('hex');
}

View file

@ -16,22 +16,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
//NPM Imports
const validator = require('validator');//No express here, so regular validator it is!
const {sanitizeUrl} = require("@braintree/sanitize-url");
//Create link cache
/**
* Basic RAM-Based cache of links, so we don't have to re-pull things after we get them
*/
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} dirtyLink - URL to Validate
* @returns {Object} Marked link object
*/
module.exports.markLink = async function(dirtyLink){
const link = sanitizeUrl(dirtyLink);
module.exports.markLink = async function(link){
//Check link cache for the requested link
const cachedLink = module.exports.cache.get(link);
@ -47,7 +36,7 @@ module.exports.markLink = async function(dirtyLink){
var type = "malformedLink"
//Make sure we have an actual, factual URL
if(validator.isURL(link,{require_valid_protocol: true, protocols: ['http', 'https']})){
if(validator.isURL(link)){
//The URL is valid, so this is at least a dead link
type = 'deadLink';

View file

@ -14,44 +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 <https://www.gnu.org/licenses/>.*/
//Node
const fs = require('node:fs/promises');
const crypto = require('node:crypto');
//Config
const config = require('../../config.json');
/**
* Creates and returns a custom exception, tagged as a 'custom' exception, using the 'custom' boolean property.
* This is used to denote that this error was generated on purpose, with a human readable message, that can be securely sent to the client.
* Unexpected exceptions should only be logged internally, however, as they may contain sensitive data.
*
* @param {String} msg - Error message to send the client
* @param {String} type - Error type to send back to the client
* @returns {Error} The exception to smith
*/
module.exports.exceptionSmith = function(msg, type){
//Create the new error with the given message
const exception = new Error(msg);
//Set the error type
exception.type = type;
//Mark the error as a custom error
exception.custom = true;
//Return the error
return exception;
}
/**
* Main error handling function
* @param {Express.Response} res - Response being sent out to the client who caused the issue
* @param {String} msg - Error message to send the client
* @param {String} type - Error type to send back to the client
* @param {Number} status - HTTP(s) Status Code to send back to the client
* @returns {Express.Response} If we have a usable Express Response object, return it back after it's been cashed
*/
//At some point this will be a bit more advanced, right now it's just a placeholder :P
module.exports.errorHandler = function(res, msg, type = "Generic", status = 400){
//Some controllers do things after sending headers, for those, we should remain silent
if(!res.headersSent){
@ -60,103 +26,39 @@ module.exports.errorHandler = function(res, msg, type = "Generic", status = 400)
}
}
/**
* Handles local exceptions which where not directly created by user interaction
* @param {Error} err - Exception to handle
*/
module.exports.localExceptionHandler = function(err){
//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);
}
}
/**
* Handles exceptions which where directly the fault of user action >:(
* @param {Express.Response} res - Express Response object to bitch at
* @param {Error} err - Error created by the jerk in question
*/
module.exports.exceptionHandler = function(res, err){
//If this is a self-made problem
if(err.custom){
module.exports.errorHandler(res, err.message, err.type);
}else{
//Locally handle the exception
module.exports.localExceptionHandler(err);
//if not yell at the browser for fucking up, and tell it what it did wrong.
module.exports.errorHandler(res, "An unexpected server crash was just prevented. You should probably report this to an admin.", "Caught Exception");
//If we're being verbose
if(config.verbose){
//Log the error
console.log(err)
}
//if not yell at the browser for fucking up, and tell it what it did wrong.
module.exports.errorHandler(res, err.message, "Caught Exception");
}
/**
* Basic error-handling for socket.io so we don't just silently swallow errors.
* @param {Socket} socket - Socket error originated from
* @param {String} msg - Error message to send the client
* @param {String} type - Error type to send back to the client
* @returns {Boolean} - Passthrough from socket.emit
*/
module.exports.socketErrorHandler = function(socket, msg, type = "Generic"){
return socket.emit("error", {errors: [{type, msg, date: new Date()}]});
}
/**
* Generates error messages for simple errors generated by socket.io interaction
* @param {Socket} socket - Socket error originated from
* @param {Error} err - Error created by the jerk in question
* @returns {Boolean} - Passthrough from socket.emit
*/
module.exports.socketExceptionHandler = function(socket, err){
//If this is a self made problem
if(err.custom){
//yell at the browser for fucking up, and tell it what it did wrong.
return module.exports.socketErrorHandler(socket, err.message, err.type);
}else{
//Locally handle the exception
module.exports.localExceptionHandler(err);
//if not yell at the browser for fucking up
return module.exports.socketErrorHandler(socket, "An unexpected server crash was just prevented. You should probably report this to an admin.", "Server");
//If we're being verbose
if(config.verbose){
//Log the error
console.log(err)
}
//if not yell at the browser for fucking up, and tell it what it did wrong.
return socket.emit("error", {errors: [{type: "Caught Exception", msg: err.message, date: new Date()}]});
}
/**
* Generates error messages and drops connection for critical errors caused by socket.io interaction
* @param {Socket} socket - Socket error originated from
* @param {Error} err - Error created by the jerk in question
* @returns {Boolean} - Passthrough from socket.disconnect
*/
module.exports.socketCriticalExceptionHandler = function(socket, err){
//If this is a self made problem
if(err.custom){
//yell at the browser for fucking up, and tell it what it did wrong.
socket.emit("kick", {type: "Disconnected", reason: `Server Error: ${err.message}`});
}else{
//Locally handle the exception
module.exports.localExceptionHandler(err);
//yell at the browser for fucking up
socket.emit("kick", {type: "Disconnected", reason: "An unexpected server crash was just prevented. You should probably report this to an admin."});
}
//if not yell at the browser for fucking up, and tell it what it did wrong.
socket.emit("kick", {type: "Disconnected", reason: `Server Error: ${err.message}`});
return socket.disconnect();
}
/**
* Prints warning text to server console
* @param {String} string - String to print to console
*/
module.exports.consoleWarn = function(string){
console.warn('\x1b[31m\x1b[4m%s\x1b[0m',string);
}
/**
* Basic error-handling middleware to ensure we're not dumping stack traces to the client, as that would be insecure
* @param {Error} err - Error to handle
* @param {Express.Request} req - Express Request
* @param {Express.Response} res - Express Response
* @param {Function} next - Next function in the Express middleware chain (Not that it's getting called XP)
*/
//Basic error-handling middleware to ensure we're not dumping stack traces
module.exports.errorMiddleware = function(err, req, res, next){
//Set generic error
var reason = "Server Error";
@ -167,96 +69,4 @@ module.exports.errorMiddleware = function(err, req, res, next){
}
module.exports.errorHandler(res, err.message, reason, err.status);
}
/**
* 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 = '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/${subDir}`
//Double check crash folder exists
try{
await fs.stat(dir);
//If we caught an error (most likely it's missing)
}catch(err){
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}${name}.log`;
//Write content to file
fs.writeFile(path, content);
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){
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);
}
}
}
module.exports.welcomeWagon = function(count, date, tokes){
//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'} 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
console.log(art);
//Add some extra padding for the port printout from server.js
process.stdout.write(' ');
}

View file

@ -19,16 +19,8 @@ const config = require('../../config.json');
//NPM imports
const nodeMailer = require("nodemailer");
const validator = require('validator');
//local imports
const loggerUtils = require('./loggerUtils');
//Setup mail transport
/**
* nodemailer transport object, generated from options specific in our config file
*/
const transporter = nodeMailer.createTransport({
host: config.mail.host,
port: config.mail.port,
@ -39,88 +31,40 @@ const transporter = nodeMailer.createTransport({
}
});
/**
* Sends an email as tokebot to the requested user w/ the requested body and signature
* @param {String} to - String containing the email address to send to
* @param {String} subject - Subject line of the email to send
* @param {String} body - Body contents, either HTML or Plaintext
* @param {Boolean} htmlBody - Whether or not Body contents should be sent as HTML or Plaintext
* @returns {Object} Sent mail info
*/
module.exports.mailem = async function(to, subject, body, htmlBody = false){
try{
//If we have a bad email address
if(!validator.isEmail(to)){
//fuck off
return;
}
//Create mail object
const mailObj = {
from: `"Tokebot🤖💨"<${config.mail.address}>`,
to,
subject
};
//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(), 'crash/mail/');
//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;
}
/**
* Sends address verification email
* @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, newUser = false, migration = false,){
let subject = `Email Change Request - ${userDB.user}`;
let content = `<h1>email change request</h1>
<p>a request to change the email associated with the ${config.instanceName} account '${userDB.user}' to this address has been requested.<br>
<a href="${requestDB.getChangeURL()}">click here</a> to confirm this change.</p>
<sup>if you received this email without request, feel free to ignore and delete it! -tokebot</sup>`;
if(newUser){
subject = `New User Email Confirmation - ${userDB.user}`;
content = `<h1>New user email confirmation</h1>
<p>a new ${config.instanceName} account '${userDB.user}' was created with this email address.<br>
<a href="${requestDB.getChangeURL()}">click here</a> to confirm this change.</p>
<sup>if you received this email without request, feel free to ignore and delete it! -tokebot</sup>`;
}
if(migration){
subject = `User Migration Email Confirmation - ${userDB.user}`;
content = `<h1>User migration email confirmation</h1>
<p>The ${config.instanceName} account '${userDB.user}' was successfully migrated to our <a href="https://git.ourfore.st/rainbownapkin/canopy">fancy new codebase</a>.<br>
<a href="${requestDB.getChangeURL()}">click here</a> to confirm this change.</p>
<sup>if you received this email without request, reach out to an admin, as your old account might be getting jacked! -tokebot</sup>`;
}
module.exports.sendAddressVerification = async function(requestDB, userDB, newEmail){
//Send the reset url via email
await module.exports.mailem(
newEmail,
subject,
content,
`Email Change Request - ${userDB.user}`,
`<h1>Email Change Request</h1>
<p>A request to change the email associated with the ${config.instanceName} account '${userDB.user}' to this address has been requested.<br>
<a href="${requestDB.getChangeURL()}">Click here</a> to confirm this change.</p>
<sup>If you received this email without request, feel free to ignore and delete it! -Tokebot</sup>`,
true
);

View file

@ -1,113 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
//Node Imports
const validator = require('validator');
//Local Imports
const media = require('../../app/channel/media/media.js');
const regexUtils = require('../regexUtils.js');
const loggerUtils = require('../loggerUtils.js')
/**
* Pulls metadate for a given archive.org item
* @param {String} fullID - Full path of the requested upload
* @param {String} title - Title to add to media object
* @returns {Array} Generated list of media objects from given upload path
*/
module.exports.fetchMetadata = async function(fullID, title){
//Split fullID by first slash
const [itemID, requestedPath] = decodeURIComponent(fullID).split(/\/(.*)/);
//Create empty list to hold media objects
const mediaList = [];
//Create empty variable to hold return data object
let data;
//Create metadata link from itemID
const metadataLink = `https://archive.org/metadata/${itemID}`;
//Fetch item metadata from the internet archive
const response = await fetch(metadataLink,
{
method: "GET"
}
);
//If we hit a snag
if(!response.ok){
//Scream and shout
const errorBody = await response.text();
throw loggerUtils.exceptionSmith(`Internet Archive Error '${response.status}': ${errorBody}`, "queue");
}
//Collect our metadata
const rawMetadata = await response.json();
//Filter out any in-compatible files
const compatibleFiles = rawMetadata.files.filter(compatibilityFilter);
//If we're requesting an empty path
if(requestedPath == '' || requestedPath == null){
//Return item metadata and compatible files
data = {
files: compatibleFiles,
metadata: rawMetadata.metadata
}
//Other wise
}else{
//Return item metadata and matching compatible files
data = {
//Filter files out that don't match requested path and return remaining list
files: compatibleFiles.filter(pathFilter),
metadata: rawMetadata.metadata
}
}
//for every compatible and relevant file returned from IA
for(let file of data.files){
//Split file path by directories
const path = file.name.split('/');
//pull filename from path and escape in-case someone put something nasty in there
const name = validator.escape(validator.trim(path[path.length - 1]));
//Construct link from pulled info
const link = `https://archive.org/download/${data.metadata.identifier}/${file.name}`;
//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, link, 'ia', Number(file.length)));
}else{
//Create new media object from file info
mediaList.push(new media(title, name, link, link, 'ia', Number(file.length)));
}
}
//return media object list
return mediaList;
function compatibilityFilter(file){
//return true for all files that match for web-safe formats
return file.format.match(/^h\.264/) || file.format == "Ogg Video" || file.format.match("MPEG4");
}
function pathFilter(file){
//return true for all file names which match the given requested file path
return file.name.match(`^${regexUtils.escapeRegex(requestedPath)}`);
}
}

View file

@ -1,173 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
//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');
const ytdlpUtil = require('./ytdlpUtils');
/**
* Checks a given URL and runs the proper metadata fetching function to create a media object from any supported URL
* @param {String} url - URL to yank media against
* @param {String} title - Title to apply to yanked media
* @returns {Array} Returns list of yanked media objects on success
*/
module.exports.yankMedia = async function(url, title){
//Get pull type
const pullType = await this.getMediaType(url);
//Check pull type
switch(pullType.type){
case "ia":
//return media object list from IA module
return await iaUtil.fetchMetadata(pullType.id, title);
case "yt":
//return media object list from the YT-DLP module's youtube function
return await ytdlpUtil.fetchYoutubeMetadata(pullType.id, title);
case "ytp":
//return media object list from YT-DLP module's youtube playlist function
//return await ytdlpUtil.fetchYoutubePlaylistMetadata(pullType.id, title);
//Holding off on this since YT-DLP takes 10 years to do a playlist as it needs to pull each and every video one-by-one
//Maybe in the future a piped alternative might be in order, however this would most likely require us to host our own local instance.
//Though it could give us added resistance against youtube/google's rolling IP bans
return null;
case "dm":
//return mediao object list from the YT-DLP module's dailymotion function
return await ytdlpUtil.fetchDailymotionMetadata(pullType.id, title);
default:
//return null to signify a bad url
return null;
}
}
/**
* Refreshes raw links on relevant media objects
*
* Useful for sources like youtube, who only provide expiring raw links
* @param {ScheduledMedia} mediaObj - Media Object to refresh
* @returns {ScheduledMedia} Refreshed media object
*/
module.exports.refreshRawLink = async function(mediaObj){
switch(mediaObj.type){
case 'yt':
//Create boolean to hold expired state
let expired = false;
//Create boolean to hold whether or not rawLink object is empty
let empty = true;
//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;
//return media object
return mediaObj;
}
}
//Return null to tell the calling function there is no refresh required for this media type
return null;
}
/**
* Detects media type by URL
*
* I'd be lying if this didn't take at least some inspiration/regex patterns from extractQueryParam() in cytube/forest's browser-side 'util.js'
* 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} dirtyURL - URL to determine media type of
* @returns {Object} containing URL type and clipped ID string
*/
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,{require_valid_protocol: true}))){
//If not toss the fucker out
return {
type: null,
id: null
}
}
//If we have link to a resource from archive.org
if(match = url.match(/archive\.org\/(?:details|download)\/([a-zA-Z0-9\/._-\s\%]+)/)){
//return internet archive upload id and filepath
return {
type: "ia",
id: match[1]
}
}
//If we have a match to a youtube video
if((match = url.match(/youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})/)) || (match = url.match(/youtu\.be\/([a-zA-Z0-9_-]{11})/))){
//return youtube video id
return {
type: "yt",
id: match[1]
}
}
//If we have a match to a youtube playlist
if((match = url.match(/youtube\.com\/playlist\?list=([a-zA-Z0-9_-]{34})/)) || (match = url.match(/youtu\.be\/playlist\?list=([a-zA-Z0-9_-]{34})/))){
//return youtube playlist id
return {
type: "ytp",
id: match[1]
}
}
//If we have a match to a dailymotion video
if(match = url.match(/dailymotion\.com\/video\/([a-zA-Z0-9]+)/)){
return {
type: "dm",
id: match[1]
}
}
//If we fell through all of our media types without a match
return{
type: null,
id: null
}
}

View file

@ -1,169 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
//Config
const config = require('../../../config.json');
//Node Imports
const { create: ytdlpMaker } = require('youtube-dl-exec');
//Import ytdlp w/ custom path from config so we can force the newest build of yt-dlp from pip
const ytdlp = ytdlpMaker(config.ytdlpPath);
const url = require("node:url");
const validator = require('validator');
//Local Imports
const media = require('../../app/channel/media/media.js');
const regexUtils = require('../regexUtils.js');
const loggerUtils = require('../loggerUtils.js')
/**
* Pulls metadata for a single youtube video via YT-DLP
* @param {String} id - Youtube Video ID
* @param {String} title - Title to add to the given media object
* @returns {Media} Media object containing relevant metadata
*/
module.exports.fetchYoutubeMetadata = async function(id, title){
try{
//Try to pull media from youtube id
const media = await fetchVideoMetadata(`https://youtu.be/${id}`, title, 'yt');
//Return found media
return media;
//If something went wrong
}catch(err){
//If our IP was banned by youtube
if(err.message.match("Sign in to confirm youre not a bot.")){
//Make our own error with blackjack and hookers
throw loggerUtils.exceptionSmith("The server's IP address has been banned by youtube. Please contact your server's administrator.", "queue");
//Otherwise if we don't have a good way to handle it
}else{
//toss it back up
throw err;
}
}
}
/**
* Pulls metadata for a playlist of youtube videos via YT-DLP
* @param {String} id - Youtube Playlist ID
* @param {String} title - Title to add to the given media objects
* @returns {Array} Array of Media objects containing relevant metadata
*/
module.exports.fetchYoutubePlaylistMetadata = async function(id, title){
try{
//Try to pull media from youtube id
const media = await fetchPlaylistMetadata(`https://youtu.be/playlist?list=${id}`, title, 'yt');
//Return found media
return media;
//If something went wrong
}catch(err){
//If our IP was banned by youtube
if(err.message.match("Sign in to confirm youre not a bot.")){
//Make our own error with blackjack and hookers
throw loggerUtils.exceptionSmith("The server's IP address has been banned by youtube. Please contact your server's administrator.", "queue");
//Otherwise if we don't have a good way to handle it
}else{
//toss it back up
throw err;
}
}
}
/* This requires HLS embeds which, in-turn, require daily motion to add us to their CORS exception list
* Not gonna happen, so we need to use their API for this, or proxy the video
module.exports.fetchDailymotionMetadata = async function(id, title){
//Pull media from dailymotion link
const media = await fetchVideoMetadata(`https://dailymotion.com/video/${id}`, title, 'dm');
//Return found media;
return media;
}*/
/**
* Generic single video YTDLP function meant to be used by service-sepecific fetchers which will then be used to fetch video metadata
* @param {String} link - Link to video in question
* @param {String} title - Title to add to the given media objects
* @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 = 'ba,bv'){
//Create media list
const mediaList = [];
//Pull raw metadata from YT-DLP
const rawMetadata = await ytdlpFetch(link, format);
//Pull data from rawMetadata, sanatizing title to prevent XSS
const name = validator.escape(validator.trim(rawMetadata.title));
//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), rawLinks));
}else{
//Create new media object from file info
mediaList.push(new media(title, name, link, id, type, Number(rawMetadata.duration), rawLinks));
}
//Return list of media
return mediaList;
}
//YT-DLP takes forever to handle playlists, we'll handle this via piped in the future perhaps
/*async function fetchPlaylistMetadata(link, title, type, format = 'b'){
}*/
//Wrapper function for YT-DLP NPM package with pre-set cli-flags
/**
* Basic async YT-DLP Fetch wrapper, ensuring config
* @param {String} link - Link to fetch using YT-DLP
* @param {String} format - Format string to hand YT-DLP, defaults to 'b'
* @returns {Object} Metadata dump from YT-DLP
*/
async function ytdlpFetch(link, format = 'ba,ogg'){
//return promise from ytdlp
return ytdlp(link, {
format,
dumpSingleJson: true,
});
}

View file

@ -1,134 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
//local includes
const server = require('../server');
const {userModel} = 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);
//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 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
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 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();
}
}
}

View file

@ -1,28 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
/**
* I won't lie this line was whole-sale ganked from stack overflow like a fucking skid
* In my defense I only did it because js-runtime-devs are taking fucking eons to implement RegExp.escape()
* This should be replaced once that function becomes available in mainline versions of node.js:
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/escape
*
* @param {String} string - Regex string to escape
* @returns {String} The Escaped String
*/
module.exports.escapeRegex = function(string){
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

View file

@ -22,13 +22,10 @@ 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
*/
module.exports.schedule = function(){
//Process hashed IP Records that haven't been recorded in a week or more
cron.schedule('0 0 * * *', ()=>{userModel.processAgedIPRecords()},{scheduled: true, timezone: "UTC"});
@ -42,26 +39,19 @@ 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"});
}
/**
* Kicks off first run of scheduled functions before scheduling functions for regular callback
*/
module.exports.kickoff = function(){
//Process Hashed IP Records that haven't been recorded in a week or more
userModel.processAgedIPRecords();
//Process expired global bans that may have expired since last restart
userBanModel.processExpiredBans();
userBanModel.processExpiredBans()
//Process expired channel bans that may have expired since last restart
channelModel.processExpiredBans();
//Process expired password reset requests that may have expired since last restart
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();

View file

@ -14,77 +14,38 @@ 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 <https://www.gnu.org/licenses/>.*/
//npm imports
const {validationResult, matchedData} = require('express-validator');
//Local Imports
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');
const {userModel} = require('../schemas/user/userSchema');
const userBanModel = require('../schemas/user/userBanSchema')
const altchaUtils = require('../utils/altchaUtils');
/**
* Create failed sign-in cache since it's easier and more preformant to implement it this way than adding extra burdon to the database
* Server restarts are far and few between. It would take multiple during a single bruteforce attempt for this to become an issue.
*/
//Create failed sign-in cache since it's easier and more preformant to implement it this way than adding extra burdon to the database
//Server restarts are far and few between. It would take multiple during a single bruteforce attempt for this to become an issue.
const failedAttempts = new Map();
/**
* How many failed attempts required to throttle with altcha
*/
const throttleAttempts = 5;
/**
* How many attempts to lock user account out for the day
*/
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.
*
* 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(identifier, secret, req, useRememberMeToken = false){
//this module is good for keeping wrappers for userModel and other shit in that does more session handling than database access/modification.
module.exports.authenticateSession = async function(user, pass, req){
//Fuck you yoda
try{
//Grab previous attempts
const attempt = failedAttempts.get(identifier);
//If we're proxied use passthrough IP
const ip = config.proxied ? req.headers['x-forwarded-for'] : req.ip;
const attempt = failedAttempts.get(user);
//Look for ban by IP
const ipBanDB = await userBanModel.checkBanByIP(ip);
const ipBanDB = await userBanModel.checkBanByIP(req.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
throw loggerUtils.exceptionSmith(`The IP address you are trying to login from has been permanently banned. Your cleartext IP has been saved to the database. Any associated accounts will be nuked in ${expiration} day(s).`, "unauthorized");
}else{
//tell it to fuck off
throw loggerUtils.exceptionSmith(`The IP address you are trying to login from has been temporarily banned. Your cleartext IP has been saved to the database until the ban expires in ${expiration} day(s).`, "unauthorized");
}
//tell it to fuck off
throw new Error(`The IP address you are trying to login from has been banned.`);
}
//If we have failed attempts
if(!useRememberMeToken && attempt != null){
if(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");
throw new Error("This account has been locked for at 24 hours due to a large amount of failed log-in attempts");
}
//If we're throttling logins
@ -92,24 +53,15 @@ module.exports.authenticateSession = async function(identifier, secret, req, use
//Verification doesnt get sanatized or checked since that would most likely break the cryptography
//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, identifier)){
throw loggerUtils.exceptionSmith("Verification failed!", "");
throw new Error("Verification failed!");
}else if(!altchaUtils.verify(req.body.verification, user)){
throw new Error("Verification failed!");
}
}
}
//define/scope empty userDB variable
let userDB = null;
//If we're using remember me tokens
if(useRememberMeToken){
userDB = await rememberMeModel.authenticate(identifier, secret);
//Otherwise
}else{
//Fallback on to username/password authentication
userDB = await userModel.authenticate(identifier, secret);
}
//Authenticate the session
const userDB = await userModel.authenticate(user, pass);
//Check for user ban
const userBanDB = await userBanModel.checkBanByUserDoc(userDB);
@ -119,9 +71,9 @@ module.exports.authenticateSession = async function(identifier, secret, req, use
//Make the number a little prettier despite the lack of precision since we're not doing calculations here :P
const expiration = userBanDB.getDaysUntilExpiration() < 1 ? 0 : userBanDB.getDaysUntilExpiration();
if(userBanDB.permanent){
throw loggerUtils.exceptionSmith(`Your account has been permanently banned, and will be nuked from the database in: ${expiration} day(s)`, "unauthorized");
throw new Error(`Your account has been permanently banned, and will be nuked from the database in: ${expiration} day(s)`);
}else{
throw loggerUtils.exceptionSmith(`Your account has been temporarily banned, and will be reinstated in: ${expiration} day(s)`, "unauthorized");
throw new Error(`Your account has been temporarily banned, and will be reinstated in: ${expiration} day(s)`);
}
}
@ -137,68 +89,49 @@ module.exports.authenticateSession = async function(identifier, secret, req, use
}
//Tattoo hashed IP address to user account for seven days
userDB.tattooIPRecord(ip);
userDB.tattooIPRecord(req.ip);
if(!useRememberMeToken){
//If we got to here then the log-in was successful. We should clear-out any failed attempts.
failedAttempts.delete(identifier);
}
//If we got to here then the log-in was successful. We should clear-out any failed attempts.
failedAttempts.delete(user);
//return user
return userDB;
return userDB.user;
}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
//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);
//Look for previous failed attempts
var attempt = failedAttempts.get(user);
//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;
}
}
/**
* Logs user out and destroys all server-side traces of a given session
* @param {express-session.session} session
*/
module.exports.killSession = async function(session){
session.destroy();
}
/**
* Returns how many failed login attempts within the past day or so since the last login has occured for a given user
* @param {String} user - User to check map against
* @returns {Number} of failed login attempts
*/
module.exports.getLoginAttempts = function(user){
//Read the code, i'm not explaining this
return failedAttempts.get(user);
}
/**
* Nightly Function Call which iterates through the failed login attempts map, removing any which haven't been attempted in over a da yeahy
*/
module.exports.processExpiredAttempts = function(){
for(user of failedAttempts.keys()){
//Get attempt by user
@ -214,53 +147,5 @@ 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 == ""){
//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');
//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
next();
}
}else{
//Jump to next middleware
next();
}
}else{
//Jump to next middleware
next();
}
}
module.exports.throttleAttempts = throttleAttempts;
module.exports.maxAttempts = maxAttempts;

View file

@ -1,105 +0,0 @@
/*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 <https://www.gnu.org/licenses/>.*/
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;
if(user == null){
return null;
}
//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){
return null;
}
//Set socket user and channel values
socket.user = {
id: userDB.id,
user: userDB.user,
};
return userDB;
}
module.exports.getChannelName = function(socket){
return socket.handshake.headers.referer.split('/c/')[1].split('/')[0];
}

Some files were not shown because too many files have changed in this diff Show more