Compare commits

..

No commits in common. "main" and "0.3-indev-hotfix-1" have entirely different histories.

137 changed files with 898 additions and 5064 deletions

4
.gitignore vendored
View file

@ -1,5 +1,5 @@
node_modules/
log/*
log/crash/*
www/doc/*/*
package-lock.json
config.json
@ -8,5 +8,3 @@ state.json
chatexamples.txt
server.cert
server.key
www/nonfree/*
migration/*

View file

@ -1,20 +1,8 @@
Canopy
Canopy - 0.3-INDEV - Hotfix 1
======
<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:
@ -26,7 +14,7 @@ This new codebase intends to solve the following issues with the current CyTube
- 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.
Canopy intends to be a simple node/express.js app. It leverages yt-dlp and the internet archive REST api for metadata gathering. Persistant storage is handled by mongodb, as it's document based nature inherintly works well for cleanly storing large config documents for user/channel settings, and the low use of inter-collection references within the canopy software. All hardcore security functions like server-side input sanatization, session handling, CSRF mitigation, and password hashing are handled by industry-standard open source libraries such as validator/express-validator, express-sessions, csrf-sync, and bcrypt, however it IS hobbiest software, and it should be treated as such.
The Canopy codebase does not, nor will it ever contain:
- Advertisements (targetted or otherwise)
@ -34,11 +22,10 @@ The Canopy codebase does not, nor will it ever contain:
- Cryptocurrency/Blockchain integration
- 'Analytics/Telemtry' spyware
- The use of video sources which require proprietary 'Digital ~~Rights Management~~ Ristricitons Malware' such as Widevine.
- The use of large language models, stable diffusion, or generative AI in either development or function.
Thirdparty media providers may or may not contain all of the above atrocities :P (though browser-side DRM extensions will never be required), always use an ad-blocker!
Our current goal is to create a cleaner, more modern, purpose-built 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.
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,21 +1,14 @@
{
"instanceName": "Canopy",
"verbose": false,
"port": 8443,
"proxied": true,
"protocol": "https",
"port": 8080,
"proxied": false,
"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"
},
"sessionSecret": "CHANGE_ME",
"altchaSecret": "CHANGE_ME",
"ipSecret": "CHANGE_ME",
"ssl":{
"cert": "./server.cert",
"key": "./server.key"
@ -33,6 +26,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

@ -6,9 +6,9 @@
//Scream about exceptions in the console
"verbose": false,
//Port to bind to (most linux/unix systems req root for ports below 1000, you should probably use nginx if you want port 80 or 443)
"port": 8443,
"port": 8080,
//Lets the server know it's sitting behind a reverse-proxy
"proxied": true,
"proxied": false,
//Protocol (either HTTP or HTTPS)
"protocol": "http",
//Domain the server is available at, used for server-side link generation
@ -16,34 +16,14 @@
//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"
},
//Session secret used to secure session keys
"sessionSecret": "CHANGE_ME",
//Altacha secret used to generate altcha challenges
"altchaSecret": "CHANGE_ME",
//IP Secret used to salt IP Hashes
"ipSecret": "CHANGE_ME",
//SSL cert and key locations
"ssl":{
"cert": "./server.cert",
@ -64,7 +44,5 @@
"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

@ -1,17 +1,13 @@
{
"name": "canopy-of-alpha",
"version": "0.1",
"canopyDisplayVersion": "0.1-Alpha",
"name": "canopy-of",
"version": "0.3",
"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",
@ -20,7 +16,7 @@
"hls.js": "^1.6.2",
"mongoose": "^8.4.3",
"node-cron": "^3.0.3",
"nodemailer": "^7.0.9",
"nodemailer": "^6.9.16",
"socket.io": "^4.8.1",
"youtube-dl-exec": "^3.0.20"
},
@ -30,7 +26,7 @@
"build": "node node_modules/jsdoc/jsdoc.js --verbose -r src/ -R README.md -d www/doc/server/ && node node_modules/jsdoc/jsdoc.js --verbose -r www/js/channel -r README.md -d www/doc/client/"
},
"devDependencies": {
"jsdoc": "^4.0.4",
"nodemon": "^3.1.10"
"nodemon": "^3.1.10",
"jsdoc": "^4.0.4"
}
}

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

@ -74,7 +74,6 @@ class activeChannel{
* @param {Mongoose.Document} userDB - User Document Passthrough to save on DB Access
* @param {Mongoose.Document} chanDB - Channnel Document Passthrough to save on DB Access
* @param {Socket} socket - Requesting Socket
* @returns {activeUser} active user object generated by the new connection
*/
async handleConnection(userDB, chanDB, socket){
//get current user object from the userlist
@ -105,13 +104,10 @@ class activeChannel{
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;
}
/**
@ -119,11 +115,11 @@ class activeChannel{
* @param {Socket} socket - Requesting Socket
*/
handleDisconnect(socket){
//temporarily store userObj
let userObj = this.userList.get(socket.user.user);
//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 +127,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);
}

View file

@ -20,12 +20,12 @@ 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
@ -33,7 +33,7 @@ const queueBroadcastManager = require('./media/queueBroadcastManager');
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
* @param {Server} io - Socket.io server instanced passed down from server.js
*/
constructor(io){
/**
@ -46,26 +46,11 @@ class channelManager{
*/
this.activeChannels = new Map;
/**
* Map containing all active users. This may be redundant, however it improves preformance for user-specific inter-channel functionality
*/
this.activeUsers = new Map;
/**
* Global Chat Handler Object
*/
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) );
}
@ -77,7 +62,7 @@ class channelManager{
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 +70,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 +88,13 @@ class channelManager{
return;
}
//Connection accepted past this point
//Define listeners for inter-channel classes
this.defineListeners(socket);
this.chatHandler.defineListeners(socket);
//Hand off the connection to it's given active channel object
//Lil' hacky to pass chanDB like that, but why double up on DB calls?
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!"});
@ -157,13 +107,77 @@ class channelManager{
}
}
/**
* Global server-side validation logic for new connections to any channel
* @param {Socket} socket - Requesting Socket
* @returns {Boolean} true on success
*/
async validateSocket(socket){
//If we're proxied use passthrough IP
const ip = config.proxied ? socket.handshake.headers['x-forwarded-for'] : socket.handshake.address;
//Look for ban by IP
const ipBanDB = await userBanModel.checkBanByIP(ip);
//If this ip is randy bobandy
if(ipBanDB != null){
//Make the number a little prettier despite the lack of precision since we're not doing calculations here :P
const expiration = ipBanDB.getDaysUntilExpiration() < 1 ? 0 : ipBanDB.getDaysUntilExpiration();
//If the ban is permanent
if(ipBanDB.permanent){
//tell it to fuck off
socket.emit("kick", {type: "kicked", reason: `The IP address you are trying to connect from has been permanently banned. Your cleartext IP has been saved to the database. Any associated accounts will be nuked in ${expiration} day(s).`});
//Otherwise
}else{
//tell it to fuck off
socket.emit("kick", {type: "kicked", reason: `The IP address you are trying to connect from has been temporarily banned. Your cleartext IP has been saved to the database until the ban expires in ${expiration} day(s).`});
}
return false;
}
//Check for Cross-Site Request Forgery
if(!csrfUtils.isRequestValid(socket.request)){
socket.emit("kick", {type: "disconnected", reason: "Invalid CSRF Token!"});
return false;
}
return true;
}
/**
* Global server-side authorization logic for new connections to any channel
* @param {Socket} socket - Requesting Socket
* @returns {Mongoose.Document} - Authorized User Document upon success
*/
async authSocket(socket){
//Find the user in the Database since the session won't store enough data to fulfill our needs :P
const userDB = await userModel.findOne({user: socket.request.session.user.user});
if(userDB == null){
throw loggerUtils.exceptionSmith("User not found!", "unauthorized");
}
//Set socket user and channel values
socket.user = {
id: userDB.id,
user: userDB.user,
};
return userDB;
}
/**
* Gets active channel from a given socket
* @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);
socket.chan = socket.handshake.headers.referer.split('/c/')[1].split('/')[0];
const chanDB = (await channelModel.findOne({name: socket.chan}));
//Check if channel exists
@ -203,37 +217,6 @@ class channelManager{
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
@ -274,28 +257,30 @@ class channelManager{
* @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;

View file

@ -14,20 +14,49 @@ 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{
class chat{
/**
* Instantiates a chat message object
* @param {connectedUser} user - User who sent the message
* @param {String} flair - Flair ID String for the flair used to send the message
* @param {Number} highLevel - Number representing current high level
* @param {String} msg - Contents of the message, with links replaced with numbered file-seperator markers
* @param {String} type - Message Type Identifier, used for client-side processing.
* @param {Array} links - Array of URLs/Links included in the message.
*/
constructor(user, flair, highLevel, msg, type, links){
//Call derived constructor
super(user, flair, highLevel, msg, type, links);
/**
* User who sent the message
*/
this.user = user;
/**
* Flair ID String for the flair used to send the message
*/
this.flair = flair;
/**
* Number representing current high level
*/
this.highLevel = highLevel;
/**
* COntents of the message, with links replaced with numbered file-seperator marks
*/
this.msg = msg;
/**
* Message Type Identifier, used for client-side processing.
*/
this.type = type;
/**
* Array of URLs/Links included in the message.
*/
this.links = links;
}
}

View file

@ -18,9 +18,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
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');
@ -44,10 +42,7 @@ class chatHandler{
/**
* Child Command Pre-Processor Object
*/
this.chatPreprocessor = new chatPreprocessor(
new commandProcessor(server, this),
new tokebot(server, this)
);
this.commandPreprocessor = new commandPreprocessor(server, this)
/**
* Max chat buffer message count
@ -72,21 +67,8 @@ class chatHandler{
* @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);
}
/**

View file

@ -14,10 +14,163 @@ 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');
/**
* Class containing global server-side chat/command pre-processing logic
*/
class commandPreprocessor{
/**
* Instantiates a commandPreprocessor object
* @param {channelManager} server - Parent Server Object
* @param {chatHandler} chatHandler - Parent Chat Handler Object
*/
constructor(server, chatHandler){
/**
* Parent Server Object
*/
this.server = server;
/**
* Parent Chat Handler Object
*/
this.chatHandler = chatHandler;
/**
* Child Command Processor Object
*/
this.commandProcessor = new commandProcessor(server, chatHandler);
/**
* Child Tokebot Object
*/
this.tokebot = new tokebot(server, chatHandler);
}
/**
* Ingests a command/chat request from Chat Handler and pre-processes and processes it accordingly
* @param {Socket} socket - Socket we're receiving the request from
* @param {Object} data - Event payload
*/
async preprocess(socket, data){
//Set command object
const commandObj = {
socket,
sendFlag: true,
rawData: data,
chatType: 'chat'
}
//If we don't pass sanatization/validation turn this car around
if(!this.sanatizeCommand(commandObj)){
return;
}
//split the command
this.splitCommand(commandObj);
//Process the command
await this.processServerCommand(commandObj);
//If we're going to relay this command as a message, continue on to chat processing
if(commandObj.sendFlag){
//Prep the message
await this.prepMessage(commandObj);
//Send the chat
this.sendChat(commandObj);
}
}
/**
* Sanatizes and Validates a single user chat message/command
* @param {Object} commandObj - Object representing a single given command/chat request
* @returns {Boolean} false if Command/Message is too long to send
*/
sanatizeCommand(commandObj){
//Trim and Sanatize for XSS
commandObj.command = validator.trim(validator.escape(commandObj.rawData.msg));
//Return whether or not the shit was long enough
return (validator.isLength(commandObj.rawData.msg, {min: 1, max: 255}));
}
/**
* Splits raw chat/command data into seperate arrays, one by word-borders and words surrounded by word-borders
* These arrays are used to handle further command/chat processing
* @param {Object} commandObj - Object representing a single given command/chat request
*/
splitCommand(commandObj){
//Split string by words
commandObj.commandArray = commandObj.command.split(/\b/g);//Split by word-borders
commandObj.argumentArray = commandObj.command.match(/\b\w+\b/g);//Match by words surrounded by borders
}
/**
* Uses the server's Command Processor object to process the chat/command request.
* @param {Object} commandObj - Object representing a single given command/chat request
*/
async processServerCommand(commandObj){
//If the raw message starts with '!' (skip commands that start with whitespace so people can send example commands in chat)
if(commandObj.rawData.msg[0] == '!'){
//if it isn't just an exclimation point, and we have a real command
if(commandObj.argumentArray != null){
//If the command processor knows what to do with whatever the fuck the user sent us
if(this.commandProcessor[commandObj.argumentArray[0].toLowerCase()] != null){
//Process the command and use the return value to set the sendflag (true if command valid)
commandObj.sendFlag = await this.commandProcessor[commandObj.argumentArray[0].toLowerCase()](commandObj, this);
}else{
//Process as toke command if we didnt get a match from the standard server-side command processor
commandObj.sendFlag = await this.tokebot.tokeProcessor(commandObj);
}
}
}
}
/**
* Iterates through links in message and marks them by link type for later use by client-side post-processing
* @param {Object} commandObj - Object representing a single given command/chat request
*/
async markLinks(commandObj){
//Setup the links array
commandObj.links = [];
//For each link sent from the client
//this.rawData.links.forEach((link) => {
for (const link of commandObj.rawData.links){
//Add a marked link object to our links array
commandObj.links.push(await linkUtils.markLink(link));
}
}
/**
* Re-creates message string from processed Command Array
* @param {Object} commandObj - Object representing a single given command/chat request
*/
async prepMessage(commandObj){
//Create message from commandArray
commandObj.message = commandObj.commandArray.join('').trimStart();
//Validate links and mark them by embed type
await this.markLinks(commandObj);
}
/**
* Relays chat to channel via parent Chat Handler object
* @param {Object} commandObj - Object representing a single given command/chat request
*/
sendChat(commandObj){
//FUCKIN' SEND IT!
this.chatHandler.relayUserChat(commandObj.socket, commandObj.message, commandObj.chatType, commandObj.links);
}
}
/**
* Class representing global server-side chat/command processing logic
*/
@ -291,4 +444,4 @@ class commandProcessor{
}
}
module.exports = commandProcessor;
module.exports = commandPreprocessor;

View file

@ -91,7 +91,6 @@ class connectedUser{
* @param {Mongoose.Document} userDB - User Document Passthrough to save on DB Access
* @param {Mongoose.Document} chanDB - Channnel Document Passthrough to save on DB Access
* @param {Socket} socket - Requesting Socket
* @returns {activeUser} active user object generated by the new connection
*/
async handleConnection(userDB, chanDB, socket){
//send metadata to client
@ -116,10 +115,6 @@ class connectedUser{
//Tattoo hashed IP address to user account for seven days
await userDB.tattooIPRecord(socket.handshake.address);
}
//Return active user object for use by activeChannel and channelManager objects
return this;
}
/**
@ -226,6 +221,9 @@ class connectedUser{
}
});
//Get schedule as a temporary array
const queue = await this.channel.queue.prepQueue(chanDB);
//Get schedule lock status
const queueLock = this.channel.queue.locked;
@ -233,7 +231,7 @@ class connectedUser{
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, queue, queueLock, chatBuffer});
}
/**

View file

@ -26,9 +26,9 @@ class media{
* @param {String} id - Video ID from source (IE: youtube watch code/archive.org file path)
* @param {String} type - Original video source
* @param {Number} duration - Length of media in seconds
* @param {String} rawLink - URLs to raw file copies of media, not applicable to all sources, not saved to the DB
* @param {String} rawLink - URL to raw file copy of media, not applicable to all sources
*/
constructor(title, fileName, url, id, type, duration, rawLink){
constructor(title, fileName, url, id, type, duration, rawLink = url){
/**
* Chosen title of media
*/
@ -59,18 +59,10 @@ class media{
*/
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;
}
/**
* URL to raw file copy of media, not applicable to all sources
*/
this.rawLink = rawLink;
}
}

View file

@ -120,12 +120,12 @@ class playlistHandler{
*/
async addToPlaylistValidator(socket, url){
//If we where given a bad URL
if(typeof url != 'string' || !validator.isURL(url,{require_valid_protocol: true})){
if(typeof url != 'string' || !validator.isURL(url)){
//Attempt to fix the situation by encoding it
url = encodeURI(url);
//If it's still bad
if(typeof url != 'string' || !validator.isURL(url,{require_valid_protocol: true})){
if(typeof url != 'string' || !validator.isURL(url)){
//Bitch, moan, complain...
loggerUtils.socketErrorHandler(socket, "Bad URL!", "validation");
//and ignore it!

View file

@ -18,12 +18,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
const validator = require('validator');
//Local imports
const config = require('../../../../config.json');
const queuedMedia = require('./queuedMedia');
const yanker = require('../../../utils/media/yanker');
const loggerUtils = require('../../../utils/loggerUtils');
const channelModel = require('../../../schemas/channel/channelSchema');
const permissionModel = require('../../../schemas/permissionSchema');
/**
* Object represneting a single channel's media queue
@ -116,11 +114,6 @@ class queue{
socket.on("move", (data) => {this.moveMedia(socket, data)});
socket.on("lock", () => {this.toggleLock(socket)});
socket.on("goLive", (data) => {this.goLive(socket, data)});
//If debug mode is enabled
if(config.debug){
socket.on("dumpQueue", (data) => {this.dumpQueue(socket, data)});
}
}
//--- USER FACING QUEUEING FUNCTIONS ---
@ -139,12 +132,12 @@ class queue{
let url = data.url;
//If we where given a bad URL
if(!validator.isURL(url,{require_valid_protocol: true})){
if(!validator.isURL(url)){
//Attempt to fix the situation by encoding it
url = encodeURI(url);
//If it's still bad
if(!validator.isURL(url,{require_valid_protocol: true})){
if(!validator.isURL(url)){
//Bitch, moan, complain...
loggerUtils.socketErrorHandler(socket, "Bad URL!", "validation");
//and ignore it!
@ -411,44 +404,6 @@ class queue{
}
}
async dumpQueue(socket, data){
//If we somehow got here while config.debug is disabled, or the user isn't allowed to preform server-side debugging
if(!(config.debug && await permissionModel.permCheck(socket.user, "debug"))){
//FUCKIN' CHEESE IT!
return;
}
//If a full data dump was requested
if(data != null && data.full){
//Pull the channel DB doc
const chanDB = await channelModel.findOne({name:this.channel.name});
//Cook and emit a new object from all of the data
socket.emit("dumpQueue", {
cache: {
schedule: Array.from(this.schedule),
nowPlaying: this.nowPlaying
},
DB: {
schedule: chanDB.media.scheduled,
nowPlaying: chanDB.media.nowPlaying,
archived: chanDB.media.archived,
}
});
//DONE.
return;
}
//Otherwise, just dump whats in RAM
socket.emit("dumpQueue", {
cache: {
schedule: Array.from(this.schedule),
nowPlaying: this.nowPlaying
}
});
}
//--- INTERNAL USE ONLY QUEUEING FUNCTIONS ---
/**
* Clears and scheduling timers
@ -592,25 +547,9 @@ class queue{
//For each item
for(let item of foundItems){
//Remove media, passing down chanDB so we're not looking again and again
await this.removeMedia(item.uuid, socket, chanDB, true, true);
//If this is the current item
if(this.nowPlaying != null && item.uuid == this.nowPlaying.uuid){
//End it
await this.end(false, true, false, chanDB, true);
}
await this.removeMedia(item.uuid, socket, chanDB);
}
//Save data to channe database
await chanDB.save();
//Refresh next timer
await this.refreshNextTimer();
//Broadcast Queue
await this.broadcastQueue(chanDB);
}catch(err){
//If this was originated by someone
if(socket != null){
@ -658,8 +597,6 @@ class queue{
//Find our media, don't remove it yet since we want to do some more testing first
let media = this.getItemByUUID(uuid);
//Create value to hold old media in-case somethin' fucks up
let oldMedia = null;
//If we got a bad request
if(media == null){
@ -676,8 +613,6 @@ class queue{
if(media.startTime < new Date().getTime()){
//If the item is currently playing
if(media.getEndTime() > new Date().getTime()){
//Set old media
oldMedia = media;
//Dupe media for the rest of the function
media = media.clone();
@ -719,13 +654,6 @@ class queue{
//Reset the start time stamp for re-calculation
media.startTimeStamp = 0;
//If there's a cut-off from moving a now-playing item
if(oldMedia != null){
//Remove it from the queue to prevent ghost items
this.removeMedia(oldMedia.uuid, socket, chanDB, true);
}
//Schedule in old slot with noSave enabled
await this.scheduleMedia([media], socket, chanDB, true);
}
@ -748,11 +676,10 @@ class queue{
* @param {String} uuid - UUID of item to reschedule
* @param {Socket} socket - Requesting Socket
* @param {Mongoose.Document} chanDB - Channnel Document Passthrough to save on DB Access
* @param {Boolean} noScheduling - Disables schedule timer refresh and this.save() calls if true, good for internal function calls
* @param {Boolean} noBroadcast - Disables schedule broadcasting to clients, good for internal function calls
* @param {Boolean} noScheduling - Disables schedule timer refresh if true
* @returns {Media} Deleted Media Item
*/
async removeMedia(uuid, socket, chanDB, noScheduling = false, noBroadcast = false){
async removeMedia(uuid, socket, chanDB, noScheduling = false){
//If we're streamlocked
if(this.streamLock){
//If an originating socket was provided for this request
@ -799,17 +726,10 @@ class queue{
}
//Otherwise
}else{
//If broadcasting is enabled
if(!noBroadcast){
//Broadcast changes
this.broadcastQueue(chanDB);
}
//If saving is disabled
if(!noScheduling){
//Save changes to the DB
await chanDB.save();
}
//Broadcast changes
this.broadcastQueue(chanDB);
//Save changes to the DB
await chanDB.save();
}
}catch(err){
//If this was originated by someone
@ -867,18 +787,12 @@ class queue{
await chanDB.save();
}
//If broadcasting is enabled
if(!noBroadcast){
//Broadcast the channel
this.broadcastQueue(chanDB);
}
//Broadcast the channel
this.broadcastQueue(chanDB);
}catch(err){
//If broadcasting is enabled
if(!noBroadcast){
//Broadcast the channel
this.broadcastQueue();
}
//Broadcast the channel
this.broadcastQueue();
//If this was originated by someone
if(socket != null){
@ -1119,7 +1033,6 @@ class queue{
if(this.nowPlaying != null){
//Silently end the media in RAM so the database isn't stepping on itself up ahead
//Alternatively we could've used await, but then we'd be doubling up on DB transactions :P
//VOLATILE END
this.end(true, true, true);
}
@ -1158,9 +1071,6 @@ class queue{
//Save the channel
await chanDB.save();
//Broadcast queue since we ended our media item early
this.broadcastQueue(chanDB);
}catch(err){
loggerUtils.localExceptionHandler(err);
}
@ -1216,7 +1126,7 @@ class queue{
* End currently playing media
* @param {Boolean} quiet - Enable to prevent ending the media client-side
* @param {Boolean} noArchive - Enable to prevent ended media from being written to channel archive. Deletes media if Volatile is false
* @param {Boolean} volatile - Enable to prevent DB Transactions, also prevents queue broadcasting so the calling code can broadcast once it's done.
* @param {Boolean} volatile - Enable to prevent DB Transactions
* @param {Mongoose.Document} chanDB - Pass through Channel Document to save on DB Transactions
*/
async end(quiet = false, noArchive = false, volatile = false, chanDB){
@ -1254,13 +1164,6 @@ class queue{
return this.endLivestream(wasPlaying, chanDB)
}
//Moved this from the block below to prevent accidental over-caching
//We may need to throw this into it's own conditional if it causes issues
//Take it out of the active schedule
//Ultimitaly though, if something is voltaile it should handle saving chanDB on its own, so this should be a non-issue
this.schedule.delete(wasPlaying.startTime);
//If we're not in volatile mode and we're not ending a livestream
if(!volatile){
//If we wheren't handed a channel
@ -1281,6 +1184,9 @@ class queue{
await chanDB.media.nowPlaying.deleteOne();
}
//Take it out of the active schedule
this.schedule.delete(wasPlaying.startTime);
//If archiving is enabled
if(!noArchive){
//Add the item to the channel archive
@ -1292,6 +1198,9 @@ class queue{
//Save our changes to the DB
await chanDB.save();
}else{
//broadcast queue using unsaved archive
this.broadcastQueue(chanDB);
}
}catch(err){
this.broadcastQueue();
@ -1506,7 +1415,7 @@ class queue{
const mediaObj = entry[1];
//Remove media from queue without calling chanDB.save() to make room before we move everything
await this.removeMedia(mediaObj.uuid, null, chanDB, true, true);
await this.removeMedia(mediaObj.uuid, null, chanDB, true);
mediaObj.genUUID();
@ -1522,9 +1431,6 @@ class queue{
//Schedule the moved schedule, letting scheduleMedia save our changes for us, starting w/o saves to prevent over-saving
await this.scheduleMedia(newSched, null, chanDB);
//Broadcast the queue now that everything is hunky dory
await this.broadcastQueue(chanDB);
}catch(err){
//Null out live remainder for the next stream
this.liveRemainder = null;
@ -1701,18 +1607,7 @@ class queue{
* @param {Mongoose.Document} chanDB - Pass through Channel Document to save on DB Transactions
*/
async broadcastQueue(chanDB){
//Broadcast queue to authenticated sockets within the channels room inside the protected 'queue-broadcast' namespace
this.server.queueBroadcastManager.namespace.in(this.channel.name).emit('queue',{queue: await this.prepQueue(chanDB)});
}
/**
* Broadcasts channel queue to a single socket
* @param {Mongoose.Document} chanDB - Pass through Channel Document to save on DB Transactions
* @param {Socket} socket - Socket to send queue to
*/
async emitQueue(chanDB, socket){
//Broadcast queue to authenticated sockets within the channels room inside the protected 'queue-broadcast' namespace
socket.emit('queue',{queue: await this.prepQueue(chanDB)});
this.server.io.in(this.channel.name).emit('queue',{queue: await this.prepQueue(chanDB)});
}
/**
@ -1753,8 +1648,8 @@ class queue{
media.earlyEnd = null;
}
//Add it to the temporary schedule array as if it where part of the actual schedule map
schedule.unshift([media.startTime, media]);
//Add it to the schedule array as if it where part of the actual schedule map
schedule.push([media.startTime, media]);
//Otherwise if it's older
}else{
//Then we should be done as archived items are added as they are played/end.
@ -1882,9 +1777,7 @@ class queue{
chanDB.media.scheduled = newSched;
//Save the DB
await chanDB.save()
//End the media;
await chanDB.save();
//if something fucked up
}catch(err){

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

@ -16,8 +16,8 @@ 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 statSchema = require('../../schemas/statSchema');
/**
@ -190,8 +190,8 @@ class tokebot{
//Asynchronously tattoo the toke into the users documents within the database so that tokebot doesn't have to wait or worry about DB transactions
userModel.tattooToke(this.tokers);
//Do the same for the global 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;

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

@ -43,7 +43,7 @@ module.exports.post = async function(req, res){
//Check to make sure the user is logged in
if(req.session.user == null){
return errorHandler(res, "Invalid user!");
errorHandler(res, "Invalid user!");
}
//Authenticate and find user model from DB
@ -51,22 +51,11 @@ 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

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);
const {user, pass} = 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
//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

@ -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

@ -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');
@ -42,7 +39,7 @@ module.exports.get = async function(req, res){
throw loggerUtils.exceptionSmith("Channel not found.", "queue");
}
return res.render('channelSettings', {instance: config.instanceName, user: req.session.user, channel: chanDB, reqRank, rankEnum: permissionModel.rankEnum, csrfToken: csrfUtils.generateToken(req), 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

@ -17,11 +17,7 @@ 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');
@ -34,10 +30,7 @@ module.exports.get = async function(req, res){
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});
return res.render('partial/panels/profile', {profile});
}else{
res.status(400);
return res.send({errors: validResult.array()})

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

@ -17,9 +17,6 @@ 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');
@ -33,10 +30,10 @@ module.exports.get = async function(req, res){
const data = matchedData(req);
const profile = await userModel.findProfile({user: data.user});
return res.render('partial/tooltip/profile', {profile, unescape: validator.unescape});
return res.render('partial/tooltip/profile', {profile});
}else{
res.status(400);
return res.send({errors: validResult.array()});
return res.send({errors: validResult.array()})
}
}catch(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,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

@ -1,30 +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 migrateController = require("../controllers/migrateController");
//globals
const router = Router();
//routing functions
router.get('/', migrateController.get);
module.exports = router;

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

@ -25,7 +25,6 @@ const popoutContainerController = require("../controllers/panel/popoutContainerC
const profileController = require("../controllers/panel/profileController");
const queueController = require("../controllers/panel/queueController");
const settingsController = require("../controllers/panel/settingsController");
const pmController = require("../controllers/panel/pmController");
//Validators
const accountValidator = require("../validators/accountValidator");
@ -39,6 +38,5 @@ 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

@ -89,12 +89,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,

View file

@ -60,7 +60,7 @@ const channelSchema = new mongoose.Schema({
thumbnail: {
type: mongoose.SchemaTypes.String,
required: true,
default: "/nonfree/johnny.png"
default: "/img/johnny.png"
},
settings: {
hidden: {

View file

@ -32,11 +32,10 @@ const chatSchema = new mongoose.Schema({
},
flair: {
type: mongoose.SchemaTypes.String,
//Leave this as unreq'd for internal type chats that have no flair
required: true,
},
highLevel: {
type: mongoose.SchemaTypes.Number,
default: 0,
required: true,
},
msg: {

View file

@ -51,11 +51,8 @@ const emoteSchema = new mongoose.Schema({
* Post-Save function, ensures all new emotes are broadcastes to actively connected clients
*/
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();
});
/**

View file

@ -100,12 +100,6 @@ const permissionSchema = new mongoose.Schema({
type: channelPermissionSchema,
default: () => ({})
},
debug: {
type: mongoose.SchemaTypes.String,
enum: rankEnum,
default: "admin",
required: true
},
});
//Statics

View file

@ -19,8 +19,6 @@ 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
@ -46,16 +44,22 @@ 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
@ -92,11 +96,9 @@ 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}.`);
}
/**
@ -135,4 +137,63 @@ statSchema.statics.incrementChannelCount = async function(){
return oldCount;
}
/**
* Tattoo's toke to the server statistics document
* @param {Map} toke - Tokemap handed down from Tokebot
*/
statSchema.statics.tattooToke = async function(toke){
//Get the statistics document
const stats = await this.getStats();
//Add the toke to the stat document
stats.tokes.push({toke});
//Save the stat document
await stats.save();
}
/**
* Gets toke count from statistics document
* @returns {Number} Number of tokes across the entire site
*/
statSchema.statics.getTokeCount = async function(){
//get stats doc
const stats = await this.getStats();
//return toke count
return stats.tokes.length;
}
/**
* Gets toke counts for each individual callout in a map
* @returns {Map} Map of toke counts for each individual callout registered to the server
*/
statSchema.statics.getTokeCommandCounts = async function(){
//get stats doc
const stats = await this.getStats()
//Create empty map to hold toke command counts
const count = new Map();
//for each toke
stats.tokes.forEach((toke) => {
//For each toke command called in the current toke
toke.toke.forEach((command) => {
//Get the current count for the current command
var curCount = count.get(command);
//if the current count is null
if(curCount == null){
//Set it to one
count.set(command, 1);
}else{
//Set it to ++curCount
count.set(command, ++curCount);
}
});
});
//return the toke command count
return count;
}
module.exports = mongoose.model("statistics", statSchema);

View file

@ -36,20 +36,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);
}
//If tokebot is up and running
if(tokebot != null && tokebot.tokeCommands != null){
//Pop the command on to the end
tokebot.tokeCommands.push(this.command);
}
}
@ -63,7 +58,7 @@ tokeCommandSchema.pre('save', async function (next){
*/
tokeCommandSchema.pre('deleteOne', {document: true}, async function (next){
//Get server tokebot object (isn't this a fun dot crawler? Why hasn't anyone asked me to stop writing software yet?)
const tokebot = server.channelManager.chatHandler.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);

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

@ -52,7 +52,7 @@ const emailChangeSchema = new mongoose.Schema({
type: mongoose.SchemaTypes.String,
required: true,
//Use a cryptographically secure algorythm to create a random hex string from 16 bytes as our change/cancel token
default: ()=>{return crypto.randomBytes(32).toString('hex')}
default: ()=>{return crypto.randomBytes(16).toString('hex')}
},
ipHash: {
type: mongoose.SchemaTypes.String,

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

@ -48,7 +48,7 @@ const passwordResetSchema = new mongoose.Schema({
type: mongoose.SchemaTypes.String,
required: true,
//Use a cryptographically secure algorythm to create a random hex string from 16 bytes as our reset token
default: ()=>{return crypto.randomBytes(32).toString('hex')}
default: ()=>{return crypto.randomBytes(16).toString('hex')}
},
ipHash: {
type: mongoose.SchemaTypes.String,

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

@ -73,6 +73,8 @@ const userBanSchema = new mongoose.Schema({
* @returns {Mongoose.Document} Found ban Document if one exists.
*/
userBanSchema.statics.checkBanByIP = async function(ip){
//Get hash of ip
const ipHash = hashUtil.hashIP(ip);
//Get all bans
const banDB = await this.find({});
//Create null variable to hold any found ban
@ -104,7 +106,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;

View file

@ -22,13 +22,11 @@ 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');
@ -62,11 +60,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,7 +74,7 @@ const userSchema = new mongoose.Schema({
img: {
type: mongoose.SchemaTypes.String,
required: true,
default: "/nonfree/johnny.png"
default: "/img/johnny.png"
},
bio: {
type: mongoose.SchemaTypes.String,
@ -165,7 +158,7 @@ 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
@ -186,11 +179,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.");
}
@ -232,18 +220,6 @@ 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
@ -255,31 +231,11 @@ userSchema.statics.register = async function(userObj, ip){
//Check password confirmation matches
if(pass == passConfirm){
//Setup user query
let userQuery = {user: new RegExp(user, 'i')};
//If we have an email
if(email != null && email != ""){
userQuery = {$or: [
userQuery,
{email: new RegExp(email, 'i')}
]};
}
//Look for a user (case insensitive)
var userDB = await this.findOne(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"){
if(userDB || user.toLowerCase() == "tokebot"){
throw loggerUtils.exceptionSmith("User name/email already taken!", "validation");
}else{
//Increment the user count, pulling the id to tattoo to the user
@ -293,11 +249,9 @@ 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{
@ -327,7 +281,7 @@ 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
@ -356,11 +310,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!"
};
@ -498,8 +451,8 @@ userSchema.statics.processAgedIPRecords = async function(){
* @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)
}
/**
@ -547,7 +500,6 @@ userSchema.methods.getProfile = function(includeEmail = false){
id: this.id,
user: this.user,
date: this.date,
lastActive: this.lastActive,
tokes: this.tokes,
tokeCount: this.getTokeCount(),
img: this.img,
@ -757,6 +709,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 +718,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,7 +755,7 @@ 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;
}
}
@ -811,9 +765,6 @@ userSchema.methods.tattooIPRecord = async function(ip){
* @param {String} reason - Reason to kill user sessions
*/
userSchema.methods.killAllSessions = async function(reason = "A full log-out from all devices was requested for your account."){
//Nuke all related remember me tokens
await rememberMeModel.deleteMany({user: this._id});
//get authenticated sessions
var sessions = await this.getAuthenticatedSessions();
@ -831,7 +782,7 @@ userSchema.methods.killAllSessions = async function(reason = "A full log-out fro
* @param {Object} passChange - passChange object handed down from Browser
*/
userSchema.methods.changePassword = async function(passChange){
if(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;
@ -884,7 +835,7 @@ userSchema.methods.nuke = async function(pass){
}
//Check that the password is correct
if(await this.checkPass(pass)){
if(this.checkPass(pass)){
//delete the user
var oldUser = await this.deleteOne();
}else{

View file

@ -25,7 +25,6 @@ const fs = require('fs');
const express = require('express');
const session = require('express-session');
const {createServer } = require('http');
const cookieParser = require('cookie-parser');
const { Server } = require('socket.io');
const path = require('path');
const mongoStore = require('connect-mongo');
@ -34,27 +33,20 @@ const mongoose = require('mongoose');
//Define Local Imports
//Application
const channelManager = require('./app/channel/channelManager');
const pmHandler = require('./app/pm/pmHandler');
//Util
const configCheck = require('./utils/configCheck');
const scheduler = require('./utils/scheduler');
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,7 +55,6 @@ 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
@ -75,10 +66,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,14 +80,10 @@ 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
@ -143,14 +132,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 +139,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 +147,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,7 +158,6 @@ app.use('/c', channelRouter);
app.use('/newChannel', newChannelRouter);
app.use('/passwordReset', passwordResetRouter);
app.use('/emailChange', emailChangeRouter);
app.use('/migrate', migrateRouter);
//Panel
app.use('/panel', panelRouter);
//tooltip
@ -195,55 +165,39 @@ app.use('/tooltip', tooltipRouter);
//Bot-Ready
app.use('/api', apiRouter);
//Static File Server
//Serve client-side libraries
app.use('/lib/bootstrap-icons',express.static(path.join(__dirname, '../node_modules/bootstrap-icons'))); //Icon set
app.use('/lib/altcha',express.static(path.join(__dirname, '../node_modules/altcha/dist_external'))); //Self-Hosted PoW-based Captcha
app.use('/lib/hls.js',express.static(path.join(__dirname, '../node_modules/hls.js/dist'))); //HLS Media Handler
//Server public 'www' folder
app.use(express.static(path.join(__dirname, '../www')));
//Handle error checking
app.use(errorMiddleware);
//Basic 404 handler
app.use(fileNotFoundController);
asyncKickStart();
//Increment launch counter
statModel.incrementLaunchCount();
/*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!`);
//Load default flairs
flairModel.loadDefaults();
//Run legacy migration
await migrationModel.ingestLegacyDump();
//Load default emotes
emoteModel.loadDefaults();
//Build migration cache
await migrationModel.buildMigrationCache();
//Load default toke commands
tokeCommandModel.loadDefaults();
//Calculate Toke Map
await tokeModel.calculateTokeMap();
//Kick off scheduled-jobs
scheduler.kickoff();
//Load default toke commands
await tokeCommandModel.loadDefaults();
//Hand over general-namespace socket.io connections to the channel manager
module.exports.channelManager = new channelManager(io)
//Load default flairs
await flairModel.loadDefaults();
//Load default emotes
await emoteModel.loadDefaults();
//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
webServer.listen(port, () => {
console.log(`Opening port ${port}`);
});

View file

@ -44,7 +44,7 @@ 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
});
@ -73,5 +73,5 @@ module.exports.verify = async function(payload, uniqueSecret = ''){
setTimeout(() => {spent.splice(payloadIndex,1);}, lifetime * 60 * 1000);
//Return verification results
return await verifySolution(payload, [config.secrets.altchaSecret, uniqueSecret].join(''));
return await verifySolution(payload, [config.altchaSecret, uniqueSecret].join(''));
}

View file

@ -40,28 +40,18 @@ 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"){
if(!validator.isStrongPassword(config.ipSecret) || config.ipSecret == "CHANGE_ME"){
loggerUtil.consoleWarn("Insecure IP Hashing Secret! Change IP Hashing Secret!");
}
@ -74,9 +64,4 @@ module.exports.securityCheck = function(){
if(!validator.isStrongPassword(config.mail.pass) || config.mail.pass == "CHANGE_ME"){
loggerUtil.consoleWarn("Insecure Email Password! Change Email password!");
}
//check debug mode
if(config.debug){
loggerUtil.consoleWarn("Debug mode enabled! Understand the risks and security implications before enabling on production servers!");
}
}

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

@ -21,7 +21,6 @@ const config = require('../../config.json');
const crypto = require('node:crypto');
//NPM Imports
const argon2 = require('argon2');
const bcrypt = require('bcrypt');
/**
@ -29,29 +28,18 @@ const bcrypt = require('bcrypt');
* @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
* Sitewide password 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);
}
@ -60,67 +48,15 @@ module.exports.compareLegacyPassword = function(pass, hash){
*
* Provides a basic level of privacy by only logging salted hashes of IP's
* @param {String} ip - IP to hash
* @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
* @returns {String} Hashed/Salted IP Adress
*/
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 and salt to the hash
hashObj.update(`${ip}${config.ipSecret}`);
//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,7 +16,6 @@ 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
/**
@ -26,12 +25,10 @@ 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
* @param {String} link - 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 +44,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

@ -16,7 +16,6 @@ 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');
@ -65,8 +64,8 @@ module.exports.errorHandler = function(res, msg, type = "Generic", status = 400)
* @param {Error} err - Exception to handle
*/
module.exports.localExceptionHandler = function(err){
//If we're being verbose and this isn't just a basic bitch
if(!err.custom && config.verbose){
//If we're being verbose
if(config.verbose){
//Log the error
module.exports.dumpError(err);
}
@ -173,90 +172,18 @@ module.exports.errorMiddleware = function(err, req, res, next){
* Dumps unexpected server crashes to dedicated log files
* @param {Error} err - error to dump to file
* @param {Date} date - Date of error, defaults to now
* @param {String} subDir - subdirectory inside the log folder we want to dump to
* @param {Boolean} muzzle - Tells the function to STFU
*/
module.exports.dumpError = async function(err, date = new Date(), subDir = '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){
module.exports.dumpError = function(err, date = new Date()){
try{
//Crash directory
const dir = `./log/${subDir}`
const content = `Error Date: ${date.toLocaleString()} (UTC-${date.getTimezoneOffset()/60})\nError Type: ${err.name}\nError Msg:${err.message}\nStack Trace:\n\n${err.stack}`;
const path = `log/crash/${date.getTime()}.log`;
//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
module.exports.consoleWarn(`Warning: Unexpected Server Crash gracefully dumped to '${path}'... SOMETHING MAY BE VERY BROKEN!!!!`);
}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.consoleWarn("Yo Dawg, I herd you like errors, so I put an error in your error dump, so you can dump while you dump:");
module.exports.consoleWarn(err);
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,11 +19,6 @@ const config = require('../../config.json');
//NPM imports
const nodeMailer = require("nodemailer");
const validator = require('validator');
//local imports
const loggerUtils = require('./loggerUtils');
//Setup mail transport
/**
@ -48,38 +43,28 @@ const transporter = nodeMailer.createTransport({
* @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;
}
/**
@ -87,40 +72,16 @@ module.exports.mailem = async function(to, subject, body, htmlBody = false){
* @param {Mongoose.Document} requestDB - DB Document Object for the current email change request token
* @param {Mongoose.Document} userDB - DB Document Object for the user we're verifying email against
* @param {String} newEmail - New email address to send to
* @param {Boolean} newUser - Denotes an email going out to a new account
* @param {Boolean} migration - Denotes an email going out to an account which was just mirated
*/
module.exports.sendAddressVerification = async function(requestDB, userDB, newEmail, 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

@ -17,7 +17,6 @@ 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');
@ -67,41 +66,24 @@ module.exports.yankMedia = async function(url, title){
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;
//Scrape expiration from query strings
const expires = mediaObj.rawLink.match(/expire=([0-9]+)/);
//Went with regex for speed, but I figure I'd keep this around in case we want the accuracy of a battle-tested implementation
//const expires = new URL(mediaObj.rawLink).searchParams.get("expire");
//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 we have a valid raw file link that will be good by the end of the video
if(expires != null && (expires * 1000) > mediaObj.getEndTime()){
//Return null to tell the calling function there is no refresh required for this video at this time
return null;
}
//If the raw link object is empty or expired
if(empty || expired){
//Re-fetch media metadata
metadata = await ytdlpUtil.fetchYoutubeMetadata(mediaObj.id);
//Re-fetch media metadata
metadata = await ytdlpUtil.fetchYoutubeMetadata(mediaObj.id);
//Refresh media rawlink from metadata
mediaObj.rawLink = metadata[0].rawLink;
//Refresh media rawlink from metadata
mediaObj.rawLink = metadata[0].rawLink;
//return media object
return mediaObj;
}
//return media object
return mediaObj;
}
//Return null to tell the calling function there is no refresh required for this media type
@ -114,15 +96,12 @@ module.exports.refreshRawLink = async function(mediaObj){
* Still this has some improvements like url pre-checks and the fact that it's handled serverside, recuing possibility of bad requests.
* Some of the regex expressions for certain services have also been improved, such as youtube, and the fore.st-unique archive.org
*
* @param {String} dirtyURL - URL to determine media type of
* @param {String} url - 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);
module.exports.getMediaType = async function(url){
//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(!validator.isURL(encodeURI(url))){
//If not toss the fucker out
return {
type: null,

View file

@ -100,7 +100,7 @@ module.exports.fetchDailymotionMetadata = async function(id, title){
* @param {String} type - Link type to attach to the resulting media object
* @returns {Array} Array of Media objects containing relevant metadata
*/
async function fetchVideoMetadata(link, title, type, format = 'ba,bv'){
async function fetchVideoMetadata(link, title, type, format = 'b'){
//Create media list
const mediaList = [];
@ -109,40 +109,16 @@ async function fetchVideoMetadata(link, title, type, format = 'ba,bv'){
//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 rawLink = rawMetadata.requested_downloads[0].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));
mediaList.push(new media(name, name, link, id, type, Number(rawMetadata.duration), rawLink));
}else{
//Create new media object from file info
mediaList.push(new media(title, name, link, id, type, Number(rawMetadata.duration), rawLinks));
mediaList.push(new media(title, name, link, id, type, Number(rawMetadata.duration), rawLink));
}
//Return list of media
@ -160,10 +136,10 @@ async function fetchVideoMetadata(link, title, type, format = 'ba,bv'){
* @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'){
async function ytdlpFetch(link, format = 'b'){
//return promise from ytdlp
return ytdlp(link, {
format,
dumpSingleJson: true,
format
});
}

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

@ -22,9 +22,9 @@ const {userModel} = require('../schemas/user/userSchema');
const userBanModel = require('../schemas/user/userBanSchema');
const passwordResetModel = require('../schemas/user/passwordResetSchema');
const emailChangeModel = require('../schemas/user/emailChangeSchema');
const rememberMeModel = require('../schemas/user/rememberMeSchema');
const channelModel = require('../schemas/channel/channelSchema');
const sessionUtils = require('./sessionUtils');
const { email } = require('../validators/accountValidator');
/**
* Schedules all timed jobs accross the server
@ -42,8 +42,6 @@ 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"});
}
/**
@ -60,8 +58,6 @@ module.exports.kickoff = function(){
passwordResetModel.processExpiredRequests();
//Process expired email change requests that may have expired since last restart
emailChangeModel.processExpiredRequests();
//Process expired remember me tokens that may have expired since last restart
rememberMeModel.processExpiredTokens()
//Schedule jobs
module.exports.schedule();

View file

@ -14,14 +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/>.*/
//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 userBanModel = require('../schemas/user/userBanSchema.js')
const altchaUtils = require('../utils/altchaUtils.js');
const loggerUtils = require('../utils/loggerUtils.js');
@ -45,19 +41,16 @@ 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 {String} user - Username to login as
* @param {String} pass - Password to authenticat session with
* @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){
module.exports.authenticateSession = async function(user, pass, req){
//Fuck you yoda
try{
//Grab previous attempts
const attempt = failedAttempts.get(identifier);
const attempt = failedAttempts.get(user);
//If we're proxied use passthrough IP
const ip = config.proxied ? req.headers['x-forwarded-for'] : req.ip;
@ -81,7 +74,7 @@ module.exports.authenticateSession = async function(identifier, secret, req, use
}
//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");
@ -93,23 +86,14 @@ module.exports.authenticateSession = async function(identifier, secret, req, use
//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)){
}else if(!altchaUtils.verify(req.body.verification, user)){
throw loggerUtils.exceptionSmith("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);
@ -139,40 +123,33 @@ module.exports.authenticateSession = async function(identifier, secret, req, use
//Tattoo hashed IP address to user account for seven days
userDB.tattooIPRecord(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;
}
@ -214,53 +191,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];
}

View file

@ -16,7 +16,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
//NPM Imports
const { checkSchema } = require('express-validator');
const {sanitizeUrl} = require("@braintree/sanitize-url");
//local imports
const {isRank} = require('./permissionsValidator');
@ -100,15 +99,11 @@ module.exports.img = function(field = 'img'){
isURL: {
options: {
require_tld: false,
require_host: false,
require_valid_protocol: true
require_host: false
},
errorMessage: "Invalid URL."
},
trim: true,
customSanitizer: {
options: sanitizeUrl
}
trim: true
}
});
}
@ -177,41 +172,19 @@ module.exports.rank = function(field = 'rank'){
});
}
const securityTokenSchema = {
escape: true,
trim: true,
isHexadecimal: true,
isLength: {
options: {
min: 64,
max: 64
}
},
errorMessage: "Invalid security token."
}
module.exports.securityToken = function(field = 'token'){
return checkSchema({[field]:securityTokenSchema});
}
module.exports.rememberMeID = function(field = 'rememberme.id'){
return checkSchema({
[field]:{
in: ['cookies'],
optional: true,
isUUID: true
[field]: {
escape: true,
trim: true,
isHexadecimal: true,
isLength: {
options: {
min: 32,
max: 32
}
},
errorMessage: "Invalid security token."
}
})
}
module.exports.rememberMeToken = function(field = 'rememberme.token'){
//Create our own schema with blackjack and hookers
const tokenSchema = structuredClone(securityTokenSchema);
//Modify as needed
tokenSchema.in = ['cookies'];
tokenSchema.optional = true;
//Return the validator
return checkSchema({[field]:tokenSchema});
});
}

View file

@ -83,11 +83,7 @@ module.exports.settingsMap = function(){
},
'settingsMap.streamURL': {
optional: true,
isURL: {
options:{
require_valid_protocol: true
}
},
isURL: true,
errorMessage: "Invalid Stream URL"
}
})

View file

@ -48,8 +48,7 @@ module.exports.link = function(field = 'link'){
isURL: {
options: {
require_tld: false,
require_host: false,
require_valid_protocol: true
require_host: false
},
errorMessage: "Invalid URL."
},
@ -77,7 +76,7 @@ module.exports.manualLink = function(input){
const clean = validator.trim(input)
//If we have a URL return the trimmed input
if(validator.isURL(clean,{require_valid_protocol: true})){
if(validator.isURL(clean)){
return clean;
}

View file

@ -1,51 +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/>. %>
<!DOCTYPE html>
<html>
<head>
<%- include('partial/styles', {instance, user}); %>
<%- include('partial/csrfToken', {csrfToken}); %>
<link rel="stylesheet" type="text/css" href="css/about.css">
<title><%= instance %> - about</title>
</head>
<body>
<%- include('partial/navbar', {user}); %>
<div id="about-div">
<h1>About <%= instance %></h1>
<div class="dynamic-container" id="about-text">
<h2>About <%= instance %></h2>
<%# It's not XSS if the text came from the config made by the server admin. If you can't trust that, you're already fucked.%>
<p><%- aboutText %></p>
<h2>About Canopy</h2>
<p>Canopy is the software behind <%= instance %>. Originally written by rainbownapkin for the founding instance,
<a href="https://ourfore.st">ourfore.st</a>. Ourfore.st was originally a cytube instance, set up after the 2021
shutdown of TTN, a movie watching/weed smoking community related to the <a href="https://reddit.com/r/trees">r/trees</a>
subreddit.
<br>
<br>
After a years of service, thousands of lines worth of stapled on modifications, the shutdown of sister sites,
it was decided that the original cytube fork, fore.st, had been run past it's prime. In summer/fall 2024, work began on a
replacement. The resulting software became <a href="https://git.ourfore.st/rainbownapkin/canopy">Canopy</a>, which was
first used to run the ourfore.st instance in late 2025.</p>
<br>
<h2>Canopy Ver: <%= version %></h2>
</div>
</div>
</body>
<footer>
<%- include('partial/scripts', {user}); %>
</footer>
</html>

View file

@ -25,8 +25,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. %>
<%- include('partial/navbar', {user}); %>
<h1 class="panel-title"><%= instance %> Admin Panel</h1>
<div class="admin-panel-container-div">
<%- include('partial/adminPanel/channelList', {chanGuide, unescape}) %>
<%- include('partial/adminPanel/userList', {user, userList, rankEnum, unescape}) %>
<%- include('partial/adminPanel/channelList', {chanGuide}) %>
<%- include('partial/adminPanel/userList', {user, userList, rankEnum}) %>
<%- include('partial/adminPanel/permList', {permList, rankEnum}) %>
<%- include('partial/adminPanel/userBanList') %>
<%- include('partial/adminPanel/tokeCommandList') %>

View file

@ -47,14 +47,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. %>
<script src="/js/channel/userlist.js"></script>
<script src="/js/channel/mediaHandler.js"></script>
<script src="/js/channel/player.js"></script>
<script src="/js/channel/pmHandler.js"></script>
<script src="/js/channel/cpanel.js"></script>
<%# panels %>
<script src="/js/channel/panels/emotePanel.js"></script>
<script src="/js/channel/panels/queuePanel/playlistManager.js"></script>
<script src="/js/channel/panels/queuePanel/queuePanel.js"></script>
<script src="/js/channel/panels/settingsPanel.js"></script>
<script src="/js/channel/panels/pmPanel.js"></script>
<%# main client %>
<script src="/js/channel/channel.js"></script>
</footer>

View file

@ -24,13 +24,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. %>
</head>
<body>
<%- include('partial/navbar', {user}); %>
<h1 class="panel-title"><%= unescape(channel.name) %> - Channel Settings</h1>
<h1 class="panel-title"><%- channel.name %> - Channel Settings</h1>
<div class="admin-panel-container-div">
<%- include('partial/channelSettings/info.ejs', {unescape, channel}); %>
<%- include('partial/channelSettings/info.ejs', {channel}); %>
<%- include('partial/channelSettings/userList.ejs'); %>
<%- include('partial/channelSettings/banList.ejs'); %>
<%- include('partial/channelSettings/settings.ejs', {unescape, channel}); %>
<%- include('partial/channelSettings/permList.ejs', {channel}); %>
<%- include('partial/channelSettings/settings.ejs'); %>
<%- include('partial/channelSettings/permList.ejs'); %>
<%- include('partial/channelSettings/tokeCommandList.ejs'); %>
<%- include('partial/channelSettings/emoteList.ejs'); %>
</div>

View file

@ -26,11 +26,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. %>
<h3><a href="/newchannel">Start a new channel...</a></h3>
<div id="channel-guide-div" class="channel-guide">
<% chanGuide.forEach((channel) => { %>
<div id="channel-guide-entry-<%= unescape(channel.name) %>" class="channel-guide-entry">
<a href="/c/<%= unescape(channel.name) %>" class="channel-guide-entry channel-guide-entry-item"><img id="channel-guide-entry-img-<%= unescape(channel.name) %>" class="channel-guide-entry channel-guide-entry-item" src="<%= encodeURI(unescape(channel.thumbnail)) %>"></a>
<h3 id="channel-guide-entry-name-<%= unescape(channel.name) %>" class="channel-guide-entry channel-guide-entry-item"><a href="/c/<%= encodeURI(unescape(channel.name)) %>" class="channel-guide-entry channel-guide-entry-item"><%= unescape(channel.name) %></a></h3>
<span id="channel-guide-entry-description-span-<%= unescape(channel.name) %>" class="channel-guide-entry channel-guide-entry-item">
<p id="channel-guide-entry-description-<%= unescape(channel.name) %>" class="channel-guide-entry channel-guide-entry-item"><%= unescape(channel.description) %></h3>
<div id="channel-guide-entry-<%- channel.name %>" class="channel-guide-entry">
<a href="/c/<%- channel.name %>" class="channel-guide-entry channel-guide-entry-item"><img id="channel-guide-entry-img-<%- channel.name %>" class="channel-guide-entry channel-guide-entry-item" src="<%- channel.thumbnail %>"></a>
<h3 id="channel-guide-entry-name-<%- channel.name %>" class="channel-guide-entry channel-guide-entry-item"><a href="/c/<%- channel.name %>" class="channel-guide-entry channel-guide-entry-item"><%- channel.name %></a></h3>
<span id="channel-guide-entry-description-span-<%- channel.name %>" class="channel-guide-entry channel-guide-entry-item">
<p id="channel-guide-entry-description-<%- channel.name %>" class="channel-guide-entry channel-guide-entry-item"><%- channel.description %></h3>
</span>
</div>
<% }); %>

View file

@ -38,7 +38,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. %>
<% if(challenge != null){ %>
<altcha-widget challengejson="<%= JSON.stringify(challenge) %>"></altcha-widget>
<% } %>
<span><label>Remember Me:</label><input class="login-page" id="login-page-remember-me" type="checkbox"></span>
<a href="/register">Create New Account</a>
<a href="/passwordReset">Forgot Password</a>
<button id="login-page-button" class='positive-button'>Login</button>

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/>. %>
<!DOCTYPE html>
<html>
<head>
<%- include('partial/styles', {instance, user}); %>
<%- include('partial/csrfToken', {csrfToken}); %>
<link rel="stylesheet" type="text/css" href="/css/migrate.css">
<link rel="stylesheet" type="text/css" href="/lib/altcha/altcha.css">
<title><%= instance %> - Account Migration</title>
</head>
<body>
<%- include('partial/navbar', {user}); %>
<h1>Welcome Back!</h1>
<h2><%= instance%> has received an update, and your account needs one too!</h2>
<h2 class="danger-text">Remember your new password, you will need it for your first login!</h2>
<form action="javascript:">
<label>Username:</label>
<input class="migrate-prompt" id="migrate-username" placeholder="Required">
<label>Old Password:</label>
<input class="migrate-prompt" id="migrate-password-old" placeholder="Required" type="password">
<label>Password:</label>
<input class="migrate-prompt" id="migrate-password" placeholder="Required" type="password">
<label>Confirm Password:</label>
<input class="migrate-prompt" id="migrate-password-confirm" placeholder="Required" type="password">
<altcha-widget floating challengejson="<%= JSON.stringify(challenge) %>"></altcha-widget>
<button id="migrate-button" class='positive-button'>migrate</button>
</form>
</body>
<footer>
<%- include('partial/scripts', {user}); %>
<script src="/js/migrate.js"></script>
<script src="/lib/altcha/altcha.js" type="module"></script>
</footer>
</html>

View file

@ -29,19 +29,19 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. %>
</td>
</tr>
<% chanGuide.forEach((channel) => { %>
<tr id="admin-channel-list-entry-<%= unescape(channel.name) %>" class="">
<td id="admin-channel-list-entry-img-<%= unescape(channel.name) %>" class=" admin-list-entry-item">
<a href="/c/<%= encodeURI(unescape(channel.name)) %>" class=" admin-list-entry-item">
<img id="admin-channel-list-entry-img-<%= unescape(channel.name) %>" class=" admin-list-entry-item" src="<%= encodeURI(unescape(channel.thumbnail)) %>">
<tr id="admin-channel-list-entry-<%- channel.name %>" class="">
<td id="admin-channel-list-entry-img-<%- channel.name %>" class=" admin-list-entry-item">
<a href="/c/<%- channel.name %>" class=" admin-list-entry-item">
<img id="admin-channel-list-entry-img-<%- channel.name %>" class=" admin-list-entry-item" src="<%- channel.thumbnail %>">
</a>
</td>
<td id="admin-channel-list-entry-name-<%= unescape(channel.name) %>" class=" admin-list-entry-item not-first-col">
<a href="/c/<%= unescape(channel.name) %>" class=" admin-list-entry-item">
<%= unescape(channel.name) %>
<td id="admin-channel-list-entry-name-<%- channel.name %>" class=" admin-list-entry-item not-first-col">
<a href="/c/<%- channel.name %>" class=" admin-list-entry-item">
<%- channel.name %>
</a>
</td>
<td id="admin-channel-list-entry-description-<%= unescape(channel.name) %>" class="large admin-list-entry-item not-first-col">
<%= unescape(channel.description) %>
<td id="admin-channel-list-entry-description-<%- channel.name %>" class="large admin-list-entry-item not-first-col">
<%- channel.description %>
</td>
</tr>
<% }); %>

View file

@ -20,10 +20,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. %>
<% Object.keys(permList).forEach((key)=>{ %>
<% if(key != "channelOverrides"){ %>
<span class="admin-list-field-container">
<label class="admin-list-label admin-perm-list" for="admin-perm-list-rank-select-<%= key %>"><%= key %>: </label>
<select name="admin-perm-list-rank-select-<%= key %>" data-key="<%= key %>" class="admin-list-select admin-perm-list-rank-select">
<label class="admin-list-label admin-perm-list" for="admin-perm-list-rank-select-<%- key %>"><%- key %>: </label>
<select name="admin-perm-list-rank-select-<%- key %>" data-key="<%- key %>" class="admin-list-select admin-perm-list-rank-select">
<%rankEnum.slice().reverse().forEach((rank)=>{ %>
<option <%if(permList[key] == rank){%> selected <%}%> value="<%= rank %>"><%= rank %></option>
<option <%if(permList[key] == rank){%> selected <%}%> value="<%- rank %>"><%- rank %></option>
<% }); %>
</select>
</span>
@ -33,10 +33,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. %>
<% Object.keys(permList.channelOverrides).forEach((key)=>{ %>
<% if(key != "channelOverrides"){ %>
<span class="admin-list-field-container">
<label class="admin-list-label admin-chan-perm-list" for="admin-chan-perm-list-rank-select-<%= key %>"><%= key %>: </label>
<select name="admin-chan-perm-list-rank-select-<%= key %>" data-key="<%= key %>" class="admin-list-select admin-chan-perm-list-rank-select">
<label class="admin-list-label admin-chan-perm-list" for="admin-chan-perm-list-rank-select-<%- key %>"><%- key %>: </label>
<select name="admin-chan-perm-list-rank-select-<%- key %>" data-key="<%- key %>" class="admin-list-select admin-chan-perm-list-rank-select">
<%rankEnum.slice().reverse().forEach((rank)=>{ %>
<option <%if(permList.channelOverrides[key] == rank){%> selected <%}%> value="<%= rank %>"><%= rank %></option>
<option <%if(permList.channelOverrides[key] == rank){%> selected <%}%> value="<%- rank %>"><%- rank %></option>
<% }); %>
</select>
</span>

View file

@ -41,42 +41,42 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. %>
</td>
</tr>
<% userList.forEach((curUser) => { %>
<tr id="admin-user-list-entry-<%= unescape(curUser.user) %>" class="admin-list-entry" data-name="<%= unescape(curUser.user) %>" >
<tr id="admin-user-list-entry-<%- curUser.user %>" class="admin-list-entry" data-name="<%- curUser.user %>" >
<td class="admin-list-entry-item">
<a href="/profile/<%= unescape(curUser.user) %>" class="admin-list-entry-item">
<img class="admin-list-entry-item" src="<%= encodeURI(unescape(curUser.img)) %>">
<a href="/profile/<%- curUser.user %>" class="admin-list-entry-item">
<img class="admin-list-entry-item" src="<%- curUser.img %>">
</a>
</td>
<td class="admin-list-entry-item not-first-col">
<a href="/profile/<%= encodeURI(unescape(curUser.user)) %>" class="admin-list-entry-item">
<%= curUser.id %>
<a href="/profile/<%- curUser.user %>" class="admin-list-entry-item">
<%- curUser.id %>
</a>
</td>
<td class="admin-list-entry-item not-first-col">
<a href="/profile/<%= encodeURI(unescape(curUser.user)) %>" class="admin-list-entry-item admin-user-list-name">
<%= unescape(curUser.user) %>
<a href="/profile/<%- curUser.user %>" class="admin-list-entry-item admin-user-list-name">
<%- curUser.user %>
</a>
</td>
<td class="admin-list-entry-item not-first-col">
<% if(rankEnum.indexOf(curUser.rank) < rankEnum.indexOf(user.rank)){%>
<select id="admin-user-list-rank-select-<%= unescape(curUser.user) %>" class="admin-user-list-rank-select">
<select id="admin-user-list-rank-select-<%- curUser.user %>" class="admin-user-list-rank-select">
<%rankEnum.slice().reverse().forEach((rank)=>{ %>
<option <%if(curUser.rank == rank){%> selected <%}%> value="<%= rank %>"><%= rank %></option>
<option <%if(curUser.rank == rank){%> selected <%}%> value="<%- rank %>"><%- rank %></option>
<% }); %>
</select>
<% }else{ %>
<%= curUser.rank %>
<%- curUser.rank %>
<% } %>
</td>
<td class="admin-list-entry-item not-first-col">
<%= unescape(curUser.email) ? curUser.email : "N/A" %>
<%- curUser.email ? curUser.email : "N/A" %>
</td>
<td class="admin-list-entry-item not-first-col">
<%= unescape(curUser.date.toUTCString()) %>
<%- curUser.date.toUTCString() %>
</td>
<td class="admin-list-entry-item not-first-col">
<%# It's either this or add whitespce >:( %>
<i class="bi-radioactive admin-user-list-icon admin-user-list-nuke-icon" title="Nuke Account: <%= unescape(curUser.user) %>"></i><i class="bi-fire admin-user-list-icon admin-user-list-ban-icon" title="Ban User: <%= unescape(curUser.user) %>"></i><i class="bi-arrow-clockwise admin-user-list-icon admin-user-list-pw-reset-icon" title="Generate Password Reset Link for <%= unescape(curUser.user) %>"></i>
<i class="bi-radioactive admin-user-list-icon admin-user-list-nuke-icon" title="Nuke Account: <%- curUser.user %>"></i><i class="bi-fire admin-user-list-icon admin-user-list-ban-icon" title="Ban User: <%- curUser.user %>"></i><i class="bi-arrow-clockwise admin-user-list-icon admin-user-list-pw-reset-icon" title="Generate Password Reset Link for <%- curUser.user %>"></i>
</td>
</tr>
<% }); %>

View file

@ -74,8 +74,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. %>
</div>
<div class="chat-panel control-prompt" id="chat-panel-control-div">
<i class="chat-panel chat-panel-control control-prompt bi-gear-fill" id="chat-panel-settings-icon"></i>
<i class="chat-panel chat-panel-control control-prompt bi-chat-left-quote-fill" id="chat-panel-pm-icon"></i>
<i class="chat-panel chat-panel-control control-prompt bi-magic" id="chat-panel-admin-icon" style="display:none;"></i>
<i class="chat-panel chat-panel-control control-prompt bi-magic" id="chat-panel-admin-icon"></i>
<i class="chat-panel chat-panel-control control-prompt bi-images" id="chat-panel-emote-icon"></i>
<span id="chat-panel-prompt-span">
<p id="chat-panel-prompt-autocomplete" class="chat-panel"><span id="chat-panel-prompt-autocomplete-filler"></span><span id="chat-panel-prompt-autocomplete-display"></span></p>

View file

@ -19,13 +19,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. %>
<span class="channel-info-span" id="channel-info-thumbnail-span">
<p class="channel-info-label">Thumbnail:</p>
<div>
<input value="<%= encodeURI(unescape(channel.thumbnail)) %>" placeholder="Thumbnail URL" style="display: none;" id="channel-info-thumbnail-prompt">
<img class="interactive" src="<%= encodeURI(unescape(channel.thumbnail)) %> " id="channel-info-thumbnail">
<input value="<%= channel.thumbnail %>" placeholder="Thumbnail URL" style="display: none;" id="channel-info-thumbnail-prompt">
<img class="interactive" src="<%= channel.thumbnail %> " id="channel-info-thumbnail">
</div>
</span>
<span class="channel-info-span" id="channel-info-description-span">
<p class="channel-info-label">Description:</p>
<p class="interactive" id="channel-info-description"><%= unescape(channel.description) %></p>
<p class="interactive" id="channel-info-description"><%= channel.description %></p>
<span>
</div>
</div>

View file

@ -20,11 +20,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. %>
<% Object.keys(channel.permissions.toObject()).forEach((key)=>{ %>
<% if(key != "channelOverrides"){ %>
<span class="admin-list-field-container">
<%# These strings are generated internally server-side. There really isn't much of a reason to sanatize them.%>
<label class="admin-list-label admin-perm-list" for="admin-perm-list-rank-select-<%= key %>"><%= key %>: </label>
<select data-key="<%= key %>" name="admin-perm-list-rank-select-<%= key %>" class="channel-perm-select admin-list-select admin-perm-list-rank-select">
<label class="admin-list-label admin-perm-list" for="admin-perm-list-rank-select-<%- key %>"><%- key %>: </label>
<select data-key="<%- key %>" name="admin-perm-list-rank-select-<%- key %>" class="channel-perm-select admin-list-select admin-perm-list-rank-select">
<%rankEnum.slice().reverse().forEach((rank)=>{ %>
<option <%if(channel.permissions.toObject()[key] == rank){%> selected <%}%> value="<%= rank %>"><%= rank %></option>
<option <%if(channel.permissions.toObject()[key] == rank){%> selected <%}%> value="<%- rank %>"><%- rank %></option>
<% }); %>
</select>
</span>

View file

@ -19,13 +19,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. %>
<form action="javascript:" class="admin-list-field">
<% Object.keys(channel.settings).forEach((key) => { %>
<span class="admin-list-field-container">
<label class="admin-list-label"><%= key %>:</label>
<label class="admin-list-label"><%- key %>:</label>
<% switch(typeof channel.settings[key]){
case "string": %>
<input data-key="<%= key %>" class="channel-preference-list-item" value="<%= channel.settings[key] %>">
<input data-key="<%- key %>" class="channel-preference-list-item" value="<%- channel.settings[key] %>">
<% break;
default: %>
<input data-key="<%= key %>" class="channel-preference-list-item" type="checkbox" <% if(channel.settings[key]){ %> checked <% } %>>
<input data-key="<%- key %>" class="channel-preference-list-item" type="checkbox" <% if(channel.settings[key]){ %> checked <% } %>>
<% break;
} %>
</span>

View file

@ -14,19 +14,14 @@ 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/>. %>
<div id="navbar">
<span class="navbar-item">
<%#<a class="navbar-item" href="/" id="instance-title">%><%#= instance %><%#</a><p class="navbar-item"> - <a class="navbar-item" href="/about">about</a></p>%>
<a class="navbar-item" href="/" id="instance-title"><%= instance %></a>
</span>
<p class="navbar-item" id="instance-title"><a href="/" class="navbar-item"><%= instance %></a></p>
<span class="navbar-item" id="right-controls">
<% if(user){ %>
<p class="navbar-item">Welcome, <a class="navbar-item" id="username" href="/profile"><%= user.user %></a> - <% if(user.rank == "admin"){ %><a href="/adminPanel" title="Admin Panel" class="bi bi-server navbar-item"></a> - <% } %> <a class="navbar-item" href="/about">About</a> - <a class="navbar-item" href="javascript:" id="logout-button">Logout</a></p>
<p class="navbar-item">Welcome, <a class="navbar-item" id="username" href="/profile"><%= user.user %></a> - <% if(user.rank == "admin"){ %><a href="/adminPanel" title="Admin Panel" class="bi bi-server navbar-item"></a> <% } %><a class="navbar-item" href="javascript:" id="logout-button">logout</a></p>
<% }else{ %>
<p class="navbar-item">Remember Me:</p>
<input class="navbar-item login-prompt" id="remember-me" type="checkbox">
<input class="navbar-item login-prompt" id="username-prompt" placeholder="username">
<input class="navbar-item login-prompt" id="password-prompt" placeholder="password" type="password">
<p class="navbar-item"><a class="navbar-item" href="javascript:" id="login-button">Login</a> - <a class="navbar-item" href="/passwordReset">Forgot Password</a> - <a class="navbar-item" href="/register">Register</a> - <a class="navbar-item" href="/about">About</a></p>
<p class="navbar-item"><a class="navbar-item" href="javascript:" id="login-button">Login</a> - <a class="navbar-item" href="/passwordReset">Forgot Password</a> - <a class="navbar-item" href="/register">Register</a></p>
<% } %>
</span>
</div>

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/>. %>
<link rel="stylesheet" type="text/css" href="/css/panel/pm.css">
<div id="pm-panel-main-div">
<div id="pm-panel-sesh-list-container">
<div class="interactive" id="pm-panel-start-sesh">
<i class="bi-person-plus-fill"></i>
<span>Start Sesh</span>
</div>
<div id="pm-panel-sesh-list">
</div>
</div>
<div id="pm-panel-sesh-container">
<div id="pm-panel-sesh-buffer">
<div id="pm-panel-sesh-welcome">
<h1>Start a sesh to start chatting!</h1>
</div>
</div>
<div class="control-prompt" id="pm-panel-sesh-control-div">
<input class="control-prompt" id="pm-panel-message-prompt" placeholder="Chat..." disabled>
<button class="positive-button" id="pm-panel-send-button" disabled>Send</button>
</div>
</div>
</div>
</div>

View file

@ -18,22 +18,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. %>
<% if(profile == null){ %>
<p>Profile not found!</p>
<% }else{ %>
<% const splitBio = profile.bio.split('\n'); %>
<a class="panel profile-link" target="_blank" href="/profile/<%= encodeURI(unescape(profile.user)) %>">View Full Profile<i class="bi-box-arrow-in-up-right"></i></a>
<h2 class="panel profile-name"><%= unescape(profile.user) %></h2>
<%- include('../profile/status', {profile, presence, auxClass:"panel", unescape}); %>
<img class="panel profile-img" src="<%= encodeURI(unescape(profile.img)) %>">
<div class="dynamic-container panel profile-box">
<p class="panel profile-info">Toke Count: <%= profile.tokeCount %></p>
<% if(profile.pronouns != '' && profile.pronouns != null){ %>
<p class="panel profile-info">Pronouns: <%= unescape(profile.pronouns) %></p>
<% } %>
<p class="panel profile-info">Signature: <%= unescape(profile.signature) %></p>
<p class="panel profile-bio">
<% for(const line of splitBio){ %>
<%= unescape(line) %><br>
<% } %>
</p>
</div>
<a class="panel profile-link" target="_blank" href="/profile/<%- profile.user %>">View Full Profile<i class="bi-box-arrow-in-up-right"></i></a>
<h2 class="panel profile-name"><%- profile.user %></h2>
<img class="panel profile-img" src="<%- profile.img %>">
<p class="panel profile-info">Toke Count: <%- profile.tokeCount %></p>
<% if(profile.pronouns != '' && profile.pronouns != null){ %>
<p class="panel profile-info">Pronouns: <%- profile.pronouns %></p>
<% } %>
<p class="panel profile-info">Signature: <%- profile.signature %></p>
<p class="panel profile-bio-label">Bio:</p>
<p class="panel profile-bio"><%- profile.bio %></p>
<% } %>
</div>

View file

@ -20,7 +20,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. %>
<span id="settings-panel-youtube-source" class="settings-panel-setting">
<p>Youtube Player Type: </p>
<select>
<option value="raw">Raw File Playback (Expiremental)</option>
<option value="raw">Raw File Playback</option>
<option value="embed">Official Embed</option>
</select>
</span>
@ -45,26 +45,5 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. %>
<p>Aspect-Ratio Lock Chat Width Minimum: </p>
<input type="number">
</span>
<h4>Notification Settings</h4>
<span id="settings-panel-ping-on-pm-rx" class="settings-panel-setting">
<p>Play Sound for received PMs: </p>
<select>
<option value="all">All</option>
<option value="unread">Unread Only</option>
<option value="never">Never</option>
</select>
</span>
<span id="settings-panel-ping-on-pm-tx" class="settings-panel-setting">
<p>Play sound for sent PMs: </p>
<input type="checkbox">
</span>
<span id="settings-panel-ping-on-new-sesh" class="settings-panel-setting">
<p>Play sound on new PM sesh: </p>
<input type="checkbox">
</span>
<span id="settings-panel-ping-on-end-sesh" class="settings-panel-setting">
<p>Play sound on PM sesh end: </p>
<input type="checkbox">
</span>
</div>
</div>

View file

@ -15,23 +15,11 @@ 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/>. %>
<span class="profile-bio-span">
<h4 id="profile-bio-label" class="profile-item-label">Bio:</h4>
<%
//Split bio by newline
const splitBio = profile.bio.split('\n');
%>
<% if(selfProfile){ %>
<%# Make sure to convert newlines to br so they display proepr %>
<p class="profile-item interactive" id="profile-bio-content">
<% for(const line of splitBio){ %>
<%= unescape(line) %><br>
<% } %>
</p>
<p class="profile-item interactive" id="profile-bio-content"><%- profile.bio.replaceAll('\n','<br>') %></p>
<textarea class="profile-item-prompt" id="profile-bio-prompt"></textarea>
<% }else{ %>
<p class="profile-item" id="profile-bio-content">
<% for(const line of splitBio){ %>
<%= unescape(line) %><br>
<% } %>
</p>
<p class="profile-item" id="profile-bio-content"><%- profile.bio.replaceAll('\n','<br>') %></p>
<% } %>
</span>

View file

@ -14,5 +14,5 @@ GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. %>
<span class="profile-item">
<p class="profile-item" id="profile-creation-date" title="<%= profile.date.toUTCString() %>">Joined: <%= profile.date.toLocaleDateString(); %></p>
<p class="profile-item" id="profile-creation-date" title="<%- profile.date.toUTCString() %>">Joined: <%- profile.date.toLocaleDateString(); %></p>
</span>

View file

@ -14,7 +14,7 @@ GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. %>
<div class="profile-item" id="profile-img">
<img class="profile-item" id="profile-img-content" src="<%= encodeURI(unescape(profile.img)) %>">
<img class="profile-item" id="profile-img-content" src="<%- profile.img %>">
<% if(selfProfile){ %>
<input class="profile-item-prompt" id="profile-img-prompt">
<% } %>

View file

@ -24,10 +24,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. %>
<% }else if(profile.pronouns != null && profile.pronouns != ""){ %>
<span class="profile-item profile-item-oneliner">
<% if(selfProfile){ %>
<p class="profile-item profile-item-oneliner" id="profile-pronouns">Pronouns: <span class="profile-content interactive" id="profile-pronouns-content"><%= unescape(profile.pronouns) %></span></p>
<p class="profile-item profile-item-oneliner" id="profile-pronouns">Pronouns: <span class="profile-content interactive" id="profile-pronouns-content"><%- profile.pronouns %></span></p>
<input class="profile-item-prompt" id="profile-pronouns-prompt">
<% }else{ %>
<p class="profile-item profile-item-oneliner" id="profile-pronouns">Pronouns: <span class="profile-content" id="profile-pronouns-content"><%= unescape(profile.pronouns) %></span></p>
<p class="profile-item profile-item-oneliner" id="profile-pronouns">Pronouns: <span class="profile-content" id="profile-pronouns-content"><%- profile.pronouns %></span></p>
<% } %>
</span>
<% } %>

View file

@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. %>
<h3 class="account-settings" id="account-settings-label">Account Settings</h3>
<% if(profile.email){ %>
<h4 class="account-settings" id="account-email-label">Email Address:</h3>
<h4 class="account-settings" id="account-email-address"><%= unescape(profile.email) %></h4>
<h4 class="account-settings" id="account-email-address"><%= profile.email %></h4>
<% } %>
<span class="account-settings" id="account-settings-buttons">
<button href="javascript:" class="account-settings positive-button" id="account-settings-update-email-button">Update Email</button>

View file

@ -15,9 +15,9 @@ 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/>. %>
<span class="profile-item">
<% if(selfProfile){ %>
<p class="profile-item profile-item-oneliner" id="profile-signature">Signature: <span class="profile-content interactive" id="profile-signature-content"><%= unescape(profile.signature) %></span></p>
<p class="profile-item profile-item-oneliner" id="profile-signature">Signature: <span class="profile-content interactive" id="profile-signature-content"><%- profile.signature %></span></p>
<input class="profile-item-prompt" id="profile-signature-prompt">
<% }else{ %>
<p class="profile-item profile-item-oneliner" id="profile-signature">Signature: <span class="profile-content" id="profile-signature-content"><%= unescape(profile.signature) %></span></p>
<p class="profile-item profile-item-oneliner" id="profile-signature">Signature: <span class="profile-content" id="profile-signature-content"><%- profile.signature %></span></p>
<% } %>
</span>

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