Compare commits
111 commits
0.3-indev-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60338eaeb1 | ||
|
|
27ab1c2c71 | ||
|
|
c3d435a0a3 | ||
|
|
ed656cb530 | ||
|
|
37e9658d51 | ||
|
|
dd4d789d9f | ||
|
|
ecebcf0d32 | ||
|
|
eaca9db987 | ||
|
|
fe957e4083 | ||
|
|
e7558d7d9d | ||
|
|
ce7be33a67 | ||
|
|
e94914a152 | ||
|
|
bd8a03d72f | ||
|
|
be01417cdf | ||
|
|
d7c55fe3da | ||
|
|
001945a284 | ||
|
|
affdd98572 | ||
|
|
eb5475ec9d | ||
|
|
a8a5357b05 | ||
|
|
08fe051269 | ||
|
|
35fd81e1b2 | ||
|
|
ade2a4210d | ||
|
|
75301ec7d9 | ||
|
|
dd36b1d923 | ||
|
|
c3712bfd77 | ||
|
|
366766d0a3 | ||
|
|
02c4d214fa | ||
|
|
a70879c76c | ||
|
|
b57d723d62 | ||
|
|
ccb1d91a5b | ||
|
|
a59b6d0e19 | ||
|
|
e0832c2c1f | ||
|
|
349a6b82aa | ||
|
|
a1f0824330 | ||
|
|
dd66601f0d | ||
|
|
787846c7d6 | ||
|
|
166e174397 | ||
|
|
37990ff8c3 | ||
|
|
f95a0ae48c | ||
|
|
db3ec58ad9 | ||
|
|
b8de76b448 | ||
|
|
7cda9517d4 | ||
|
|
1bd9fcdc80 | ||
|
|
a34ece4374 | ||
|
|
79df27b72c | ||
|
|
f9ac076e6f | ||
|
|
875baa833f | ||
|
|
b620b423f6 | ||
|
|
a68bc6a7da | ||
|
|
aa32587259 | ||
|
|
57787f81e7 | ||
|
|
6d16ac2353 | ||
|
|
5eb307bb9e | ||
|
|
e88ef29852 | ||
|
|
597a984e46 | ||
|
|
1e48d3af2c | ||
|
|
d874f5e2da | ||
|
|
bc0657a702 | ||
|
|
3fb71ffb78 | ||
|
|
1d5a087d79 | ||
|
|
61ec3ffc52 | ||
|
|
e00e5a608b | ||
|
|
95ed2fa403 | ||
|
|
5caa679b92 | ||
|
|
895a8201a5 | ||
|
|
7f6abdf8e2 | ||
|
|
06f552a9ec | ||
|
|
6bab5b4723 | ||
|
|
eb48b92551 | ||
|
|
bddbd9cd36 | ||
|
|
cb3fc9bb91 | ||
|
|
6ae652b47c | ||
|
|
66ec2fabc5 | ||
|
|
6cbb726764 | ||
|
|
da9428205f | ||
|
|
a1b602efdb | ||
|
|
42ad17072f | ||
|
|
e9b9539477 | ||
|
|
a231c8fc4c | ||
|
|
bb2a1369a3 | ||
|
|
ad0dd6bdbb | ||
|
|
9fda308306 | ||
|
|
4698ba4122 | ||
|
|
3d2b40b3c8 | ||
|
|
64348b8486 | ||
|
|
e85fb18ce5 | ||
|
|
976e157cf1 | ||
|
|
53fc46adc3 | ||
|
|
64f9d713da | ||
|
|
2feea72694 | ||
|
|
faf72fd7a5 | ||
|
|
e1cdca2b96 | ||
|
|
6f89f36fb8 | ||
|
|
ad3cdd38a3 | ||
|
|
0ed1c0dd89 | ||
|
|
23ad679473 | ||
|
|
57db26a827 | ||
|
|
b26dd1094c | ||
|
|
d465863ee6 | ||
|
|
e81a4c0973 | ||
|
|
f109314163 | ||
|
|
a681bddbf7 | ||
|
|
e2020406a7 | ||
|
|
d8e5c64c13 | ||
|
|
e19ae74412 | ||
|
|
67edef9035 | ||
|
|
7da07c8717 | ||
|
|
d541dce8c4 | ||
|
|
1384b02f4d | ||
|
|
6445950f90 | ||
|
|
6222535c47 |
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1,5 +1,5 @@
|
|||
node_modules/
|
||||
log/crash/*
|
||||
log/*
|
||||
www/doc/*/*
|
||||
package-lock.json
|
||||
config.json
|
||||
|
|
@ -8,3 +8,5 @@ state.json
|
|||
chatexamples.txt
|
||||
server.cert
|
||||
server.key
|
||||
www/nonfree/*
|
||||
migration/*
|
||||
19
README.md
19
README.md
|
|
@ -1,8 +1,20 @@
|
|||
Canopy - 0.3-INDEV - Hotfix 1
|
||||
Canopy
|
||||
======
|
||||
|
||||
<img src="https://img.shields.io/badge/developed-while%20high-339933">
|
||||
<img src="https://pride-badges.pony.workers.dev/static/v1?label=enbyware&labelColor=%23555&stripeWidth=8&stripeColors=FCF434%2CFFFFFF%2C9C59D1%2C2C2C2C">
|
||||
<img src="https://pride-badges.pony.workers.dev/static/v1?label=trans%20rights&stripeWidth=6&stripeColors=5BCEFA,F5A9B8,FFFFFF,F5A9B8,5BCEFA">
|
||||
<img src="https://pride-badges.pony.workers.dev/static/v1?label=Sponsored+by+the+Gay+Agenda&labelColor=%23555&stripeWidth=8&stripeColors=E40303%2CFF8C00%2CFFED00%2C008026%2C24408E%2C732982">
|
||||
<a href="https://git.ourfore.st/rainbownapkin/canopy/issues" target="_blank"><img src="https://git.ourfore.st/rainbownapkin/canopy/badges/issues/open.svg"></a>
|
||||
<a href="https://git.ourfore.st/rainbownapkin/canopy/issues" target="_blank"><img src="https://git.ourfore.st/rainbownapkin/canopy/badges/issues/closed.svg"></a>
|
||||
<a href="https://www.gnu.org/licenses/agpl-3.0.en.html" target="_blank"><img src="https://img.shields.io/badge/License-AGPL_v3-663366.svg"></a>
|
||||
|
||||
0.1-Alpha
|
||||
=========
|
||||
|
||||
Canopy - /ˈkæ.nə.pi/:
|
||||
- The upper layer of foliage and branches of a forest, containing the majority of animal life.
|
||||
- An honest attempt at a freedom/privacy respecting, libre, and open-source refrence implementation of what a stoner streaming service can be.
|
||||
|
||||
Canopy is a community chat & synced video embedding web application, intended to replace fore.st as the server software for ourfore.st.
|
||||
This new codebase intends to solve the following issues with the current CyTube based software:
|
||||
|
|
@ -14,7 +26,7 @@ This new codebase intends to solve the following issues with the current CyTube
|
|||
- General Clunk
|
||||
- Less Unique Community Identity
|
||||
|
||||
Canopy intends to be a simple node/express.js app. It leverages yt-dlp and the internet archive REST api for metadata gathering. Persistant storage is handled by mongodb, as it's document based nature inherintly works well for cleanly storing large config documents for user/channel settings, and the low use of inter-collection references within the canopy software. All hardcore security functions like server-side input sanatization, session handling, CSRF mitigation, and password hashing are handled by industry-standard open source libraries such as validator/express-validator, express-sessions, csrf-sync, and bcrypt, however it IS hobbiest software, and it should be treated as such.
|
||||
Canopy is a simple node/express.js app, leveraging yt-dlp and the internet archive REST api for metadata gathering. Persistant storage is handled by mongodb, as it's document based nature inherintly works well for cleanly storing large config documents for user/channel settings, and the low use of inter-collection references within the canopy software. All hardcore security functions like server-side input sanatization, session handling, CSRF mitigation, and password hashing are handled by industry-standard open source libraries such as validator/express-validator, express-sessions, csrf-sync, and argon2, however it IS hobbiest software, and it should be treated as such.
|
||||
|
||||
The Canopy codebase does not, nor will it ever contain:
|
||||
- Advertisements (targetted or otherwise)
|
||||
|
|
@ -22,10 +34,11 @@ The Canopy codebase does not, nor will it ever contain:
|
|||
- Cryptocurrency/Blockchain integration
|
||||
- 'Analytics/Telemtry' spyware
|
||||
- The use of video sources which require proprietary 'Digital ~~Rights Management~~ Ristricitons Malware' such as Widevine.
|
||||
- The use of large language models, stable diffusion, or generative AI in either development or function.
|
||||
|
||||
Thirdparty media providers may or may not contain all of the above atrocities :P (though browser-side DRM extensions will never be required), always use an ad-blocker!
|
||||
|
||||
Our current goal is to create a cleaner, more modern, purpose-built codebase that has feature-parity with the current version of fore.st, while writing improvements where possible. Once this is accomplished, and ourfore.st has been migrated, work will continue to re-create features from TTN, while also building completely new ones as well.
|
||||
Our current goal is to create a cleaner, more modern, purpose-built back-end that has feature-parity with the current version of fore.st, writing improvements where possible. Paired with this functionality, are a mix of engineering and artistic choices which attempt to re-create the power-user friendly UX of desktop sites from the early 2010's, with the 'aged like wine' looks that late oughts/early web 2.0 designs graced us with. Making sure that pageloads are low, and GPU use is non-existant along the way, to ensure everything is usable, even on low-end machines.
|
||||
|
||||
## License
|
||||
Canopy is written by the community, and provided under the GNU Affero General Public License v3 in order to prevent Canopy from being used in proprietary software or shitcoin scams.
|
||||
|
|
@ -1,14 +1,21 @@
|
|||
{
|
||||
"instanceName": "Canopy",
|
||||
"verbose": false,
|
||||
"port": 8080,
|
||||
"proxied": false,
|
||||
"protocol": "http",
|
||||
"port": 8443,
|
||||
"proxied": true,
|
||||
"protocol": "https",
|
||||
"domain": "localhost",
|
||||
"ytdlpPath": "/home/canopy/.local/pipx/venvs/yt-dlp/bin/yt-dlp",
|
||||
"sessionSecret": "CHANGE_ME",
|
||||
"altchaSecret": "CHANGE_ME",
|
||||
"ipSecret": "CHANGE_ME",
|
||||
"migrate": false,
|
||||
"dropLegacyTokes": false,
|
||||
"debug": false,
|
||||
"secrets":{
|
||||
"passwordSecret": "CHANGE_ME",
|
||||
"rememberMeSecret": "CHANGE_ME",
|
||||
"sessionSecret": "CHANGE_ME",
|
||||
"altchaSecret": "CHANGE_ME",
|
||||
"ipSecret": "CHANGE_ME"
|
||||
},
|
||||
"ssl":{
|
||||
"cert": "./server.cert",
|
||||
"key": "./server.key"
|
||||
|
|
@ -26,5 +33,6 @@
|
|||
"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."
|
||||
}
|
||||
|
|
@ -6,9 +6,9 @@
|
|||
//Scream about exceptions in the console
|
||||
"verbose": false,
|
||||
//Port to bind to (most linux/unix systems req root for ports below 1000, you should probably use nginx if you want port 80 or 443)
|
||||
"port": 8080,
|
||||
"port": 8443,
|
||||
//Lets the server know it's sitting behind a reverse-proxy
|
||||
"proxied": false,
|
||||
"proxied": true,
|
||||
//Protocol (either HTTP or HTTPS)
|
||||
"protocol": "http",
|
||||
//Domain the server is available at, used for server-side link generation
|
||||
|
|
@ -16,14 +16,34 @@
|
|||
//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
|
||||
//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",
|
||||
"secrets":{
|
||||
//Password secret used to pepper password hashes
|
||||
"passwordSecret": "CHANGE_ME",
|
||||
//Password secret used to pepper rememberMe token hashes
|
||||
"rememberMeSecret": "CHANGE_ME",
|
||||
//Session secret used to secure session keys
|
||||
"sessionSecret": "CHANGE_ME",
|
||||
//Altacha secret used to generate altcha challenges
|
||||
"altchaSecret": "CHANGE_ME",
|
||||
//IP Secret used to pepper IP Hashes
|
||||
"ipSecret": "CHANGE_ME"
|
||||
},
|
||||
//SSL cert and key locations
|
||||
"ssl":{
|
||||
"cert": "./server.cert",
|
||||
|
|
@ -44,5 +64,7 @@
|
|||
"secure": true,
|
||||
"address": "toke@42069.weed",
|
||||
"pass": "CHANGE_ME"
|
||||
}
|
||||
},
|
||||
//Fills the 'about ${instanceName}' section on the /about page, lets users know about your specific instance
|
||||
"aboutText":"<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."
|
||||
}
|
||||
14
package.json
14
package.json
|
|
@ -1,13 +1,17 @@
|
|||
{
|
||||
"name": "canopy-of",
|
||||
"version": "0.3",
|
||||
"name": "canopy-of-alpha",
|
||||
"version": "0.1",
|
||||
"canopyDisplayVersion": "0.1-Alpha",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "^7.1.1",
|
||||
"altcha": "^1.0.7",
|
||||
"altcha-lib": "^1.2.0",
|
||||
"argon2": "^0.44.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bootstrap-icons": "^1.11.3",
|
||||
"connect-mongo": "^5.1.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"csrf-sync": "^4.0.3",
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^4.18.2",
|
||||
|
|
@ -16,7 +20,7 @@
|
|||
"hls.js": "^1.6.2",
|
||||
"mongoose": "^8.4.3",
|
||||
"node-cron": "^3.0.3",
|
||||
"nodemailer": "^6.9.16",
|
||||
"nodemailer": "^7.0.9",
|
||||
"socket.io": "^4.8.1",
|
||||
"youtube-dl-exec": "^3.0.20"
|
||||
},
|
||||
|
|
@ -26,7 +30,7 @@
|
|||
"build": "node node_modules/jsdoc/jsdoc.js --verbose -r src/ -R README.md -d www/doc/server/ && node node_modules/jsdoc/jsdoc.js --verbose -r www/js/channel -r README.md -d www/doc/client/"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.10",
|
||||
"jsdoc": "^4.0.4"
|
||||
"jsdoc": "^4.0.4",
|
||||
"nodemon": "^3.1.10"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
88
src/app/auxServer.js
Normal file
88
src/app/auxServer.js
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/*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;
|
||||
|
|
@ -74,6 +74,7 @@ class activeChannel{
|
|||
* @param {Mongoose.Document} userDB - User Document Passthrough to save on DB Access
|
||||
* @param {Mongoose.Document} chanDB - Channnel Document Passthrough to save on DB Access
|
||||
* @param {Socket} socket - Requesting Socket
|
||||
* @returns {activeUser} active user object generated by the new connection
|
||||
*/
|
||||
async handleConnection(userDB, chanDB, socket){
|
||||
//get current user object from the userlist
|
||||
|
|
@ -104,10 +105,13 @@ class activeChannel{
|
|||
this.playlistHandler.defineListeners(socket);
|
||||
|
||||
//Hand off the connection initiation to it's user object
|
||||
await userObj.handleConnection(userDB, chanDB, socket)
|
||||
const activeUser = await userObj.handleConnection(userDB, chanDB, socket)
|
||||
|
||||
//Send out the userlist
|
||||
this.broadcastUserList(socket.chan);
|
||||
|
||||
//Return active user connection object for use by the channelManager object
|
||||
return activeUser;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -115,11 +119,11 @@ class activeChannel{
|
|||
* @param {Socket} socket - Requesting Socket
|
||||
*/
|
||||
handleDisconnect(socket){
|
||||
//If we have more than one active connection
|
||||
if(this.userList.get(socket.user.user).sockets.length > 1){
|
||||
//temporarily store userObj
|
||||
var userObj = this.userList.get(socket.user.user);
|
||||
//temporarily store userObj
|
||||
let userObj = this.userList.get(socket.user.user);
|
||||
|
||||
//If we have more than one active connection
|
||||
if(userObj.sockets.length > 1){
|
||||
//Filter out disconnecting socket from socket list, and set as current socket list for user
|
||||
userObj.sockets = userObj.sockets.filter((id) => {
|
||||
return id != socket.id;
|
||||
|
|
@ -127,7 +131,11 @@ class activeChannel{
|
|||
|
||||
//Update the userlist
|
||||
this.userList.set(socket.user.user, userObj);
|
||||
//If this is the last one
|
||||
}else{
|
||||
//Tell the server to handle the disconnection of this user object
|
||||
this.server.handleUserDisconnect(userObj);
|
||||
|
||||
//If this is the last connection for this user, remove them from the userlist
|
||||
this.userList.delete(socket.user.user);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,12 +20,12 @@ const config = require('../../../config.json');
|
|||
//Local Imports
|
||||
const channelModel = require('../../schemas/channel/channelSchema');
|
||||
const emoteModel = require('../../schemas/emoteSchema');
|
||||
const {userModel} = require('../../schemas/user/userSchema');
|
||||
const userBanModel = require('../../schemas/user/userBanSchema');
|
||||
const socketUtils = require('../../utils/socketUtils');
|
||||
const loggerUtils = require('../../utils/loggerUtils');
|
||||
const csrfUtils = require('../../utils/csrfUtils');
|
||||
const presenceUtils = require('../../utils/presenceUtils');
|
||||
const activeChannel = require('./activeChannel');
|
||||
const chatHandler = require('./chatHandler');
|
||||
const queueBroadcastManager = require('./media/queueBroadcastManager');
|
||||
|
||||
/**
|
||||
* Class containing global server-side channel connection management logic
|
||||
|
|
@ -33,7 +33,7 @@ const chatHandler = require('./chatHandler');
|
|||
class channelManager{
|
||||
/**
|
||||
* Instantiates object containing global server-side channel conection management logic
|
||||
* @param {Server} io - Socket.io server instanced passed down from server.js
|
||||
* @param {Socket.io} io - Socket.io server instanced passed down from server.js
|
||||
*/
|
||||
constructor(io){
|
||||
/**
|
||||
|
|
@ -46,11 +46,26 @@ 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) );
|
||||
}
|
||||
|
|
@ -62,7 +77,7 @@ class channelManager{
|
|||
async handleConnection(socket){
|
||||
try{
|
||||
//ensure unbanned ip and valid CSRF token
|
||||
if(!(await this.validateSocket(socket))){
|
||||
if(!(await socketUtils.validateSocket(socket))){
|
||||
socket.disconnect();
|
||||
return;
|
||||
}
|
||||
|
|
@ -70,7 +85,7 @@ class channelManager{
|
|||
//Prevent logged out connections and authenticate socket
|
||||
if(socket.request.session.user != null){
|
||||
//Authenticate socket
|
||||
const userDB = await this.authSocket(socket);
|
||||
const userDB = await socketUtils.authSocket(socket);
|
||||
|
||||
//Get the active channel based on the socket
|
||||
var {activeChan, chanDB} = await this.getActiveChan(socket);
|
||||
|
|
@ -88,13 +103,48 @@ class channelManager{
|
|||
return;
|
||||
}
|
||||
|
||||
//Connection accepted past this point
|
||||
|
||||
//Define listeners for inter-channel classes
|
||||
this.defineListeners(socket);
|
||||
this.chatHandler.defineListeners(socket);
|
||||
|
||||
//Hand off the connection to it's given active channel object
|
||||
//Lil' hacky to pass chanDB like that, but why double up on DB calls?
|
||||
activeChan.handleConnection(userDB, chanDB, socket);
|
||||
const activeUser = await activeChan.handleConnection(userDB, chanDB, socket);
|
||||
|
||||
//Pull status from server-wide activeUsers map
|
||||
let status = this.activeUsers.get(activeUser.user);
|
||||
|
||||
//If this user isn't connected anywhere else
|
||||
if(status == null){
|
||||
//initiate the entry
|
||||
this.activeUsers.set(activeUser.user, [activeUser]);
|
||||
//otherwise
|
||||
}else{
|
||||
//Push user to array by default
|
||||
let pushUser = true;
|
||||
|
||||
//For each active connection within the status map
|
||||
for(let curUser of status){
|
||||
//If we're already listing this active user (we're splitting a user connection amongst several sockets)
|
||||
if(curUser.channel.name == activeUser.channel.name){
|
||||
//don't need to push it again
|
||||
pushUser = false;
|
||||
}
|
||||
}
|
||||
|
||||
//if the user is flagged as un-added
|
||||
if(pushUser){
|
||||
//Add their connection object into the status array we pulled
|
||||
status.push(activeUser);
|
||||
|
||||
//Set status entry to updated array
|
||||
this.activeUsers.set(activeUser.set, status);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}else{
|
||||
//Toss out anon's
|
||||
socket.emit("kick", {type: "disconnected", reason: "You must log-in to join this channel!"});
|
||||
|
|
@ -107,77 +157,13 @@ 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 = socket.handshake.headers.referer.split('/c/')[1].split('/')[0];
|
||||
socket.chan = socketUtils.getChannelName(socket);
|
||||
const chanDB = (await channelModel.findOne({name: socket.chan}));
|
||||
|
||||
//Check if channel exists
|
||||
|
|
@ -217,6 +203,37 @@ 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
|
||||
|
|
@ -257,30 +274,28 @@ class channelManager{
|
|||
* @param {Function} cb - Callback function to run active connections of a given user against
|
||||
*/
|
||||
crawlConnections(user, cb){
|
||||
//For each channel
|
||||
this.activeChannels.forEach((channel) => {
|
||||
//Check and see if the user is connected
|
||||
const foundUser = channel.userList.get(user);
|
||||
//Pull connection list from status map
|
||||
const list = this.activeUsers.get(user);
|
||||
|
||||
//If we found a user and this channel hasn't been added to the list
|
||||
if(foundUser){
|
||||
cb(foundUser);
|
||||
//If we have active connections
|
||||
if(list != null){
|
||||
//For each connection
|
||||
for(let user of list){
|
||||
//Run the callback against it
|
||||
cb(user);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterates through connections by a given username, and runs them through a given callback function/method
|
||||
* This function is deprecated. Instead use channelManager.activeUsers.get(user)
|
||||
* @param {String} user - Username to crawl connections against
|
||||
* @param {Function} cb - Callback function to run active connections of a given user against
|
||||
*/
|
||||
getConnections(user){
|
||||
//Create a list to store our connections
|
||||
var connections = [];
|
||||
|
||||
//crawl through connections
|
||||
//this.crawlConnections(user,(foundUser)=>{connections.push(foundUser)});
|
||||
this.crawlConnections(user,(foundUser)=>{connections.push(foundUser)});
|
||||
const connections = this.activeUsers.get(user);
|
||||
|
||||
//return connects
|
||||
return connections;
|
||||
|
|
|
|||
|
|
@ -14,49 +14,20 @@ 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{
|
||||
class chat extends chatMetadata{
|
||||
/**
|
||||
* Instantiates a chat message object
|
||||
* @param {connectedUser} user - User who sent the message
|
||||
* @param {String} flair - Flair ID String for the flair used to send the message
|
||||
* @param {Number} highLevel - Number representing current high level
|
||||
* @param {String} msg - Contents of the message, with links replaced with numbered file-seperator markers
|
||||
* @param {String} type - Message Type Identifier, used for client-side processing.
|
||||
* @param {Array} links - Array of URLs/Links included in the message.
|
||||
*/
|
||||
constructor(user, flair, highLevel, msg, type, links){
|
||||
/**
|
||||
* 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;
|
||||
//Call derived constructor
|
||||
super(user, flair, highLevel, msg, type, links);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
|
|||
const validator = require('validator')
|
||||
|
||||
//local imports
|
||||
const commandPreprocessor = require('./commandPreprocessor');
|
||||
const chatPreprocessor = require('../chatPreprocessor');
|
||||
const commandProcessor = require('./commandProcessor');
|
||||
const tokebot = require('./tokebot');
|
||||
const loggerUtils = require('../../utils/loggerUtils');
|
||||
const linkUtils = require('../../utils/linkUtils');
|
||||
const emoteValidator = require('../../validators/emoteValidator');
|
||||
|
|
@ -42,7 +44,10 @@ class chatHandler{
|
|||
/**
|
||||
* Child Command Pre-Processor Object
|
||||
*/
|
||||
this.commandPreprocessor = new commandPreprocessor(server, this)
|
||||
this.chatPreprocessor = new chatPreprocessor(
|
||||
new commandProcessor(server, this),
|
||||
new tokebot(server, this)
|
||||
);
|
||||
|
||||
/**
|
||||
* Max chat buffer message count
|
||||
|
|
@ -67,8 +72,21 @@ class chatHandler{
|
|||
* @param {Socket} socket - Socket we're receiving the request from
|
||||
* @param {Object} data - Event payload
|
||||
*/
|
||||
handleChat(socket, data){
|
||||
this.commandPreprocessor.preprocess(socket, data);
|
||||
async handleChat(socket, data){
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -14,163 +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 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
|
||||
*/
|
||||
|
|
@ -444,4 +291,4 @@ class commandProcessor{
|
|||
}
|
||||
}
|
||||
|
||||
module.exports = commandPreprocessor;
|
||||
module.exports = commandProcessor;
|
||||
|
|
@ -91,6 +91,7 @@ class connectedUser{
|
|||
* @param {Mongoose.Document} userDB - User Document Passthrough to save on DB Access
|
||||
* @param {Mongoose.Document} chanDB - Channnel Document Passthrough to save on DB Access
|
||||
* @param {Socket} socket - Requesting Socket
|
||||
* @returns {activeUser} active user object generated by the new connection
|
||||
*/
|
||||
async handleConnection(userDB, chanDB, socket){
|
||||
//send metadata to client
|
||||
|
|
@ -115,6 +116,10 @@ class connectedUser{
|
|||
//Tattoo hashed IP address to user account for seven days
|
||||
await userDB.tattooIPRecord(socket.handshake.address);
|
||||
}
|
||||
|
||||
|
||||
//Return active user object for use by activeChannel and channelManager objects
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -221,9 +226,6 @@ class connectedUser{
|
|||
}
|
||||
});
|
||||
|
||||
//Get schedule as a temporary array
|
||||
const queue = await this.channel.queue.prepQueue(chanDB);
|
||||
|
||||
//Get schedule lock status
|
||||
const queueLock = this.channel.queue.locked;
|
||||
|
||||
|
|
@ -231,7 +233,7 @@ class connectedUser{
|
|||
const chatBuffer = this.channel.chatBuffer.buffer;
|
||||
|
||||
//Send off the metadata to our user's clients
|
||||
this.emit("clientMetadata", {user: userObj, flairList, queue, queueLock, chatBuffer});
|
||||
this.emit("clientMetadata", {user: userObj, flairList, queueLock, chatBuffer});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -26,9 +26,9 @@ class media{
|
|||
* @param {String} id - Video ID from source (IE: youtube watch code/archive.org file path)
|
||||
* @param {String} type - Original video source
|
||||
* @param {Number} duration - Length of media in seconds
|
||||
* @param {String} rawLink - URL to raw file copy of media, not applicable to all sources
|
||||
* @param {String} rawLink - URLs to raw file copies of media, not applicable to all sources, not saved to the DB
|
||||
*/
|
||||
constructor(title, fileName, url, id, type, duration, rawLink = url){
|
||||
constructor(title, fileName, url, id, type, duration, rawLink){
|
||||
/**
|
||||
* Chosen title of media
|
||||
*/
|
||||
|
|
@ -59,10 +59,18 @@ class media{
|
|||
*/
|
||||
this.duration = duration;
|
||||
|
||||
/**
|
||||
* URL to raw file copy of media, not applicable to all sources
|
||||
*/
|
||||
this.rawLink = rawLink;
|
||||
if(rawLink == null){
|
||||
/**
|
||||
* URL to raw file copy of media, not applicable to all sources
|
||||
*/
|
||||
this.rawLink = {
|
||||
audio: [],
|
||||
video: [],
|
||||
combo: [['default',url]]
|
||||
};
|
||||
}else{
|
||||
this.rawLink = rawLink;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -120,12 +120,12 @@ class playlistHandler{
|
|||
*/
|
||||
async addToPlaylistValidator(socket, url){
|
||||
//If we where given a bad URL
|
||||
if(typeof url != 'string' || !validator.isURL(url)){
|
||||
if(typeof url != 'string' || !validator.isURL(url,{require_valid_protocol: true})){
|
||||
//Attempt to fix the situation by encoding it
|
||||
url = encodeURI(url);
|
||||
|
||||
//If it's still bad
|
||||
if(typeof url != 'string' || !validator.isURL(url)){
|
||||
if(typeof url != 'string' || !validator.isURL(url,{require_valid_protocol: true})){
|
||||
//Bitch, moan, complain...
|
||||
loggerUtils.socketErrorHandler(socket, "Bad URL!", "validation");
|
||||
//and ignore it!
|
||||
|
|
|
|||
|
|
@ -18,10 +18,12 @@ 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
|
||||
|
|
@ -114,6 +116,11 @@ class queue{
|
|||
socket.on("move", (data) => {this.moveMedia(socket, data)});
|
||||
socket.on("lock", () => {this.toggleLock(socket)});
|
||||
socket.on("goLive", (data) => {this.goLive(socket, data)});
|
||||
|
||||
//If debug mode is enabled
|
||||
if(config.debug){
|
||||
socket.on("dumpQueue", (data) => {this.dumpQueue(socket, data)});
|
||||
}
|
||||
}
|
||||
|
||||
//--- USER FACING QUEUEING FUNCTIONS ---
|
||||
|
|
@ -132,12 +139,12 @@ class queue{
|
|||
let url = data.url;
|
||||
|
||||
//If we where given a bad URL
|
||||
if(!validator.isURL(url)){
|
||||
if(!validator.isURL(url,{require_valid_protocol: true})){
|
||||
//Attempt to fix the situation by encoding it
|
||||
url = encodeURI(url);
|
||||
|
||||
//If it's still bad
|
||||
if(!validator.isURL(url)){
|
||||
if(!validator.isURL(url,{require_valid_protocol: true})){
|
||||
//Bitch, moan, complain...
|
||||
loggerUtils.socketErrorHandler(socket, "Bad URL!", "validation");
|
||||
//and ignore it!
|
||||
|
|
@ -404,6 +411,44 @@ class queue{
|
|||
}
|
||||
}
|
||||
|
||||
async dumpQueue(socket, data){
|
||||
//If we somehow got here while config.debug is disabled, or the user isn't allowed to preform server-side debugging
|
||||
if(!(config.debug && await permissionModel.permCheck(socket.user, "debug"))){
|
||||
//FUCKIN' CHEESE IT!
|
||||
return;
|
||||
}
|
||||
|
||||
//If a full data dump was requested
|
||||
if(data != null && data.full){
|
||||
//Pull the channel DB doc
|
||||
const chanDB = await channelModel.findOne({name:this.channel.name});
|
||||
|
||||
//Cook and emit a new object from all of the data
|
||||
socket.emit("dumpQueue", {
|
||||
cache: {
|
||||
schedule: Array.from(this.schedule),
|
||||
nowPlaying: this.nowPlaying
|
||||
},
|
||||
DB: {
|
||||
schedule: chanDB.media.scheduled,
|
||||
nowPlaying: chanDB.media.nowPlaying,
|
||||
archived: chanDB.media.archived,
|
||||
}
|
||||
});
|
||||
|
||||
//DONE.
|
||||
return;
|
||||
}
|
||||
|
||||
//Otherwise, just dump whats in RAM
|
||||
socket.emit("dumpQueue", {
|
||||
cache: {
|
||||
schedule: Array.from(this.schedule),
|
||||
nowPlaying: this.nowPlaying
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//--- INTERNAL USE ONLY QUEUEING FUNCTIONS ---
|
||||
/**
|
||||
* Clears and scheduling timers
|
||||
|
|
@ -547,9 +592,25 @@ class queue{
|
|||
//For each item
|
||||
for(let item of foundItems){
|
||||
//Remove media, passing down chanDB so we're not looking again and again
|
||||
await this.removeMedia(item.uuid, socket, chanDB);
|
||||
await this.removeMedia(item.uuid, socket, chanDB, true, 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//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){
|
||||
|
|
@ -597,6 +658,8 @@ class queue{
|
|||
|
||||
//Find our media, don't remove it yet since we want to do some more testing first
|
||||
let media = this.getItemByUUID(uuid);
|
||||
//Create value to hold old media in-case somethin' fucks up
|
||||
let oldMedia = null;
|
||||
|
||||
//If we got a bad request
|
||||
if(media == null){
|
||||
|
|
@ -613,6 +676,8 @@ class queue{
|
|||
if(media.startTime < new Date().getTime()){
|
||||
//If the item is currently playing
|
||||
if(media.getEndTime() > new Date().getTime()){
|
||||
//Set old media
|
||||
oldMedia = media;
|
||||
//Dupe media for the rest of the function
|
||||
media = media.clone();
|
||||
|
||||
|
|
@ -654,6 +719,13 @@ class queue{
|
|||
//Reset the start time stamp for re-calculation
|
||||
media.startTimeStamp = 0;
|
||||
|
||||
//If there's a cut-off from moving a now-playing item
|
||||
if(oldMedia != null){
|
||||
//Remove it from the queue to prevent ghost items
|
||||
this.removeMedia(oldMedia.uuid, socket, chanDB, true);
|
||||
}
|
||||
|
||||
|
||||
//Schedule in old slot with noSave enabled
|
||||
await this.scheduleMedia([media], socket, chanDB, true);
|
||||
}
|
||||
|
|
@ -676,10 +748,11 @@ class queue{
|
|||
* @param {String} uuid - UUID of item to reschedule
|
||||
* @param {Socket} socket - Requesting Socket
|
||||
* @param {Mongoose.Document} chanDB - Channnel Document Passthrough to save on DB Access
|
||||
* @param {Boolean} noScheduling - Disables schedule timer refresh if true
|
||||
* @param {Boolean} noScheduling - Disables schedule timer refresh and this.save() calls if true, good for internal function calls
|
||||
* @param {Boolean} noBroadcast - Disables schedule broadcasting to clients, good for internal function calls
|
||||
* @returns {Media} Deleted Media Item
|
||||
*/
|
||||
async removeMedia(uuid, socket, chanDB, noScheduling = false){
|
||||
async removeMedia(uuid, socket, chanDB, noScheduling = false, noBroadcast = false){
|
||||
//If we're streamlocked
|
||||
if(this.streamLock){
|
||||
//If an originating socket was provided for this request
|
||||
|
|
@ -726,10 +799,17 @@ class queue{
|
|||
}
|
||||
//Otherwise
|
||||
}else{
|
||||
//Broadcast changes
|
||||
this.broadcastQueue(chanDB);
|
||||
//Save changes to the DB
|
||||
await chanDB.save();
|
||||
//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();
|
||||
}
|
||||
}
|
||||
}catch(err){
|
||||
//If this was originated by someone
|
||||
|
|
@ -787,12 +867,18 @@ class queue{
|
|||
await chanDB.save();
|
||||
}
|
||||
|
||||
//Broadcast the channel
|
||||
this.broadcastQueue(chanDB);
|
||||
//If broadcasting is enabled
|
||||
if(!noBroadcast){
|
||||
//Broadcast the channel
|
||||
this.broadcastQueue(chanDB);
|
||||
}
|
||||
|
||||
}catch(err){
|
||||
//Broadcast the channel
|
||||
this.broadcastQueue();
|
||||
//If broadcasting is enabled
|
||||
if(!noBroadcast){
|
||||
//Broadcast the channel
|
||||
this.broadcastQueue();
|
||||
}
|
||||
|
||||
//If this was originated by someone
|
||||
if(socket != null){
|
||||
|
|
@ -1033,6 +1119,7 @@ class queue{
|
|||
if(this.nowPlaying != null){
|
||||
//Silently end the media in RAM so the database isn't stepping on itself up ahead
|
||||
//Alternatively we could've used await, but then we'd be doubling up on DB transactions :P
|
||||
//VOLATILE END
|
||||
this.end(true, true, true);
|
||||
}
|
||||
|
||||
|
|
@ -1071,6 +1158,9 @@ class queue{
|
|||
|
||||
//Save the channel
|
||||
await chanDB.save();
|
||||
|
||||
//Broadcast queue since we ended our media item early
|
||||
this.broadcastQueue(chanDB);
|
||||
}catch(err){
|
||||
loggerUtils.localExceptionHandler(err);
|
||||
}
|
||||
|
|
@ -1126,7 +1216,7 @@ class queue{
|
|||
* End currently playing media
|
||||
* @param {Boolean} quiet - Enable to prevent ending the media client-side
|
||||
* @param {Boolean} noArchive - Enable to prevent ended media from being written to channel archive. Deletes media if Volatile is false
|
||||
* @param {Boolean} volatile - Enable to prevent DB Transactions
|
||||
* @param {Boolean} volatile - Enable to prevent DB Transactions, also prevents queue broadcasting so the calling code can broadcast once it's done.
|
||||
* @param {Mongoose.Document} chanDB - Pass through Channel Document to save on DB Transactions
|
||||
*/
|
||||
async end(quiet = false, noArchive = false, volatile = false, chanDB){
|
||||
|
|
@ -1164,6 +1254,13 @@ 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
|
||||
|
|
@ -1184,9 +1281,6 @@ class queue{
|
|||
await chanDB.media.nowPlaying.deleteOne();
|
||||
}
|
||||
|
||||
//Take it out of the active schedule
|
||||
this.schedule.delete(wasPlaying.startTime);
|
||||
|
||||
//If archiving is enabled
|
||||
if(!noArchive){
|
||||
//Add the item to the channel archive
|
||||
|
|
@ -1198,9 +1292,6 @@ class queue{
|
|||
|
||||
//Save our changes to the DB
|
||||
await chanDB.save();
|
||||
}else{
|
||||
//broadcast queue using unsaved archive
|
||||
this.broadcastQueue(chanDB);
|
||||
}
|
||||
}catch(err){
|
||||
this.broadcastQueue();
|
||||
|
|
@ -1415,7 +1506,7 @@ class queue{
|
|||
const mediaObj = entry[1];
|
||||
|
||||
//Remove media from queue without calling chanDB.save() to make room before we move everything
|
||||
await this.removeMedia(mediaObj.uuid, null, chanDB, true);
|
||||
await this.removeMedia(mediaObj.uuid, null, chanDB, true, true);
|
||||
|
||||
mediaObj.genUUID();
|
||||
|
||||
|
|
@ -1431,6 +1522,9 @@ class queue{
|
|||
|
||||
//Schedule the moved schedule, letting scheduleMedia save our changes for us, starting w/o saves to prevent over-saving
|
||||
await this.scheduleMedia(newSched, null, chanDB);
|
||||
|
||||
//Broadcast the queue now that everything is hunky dory
|
||||
await this.broadcastQueue(chanDB);
|
||||
}catch(err){
|
||||
//Null out live remainder for the next stream
|
||||
this.liveRemainder = null;
|
||||
|
|
@ -1607,7 +1701,18 @@ class queue{
|
|||
* @param {Mongoose.Document} chanDB - Pass through Channel Document to save on DB Transactions
|
||||
*/
|
||||
async broadcastQueue(chanDB){
|
||||
this.server.io.in(this.channel.name).emit('queue',{queue: await this.prepQueue(chanDB)});
|
||||
//Broadcast queue to authenticated sockets within the channels room inside the protected 'queue-broadcast' namespace
|
||||
this.server.queueBroadcastManager.namespace.in(this.channel.name).emit('queue',{queue: await this.prepQueue(chanDB)});
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts channel queue to a single socket
|
||||
* @param {Mongoose.Document} chanDB - Pass through Channel Document to save on DB Transactions
|
||||
* @param {Socket} socket - Socket to send queue to
|
||||
*/
|
||||
async emitQueue(chanDB, socket){
|
||||
//Broadcast queue to authenticated sockets within the channels room inside the protected 'queue-broadcast' namespace
|
||||
socket.emit('queue',{queue: await this.prepQueue(chanDB)});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1648,8 +1753,8 @@ class queue{
|
|||
media.earlyEnd = null;
|
||||
}
|
||||
|
||||
//Add it to the schedule array as if it where part of the actual schedule map
|
||||
schedule.push([media.startTime, media]);
|
||||
//Add it to the temporary schedule array as if it where part of the actual schedule map
|
||||
schedule.unshift([media.startTime, media]);
|
||||
//Otherwise if it's older
|
||||
}else{
|
||||
//Then we should be done as archived items are added as they are played/end.
|
||||
|
|
@ -1777,7 +1882,9 @@ class queue{
|
|||
chanDB.media.scheduled = newSched;
|
||||
|
||||
//Save the DB
|
||||
await chanDB.save();
|
||||
await chanDB.save()
|
||||
|
||||
//End the media;
|
||||
|
||||
//if something fucked up
|
||||
}catch(err){
|
||||
|
|
|
|||
94
src/app/channel/media/queueBroadcastManager.js
Normal file
94
src/app/channel/media/queueBroadcastManager.js
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
/*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;
|
||||
|
|
@ -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 stat schema
|
||||
statSchema.tattooToke(this.tokers);
|
||||
//Do the same for the global toke statistics collection
|
||||
tokeModel.tattooToke(this.tokers);
|
||||
|
||||
//Set the toke cooldown
|
||||
this.cooldownCounter = this.cooldownTime;
|
||||
|
|
|
|||
63
src/app/chatMetadata.js
Normal file
63
src/app/chatMetadata.js
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/*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;
|
||||
153
src/app/chatPreprocessor.js
Normal file
153
src/app/chatPreprocessor.js
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/*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;
|
||||
39
src/app/pm/message.js
Normal file
39
src/app/pm/message.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/*Canopy - The next generation of stoner streaming software
|
||||
Copyright (C) 2024-2025 Rainbownapkin and the TTN Community
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <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;
|
||||
159
src/app/pm/pmHandler.js
Normal file
159
src/app/pm/pmHandler.js
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
/*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;
|
||||
28
src/controllers/aboutController.js
Normal file
28
src/controllers/aboutController.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/*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)});
|
||||
}
|
||||
|
|
@ -17,6 +17,9 @@ 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');
|
||||
|
|
@ -45,7 +48,8 @@ module.exports.get = async function(req, res){
|
|||
chanGuide: chanGuide,
|
||||
userList: userList,
|
||||
permList: permList,
|
||||
csrfToken: csrfUtils.generateToken(req)
|
||||
csrfToken: csrfUtils.generateToken(req),
|
||||
unescape: validator.unescape
|
||||
});
|
||||
|
||||
}catch(err){
|
||||
|
|
|
|||
|
|
@ -33,17 +33,14 @@ module.exports.post = async function(req, res){
|
|||
const data = matchedData(req);
|
||||
|
||||
//make sure we're not bullshitting ourselves here.
|
||||
if(user == null){
|
||||
res.status(400);
|
||||
return res.send('Invalid Session! Cannot delete account while logged out!');
|
||||
if(user == null || user.user == null){
|
||||
return errorHandler(res, 'You must be logged in to delete your account!', 'unauthorized');
|
||||
}
|
||||
|
||||
const userDB = await userModel.findOne(user);
|
||||
|
||||
const userDB = await userModel.findOne({user: user.user});
|
||||
|
||||
if(!userDB){
|
||||
res.status(400);
|
||||
return res.send('Invalid User! Account must exist in order to delete!');
|
||||
return errorHandler(res, 'User not found!', 'unauthorized');
|
||||
}
|
||||
|
||||
await userDB.nuke(data.pass);
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ module.exports.post = async function(req, res){
|
|||
|
||||
//Check to make sure the user is logged in
|
||||
if(req.session.user == null){
|
||||
errorHandler(res, "Invalid user!");
|
||||
return errorHandler(res, "Invalid user!");
|
||||
}
|
||||
|
||||
//Authenticate and find user model from DB
|
||||
|
|
@ -51,11 +51,22 @@ module.exports.post = async function(req, res){
|
|||
|
||||
//If we have an invalid user
|
||||
if(userDB == null){
|
||||
errorHandler(res, "Invalid user!");
|
||||
return errorHandler(res, "Invalid user!");
|
||||
}
|
||||
|
||||
if(userDB.email == email){
|
||||
errorHandler(res, "Cannot set current email!");
|
||||
return errorHandler(res, "Cannot set current email!");
|
||||
}
|
||||
|
||||
|
||||
//Look through DB and migration cache for existing email
|
||||
const existingDB = await userModel.findOne({email: new RegExp(email, 'i')});
|
||||
const needsMigration = userModel.migrationCache.emails.includes(email.toLowerCase());
|
||||
|
||||
//If the email is in use
|
||||
if(existingDB != null || needsMigration){
|
||||
//Complain
|
||||
return errorHandler(res, "Email already in use!");
|
||||
}
|
||||
|
||||
//Generate the password reset link
|
||||
|
|
|
|||
|
|
@ -21,10 +21,11 @@ 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){
|
||||
|
|
@ -35,10 +36,45 @@ module.exports.post = async function(req, res){
|
|||
//if we don't have errors
|
||||
if(validResult.isEmpty()){
|
||||
//Pull sanatzied/validated data
|
||||
const {user, pass} = matchedData(req);
|
||||
const data = matchedData(req);
|
||||
|
||||
//try to authenticate the session, and return a successful code if it works
|
||||
await sessionUtils.authenticateSession(user, pass, 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
|
||||
return res.sendStatus(200);
|
||||
}else{
|
||||
res.status(400);
|
||||
|
|
@ -51,21 +87,35 @@ module.exports.post = async function(req, res){
|
|||
//if we don't have errors
|
||||
if(validResult.isEmpty()){
|
||||
//Get login attempts for current user
|
||||
const {user} = matchedData(req);
|
||||
const {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 attempts = sessionUtils.getLoginAttempts(user)
|
||||
|
||||
//if we've gone over max attempts and
|
||||
if(attempts.count > sessionUtils.throttleAttempts){
|
||||
//if we've gone over max attempts
|
||||
if(attempts != null && attempts.count > sessionUtils.throttleAttempts){
|
||||
//tell client it needs a captcha
|
||||
return res.sendStatus(429);
|
||||
}else{
|
||||
//Scream about any un-caught errors
|
||||
return exceptionHandler(res, err);
|
||||
}
|
||||
}else{
|
||||
res.status(400);
|
||||
return res.send({errors: validResult.array()})
|
||||
}
|
||||
|
||||
//
|
||||
return exceptionHandler(res, err);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -15,13 +15,36 @@ 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 accountUtils = require('../../../utils/sessionUtils');
|
||||
const {exceptionHandler, errorHandler} = require('../../../utils/loggerUtils');
|
||||
const rememberMeModel = require('../../../schemas/user/rememberMeSchema');
|
||||
const sessionUtils = require('../../../utils/sessionUtils');
|
||||
const {exceptionHandler} = require('../../../utils/loggerUtils');
|
||||
const {validationResult, matchedData} = require('express-validator');
|
||||
|
||||
module.exports.post = async function(req, res){
|
||||
if(req.session.user){
|
||||
try{
|
||||
accountUtils.killSession(req.session);
|
||||
sessionUtils.killSession(req.session);
|
||||
|
||||
//Check validation results
|
||||
const validResult = validationResult(req);
|
||||
|
||||
//if we don't have errors
|
||||
if(validResult.isEmpty()){
|
||||
//Pull sanatzied/validated data
|
||||
const data = matchedData(req);
|
||||
|
||||
//If the user has a remember me token id they've submitted with the request
|
||||
if(data.rememberme != 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
|
||||
return res.sendStatus(200);
|
||||
}catch(err){
|
||||
return exceptionHandler(res, err);
|
||||
|
|
|
|||
96
src/controllers/api/account/migrationController.js
Normal file
96
src/controllers/api/account/migrationController.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
/*Canopy - The next generation of stoner streaming software
|
||||
Copyright (C) 2024-2025 Rainbownapkin and the TTN Community
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <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);
|
||||
}
|
||||
}
|
||||
|
|
@ -46,7 +46,13 @@ module.exports.post = async function(req, res){
|
|||
const {field, change} = data;
|
||||
const {user} = req.session;
|
||||
|
||||
const userDB = await userModel.findOne(user);
|
||||
//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 update = {};
|
||||
|
||||
|
||||
|
|
@ -86,8 +92,7 @@ module.exports.post = async function(req, res){
|
|||
res.status(200);
|
||||
return res.send(update);
|
||||
}else{
|
||||
res.status(400);
|
||||
return res.send({errors: [{msg:"User not found!"}]});
|
||||
return errorHandler(res, 'User not found!', 'unauthorized');
|
||||
}
|
||||
}else{
|
||||
res.status(400);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
|
|||
//Config
|
||||
const config = require('../../config.json');
|
||||
|
||||
//NPM Imports
|
||||
const validator = require('validator');//No express here, so regular validator it is!
|
||||
|
||||
//local imports
|
||||
const channelModel = require('../schemas/channel/channelSchema');
|
||||
const permissionModel = require('../schemas/permissionSchema');
|
||||
|
|
@ -39,7 +42,7 @@ module.exports.get = async function(req, res){
|
|||
throw loggerUtils.exceptionSmith("Channel not found.", "queue");
|
||||
}
|
||||
|
||||
return res.render('channelSettings', {instance: config.instanceName, user: req.session.user, channel: chanDB, reqRank, rankEnum: permissionModel.rankEnum, csrfToken: csrfUtils.generateToken(req)});
|
||||
return res.render('channelSettings', {instance: config.instanceName, user: req.session.user, channel: chanDB, reqRank, rankEnum: permissionModel.rankEnum, csrfToken: csrfUtils.generateToken(req), unescape: validator.unescape});
|
||||
}catch(err){
|
||||
return exceptionHandler(res, err);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ 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');
|
||||
|
|
@ -26,7 +29,7 @@ const {exceptionHandler, errorHandler} = require('../utils/loggerUtils');
|
|||
module.exports.get = async function(req, res){
|
||||
try{
|
||||
const chanGuide = await channelModel.getChannelList();
|
||||
return res.render('index', {instance: config.instanceName, user: req.session.user, chanGuide: chanGuide, csrfToken: csrfUtils.generateToken(req)});
|
||||
return res.render('index', {instance: config.instanceName, user: req.session.user, chanGuide: chanGuide, csrfToken: csrfUtils.generateToken(req), unescape: validator.unescape});
|
||||
}catch(err){
|
||||
return exceptionHandler(res, err);
|
||||
}
|
||||
|
|
|
|||
31
src/controllers/migrateController.js
Normal file
31
src/controllers/migrateController.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/*Canopy - The next generation of stoner streaming software
|
||||
Copyright (C) 2024-2025 Rainbownapkin and the TTN Community
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <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)});
|
||||
}
|
||||
20
src/controllers/panel/pmController.js
Normal file
20
src/controllers/panel/pmController.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/*Canopy - The next generation of stoner streaming software
|
||||
Copyright (C) 2024-2025 Rainbownapkin and the TTN Community
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.*/
|
||||
|
||||
//root index functions
|
||||
module.exports.get = async function(req, res){
|
||||
res.render('partial/panels/pm', {});
|
||||
}
|
||||
|
|
@ -17,7 +17,11 @@ 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');
|
||||
|
||||
|
|
@ -30,7 +34,10 @@ module.exports.get = async function(req, res){
|
|||
const data = matchedData(req);
|
||||
const profile = await userModel.findProfile({user: data.user});
|
||||
|
||||
return res.render('partial/panels/profile', {profile});
|
||||
//Pull presence (should be quick since everyone whos been on since last startup will be backed in RAM)
|
||||
const presence = await presenceUtils.getPresence(profile.user);
|
||||
|
||||
return res.render('partial/panels/profile', {profile, presence, unescape: validator.unescape});
|
||||
}else{
|
||||
res.status(400);
|
||||
return res.send({errors: validResult.array()})
|
||||
|
|
|
|||
|
|
@ -17,8 +17,12 @@ 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');
|
||||
|
||||
|
|
@ -34,12 +38,17 @@ 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,
|
||||
csrfToken: csrfUtils.generateToken(req)
|
||||
presence,
|
||||
csrfToken: csrfUtils.generateToken(req),
|
||||
unescape: validator.unescape
|
||||
});
|
||||
}else{
|
||||
res.render('profile', {
|
||||
|
|
@ -47,7 +56,9 @@ module.exports.get = async function(req, res){
|
|||
user: req.session.user,
|
||||
profile: null,
|
||||
selfProfile: false,
|
||||
csrfToken: csrfUtils.generateToken(req)
|
||||
presence: null,
|
||||
csrfToken: csrfUtils.generateToken(req),
|
||||
unescape: validator.unescape
|
||||
});
|
||||
}
|
||||
}catch(err){
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ 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');
|
||||
|
|
@ -34,7 +35,7 @@ module.exports.get = async function(req, res){
|
|||
return errorHandler(res, 'Cannot get alts for non-existant user!');
|
||||
}
|
||||
|
||||
return res.render('partial/tooltip/altList', {alts: await userDB.getAltProfiles()});
|
||||
return res.render('partial/tooltip/altList', {alts: await userDB.getAltProfiles(), unescape: validator.unescape});
|
||||
}else{
|
||||
res.status(400);
|
||||
return res.send({errors: validResult.array()})
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ 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');
|
||||
|
|
@ -30,10 +33,10 @@ module.exports.get = async function(req, res){
|
|||
const data = matchedData(req);
|
||||
const profile = await userModel.findProfile({user: data.user});
|
||||
|
||||
return res.render('partial/tooltip/profile', {profile});
|
||||
return res.render('partial/tooltip/profile', {profile, unescape: validator.unescape});
|
||||
}else{
|
||||
res.status(400);
|
||||
return res.send({errors: validResult.array()})
|
||||
return res.send({errors: validResult.array()});
|
||||
}
|
||||
|
||||
}catch(err){
|
||||
|
|
|
|||
34
src/routers/aboutRouter.js
Normal file
34
src/routers/aboutRouter.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/*Canopy - The next generation of stoner streaming software
|
||||
Copyright (C) 2024-2025 Rainbownapkin and the TTN Community
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <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;
|
||||
|
|
@ -17,16 +17,17 @@ 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);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const accountValidator = require("../../validators/accountValidator");
|
|||
const loginController = require("../../controllers/api/account/loginController");
|
||||
const logoutController = require("../../controllers/api/account/logoutController");
|
||||
const registerController = require("../../controllers/api/account/registerController");
|
||||
const migrationController = require("../../controllers/api/account/migrationController");
|
||||
const updateController = require("../../controllers/api/account/updateController");
|
||||
const rankEnumController = require("../../controllers/api/account/rankEnumController");
|
||||
const passwordResetRequestController = require("../../controllers/api/account/passwordResetRequestController");
|
||||
|
|
@ -38,18 +39,32 @@ router.post('/login', accountValidator.user(), accountValidator.pass(), loginCon
|
|||
//logout
|
||||
router.post('/logout', logoutController.post);
|
||||
//register
|
||||
router.post('/register', accountValidator.user(),
|
||||
router.post('/register',
|
||||
accountValidator.user(),
|
||||
accountValidator.securePass(),
|
||||
accountValidator.pass('passConfirm'),
|
||||
accountValidator.email(), registerController.post);
|
||||
accountValidator.email(),
|
||||
registerController.post);
|
||||
|
||||
//migrate legacy profile
|
||||
router.post('/migrate',
|
||||
accountValidator.user(),
|
||||
accountValidator.pass('oldPass'),
|
||||
accountValidator.securePass('newPass'),
|
||||
accountValidator.pass('passConfirm'),
|
||||
migrationController.post);
|
||||
|
||||
//update profile
|
||||
router.post('/update', accountValidator.img(),
|
||||
router.post('/update',
|
||||
accountValidator.img(),
|
||||
accountValidator.bio(),
|
||||
accountValidator.signature(),
|
||||
accountValidator.pronouns(),
|
||||
accountValidator.pass('passChange.oldPass'),
|
||||
accountValidator.securePass('passChange.newPass'),
|
||||
accountValidator.pass('passChange.confirmPass'), updateController.post);
|
||||
accountValidator.pass('passChange.confirmPass'),
|
||||
updateController.post);
|
||||
|
||||
//rankEnum
|
||||
//This might seem silly, but it allows us to cleanly get the current rank list to compare against, without storing it in multiple places
|
||||
router.get('/rankEnum', rankEnumController.get);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const { Router } = require('express');
|
|||
const channelModel = require("../schemas/channel/channelSchema");
|
||||
const channelController = require("../controllers/channelController");
|
||||
const channelSettingsController = require("../controllers/channelSettingsController");
|
||||
const presenceUtils = require("../utils/presenceUtils");
|
||||
|
||||
//globals
|
||||
const router = Router();
|
||||
|
|
@ -29,6 +30,9 @@ const router = Router();
|
|||
//User authentication middleware
|
||||
router.use("/*/settings",channelModel.reqPermCheck("manageChannel","/c/"));
|
||||
|
||||
//Use presence middleware
|
||||
router.use(presenceUtils.presenceMiddleware);
|
||||
|
||||
//routing functions
|
||||
router.get('/*/settings', channelSettingsController.get);
|
||||
router.get('/*/', channelController.get);
|
||||
|
|
|
|||
|
|
@ -20,10 +20,14 @@ const { Router } = require('express');
|
|||
|
||||
//local imports
|
||||
const indexController = require("../controllers/indexController");
|
||||
const presenceUtils = require("../utils/presenceUtils");
|
||||
|
||||
//globals
|
||||
const router = Router();
|
||||
|
||||
//Use presence middleware
|
||||
router.use(presenceUtils.presenceMiddleware);
|
||||
|
||||
//routing functions
|
||||
router.get('/', indexController.get);
|
||||
|
||||
|
|
|
|||
30
src/routers/migrateRouter.js
Normal file
30
src/routers/migrateRouter.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/*Canopy - The next generation of stoner streaming software
|
||||
Copyright (C) 2024-2025 Rainbownapkin and the TTN Community
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <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;
|
||||
|
|
@ -21,6 +21,7 @@ const { Router } = require('express');
|
|||
//local imports
|
||||
const permissionSchema = require("../schemas/permissionSchema");
|
||||
const newChannelController = require("../controllers/newChannelController");
|
||||
const presenceUtils = require("../utils/presenceUtils");
|
||||
|
||||
//globals
|
||||
const router = Router();
|
||||
|
|
@ -28,6 +29,9 @@ const router = Router();
|
|||
//user authentication middleware
|
||||
router.use("/",permissionSchema.reqPermCheck("registerChannel"));
|
||||
|
||||
//Use presence middleware
|
||||
router.use(presenceUtils.presenceMiddleware);
|
||||
|
||||
//routing functions
|
||||
router.get('/', newChannelController.get);
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ const popoutContainerController = require("../controllers/panel/popoutContainerC
|
|||
const profileController = require("../controllers/panel/profileController");
|
||||
const queueController = require("../controllers/panel/queueController");
|
||||
const settingsController = require("../controllers/panel/settingsController");
|
||||
const pmController = require("../controllers/panel/pmController");
|
||||
//Validators
|
||||
const accountValidator = require("../validators/accountValidator");
|
||||
|
||||
|
|
@ -38,5 +39,6 @@ router.get('/popoutContainer', popoutContainerController.get);
|
|||
router.get('/profile', accountValidator.user(), profileController.get);
|
||||
router.get('/queue', queueController.get);
|
||||
router.get('/settings', settingsController.get);
|
||||
router.get('/pm', pmController.get);
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -89,6 +89,12 @@ const channelPermissionSchema = new mongoose.Schema({
|
|||
default: "admin",
|
||||
required: true
|
||||
},
|
||||
readSchedule: {
|
||||
type: mongoose.SchemaTypes.String,
|
||||
enum: rankEnum,
|
||||
default: "admin",
|
||||
required: true
|
||||
},
|
||||
scheduleMedia: {
|
||||
type: mongoose.SchemaTypes.String,
|
||||
enum: rankEnum,
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ const channelSchema = new mongoose.Schema({
|
|||
thumbnail: {
|
||||
type: mongoose.SchemaTypes.String,
|
||||
required: true,
|
||||
default: "/img/johnny.png"
|
||||
default: "/nonfree/johnny.png"
|
||||
},
|
||||
settings: {
|
||||
hidden: {
|
||||
|
|
|
|||
|
|
@ -32,10 +32,11 @@ const chatSchema = new mongoose.Schema({
|
|||
},
|
||||
flair: {
|
||||
type: mongoose.SchemaTypes.String,
|
||||
required: true,
|
||||
//Leave this as unreq'd for internal type chats that have no flair
|
||||
},
|
||||
highLevel: {
|
||||
type: mongoose.SchemaTypes.Number,
|
||||
default: 0,
|
||||
required: true,
|
||||
},
|
||||
msg: {
|
||||
|
|
|
|||
|
|
@ -51,8 +51,11 @@ const emoteSchema = new mongoose.Schema({
|
|||
* Post-Save function, ensures all new emotes are broadcastes to actively connected clients
|
||||
*/
|
||||
emoteSchema.post('save', async function (next){
|
||||
//broadcast updated emotes
|
||||
server.channelManager.broadcastSiteEmotes();
|
||||
//Ensure the channel manager is actually up
|
||||
if(server.channelManager != null){
|
||||
//broadcast updated emotes
|
||||
server.channelManager.broadcastSiteEmotes();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -100,6 +100,12 @@ const permissionSchema = new mongoose.Schema({
|
|||
type: channelPermissionSchema,
|
||||
default: () => ({})
|
||||
},
|
||||
debug: {
|
||||
type: mongoose.SchemaTypes.String,
|
||||
enum: rankEnum,
|
||||
default: "admin",
|
||||
required: true
|
||||
},
|
||||
});
|
||||
|
||||
//Statics
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ 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
|
||||
|
|
@ -44,22 +46,16 @@ const statSchema = new mongoose.Schema({
|
|||
type: mongoose.SchemaTypes.Date,
|
||||
required: true,
|
||||
default: new Date()
|
||||
},
|
||||
tokes: [{
|
||||
toke: {
|
||||
type: mongoose.SchemaTypes.Map,
|
||||
required: true,
|
||||
default: new Map()
|
||||
},
|
||||
date: {
|
||||
type: mongoose.SchemaTypes.Date,
|
||||
required: true,
|
||||
default: new Date()
|
||||
}
|
||||
}]
|
||||
}
|
||||
});
|
||||
|
||||
//statics
|
||||
|
||||
/**
|
||||
* Set placeholder variable to hold cached firstLaunch date from stat document
|
||||
*/
|
||||
statSchema.statics.firstLaunch = null;
|
||||
|
||||
/**
|
||||
* Get's servers sole stat document from the DB
|
||||
* @returns {Mongoose.Document} Server's sole statistics document
|
||||
|
|
@ -96,9 +92,11 @@ statSchema.statics.incrementLaunchCount = async function(){
|
|||
stats.launchCount++;
|
||||
stats.save();
|
||||
|
||||
//Cache first launch
|
||||
this.firstLaunch = stats.firstLaunch;
|
||||
|
||||
//print bootup message to console.
|
||||
console.log(`${config.instanceName}(Powered by Canopy) initialized. This server has booted ${stats.launchCount} time${stats.launchCount == 1 ? '' : 's'}.`)
|
||||
console.log(`First booted on ${stats.firstLaunch}.`);
|
||||
loggerUtils.welcomeWagon(stats.launchCount, stats.firstLaunch, tokeSchema.count);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -137,63 +135,4 @@ statSchema.statics.incrementChannelCount = async function(){
|
|||
return oldCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tattoo's toke to the server statistics document
|
||||
* @param {Map} toke - Tokemap handed down from Tokebot
|
||||
*/
|
||||
statSchema.statics.tattooToke = async function(toke){
|
||||
//Get the statistics document
|
||||
const stats = await this.getStats();
|
||||
|
||||
//Add the toke to the stat document
|
||||
stats.tokes.push({toke});
|
||||
|
||||
//Save the stat document
|
||||
await stats.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
|
@ -36,15 +36,20 @@ const tokeCommandSchema = new mongoose.Schema({
|
|||
* Pre-Save middleware, ensures tokebot receives all new toke commands
|
||||
*/
|
||||
tokeCommandSchema.pre('save', async function (next){
|
||||
//if the command was changed
|
||||
//if the channel manager, chat handler, and chat post-processor are all loaded up...
|
||||
if(this.isModified("command")){
|
||||
//Get server tokebot object
|
||||
const tokebot = server.channelManager.chatHandler.commandPreprocessor.tokebot;
|
||||
if(server.channelManager != null &&
|
||||
server.channelManager.chatHandler != null &&
|
||||
server.channelManager.chatHandler.chatPreprocessor != null){
|
||||
|
||||
//If tokebot is up and running
|
||||
if(tokebot != null && tokebot.tokeCommands != null){
|
||||
//Pop the command on to the end
|
||||
tokebot.tokeCommands.push(this.command);
|
||||
//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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -58,7 +63,7 @@ tokeCommandSchema.pre('save', async function (next){
|
|||
*/
|
||||
tokeCommandSchema.pre('deleteOne', {document: true}, async function (next){
|
||||
//Get server tokebot object (isn't this a fun dot crawler? Why hasn't anyone asked me to stop writing software yet?)
|
||||
const tokebot = server.channelManager.chatHandler.commandPreprocessor.tokebot;
|
||||
const tokebot = server.channelManager.chatHandler.chatPreprocessor.tokebot;
|
||||
|
||||
//Get the index of the command within tokeCommand and splice it out
|
||||
tokebot.tokeCommands.splice(tokebot.tokeCommands.indexOf(this.command),1);
|
||||
|
|
|
|||
219
src/schemas/tokebot/tokeSchema.js
Normal file
219
src/schemas/tokebot/tokeSchema.js
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
/*Canopy - The next generation of stoner streaming software
|
||||
Copyright (C) 2024-2025 Rainbownapkin and the TTN Community
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <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);
|
||||
|
|
@ -52,7 +52,7 @@ const emailChangeSchema = new mongoose.Schema({
|
|||
type: mongoose.SchemaTypes.String,
|
||||
required: true,
|
||||
//Use a cryptographically secure algorythm to create a random hex string from 16 bytes as our change/cancel token
|
||||
default: ()=>{return crypto.randomBytes(16).toString('hex')}
|
||||
default: ()=>{return crypto.randomBytes(32).toString('hex')}
|
||||
},
|
||||
ipHash: {
|
||||
type: mongoose.SchemaTypes.String,
|
||||
|
|
|
|||
389
src/schemas/user/migrationSchema.js
Normal file
389
src/schemas/user/migrationSchema.js
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
/*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);
|
||||
|
|
@ -48,7 +48,7 @@ const passwordResetSchema = new mongoose.Schema({
|
|||
type: mongoose.SchemaTypes.String,
|
||||
required: true,
|
||||
//Use a cryptographically secure algorythm to create a random hex string from 16 bytes as our reset token
|
||||
default: ()=>{return crypto.randomBytes(16).toString('hex')}
|
||||
default: ()=>{return crypto.randomBytes(32).toString('hex')}
|
||||
},
|
||||
ipHash: {
|
||||
type: mongoose.SchemaTypes.String,
|
||||
|
|
|
|||
198
src/schemas/user/rememberMeSchema.js
Normal file
198
src/schemas/user/rememberMeSchema.js
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
/*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);
|
||||
|
|
@ -73,8 +73,6 @@ const userBanSchema = new mongoose.Schema({
|
|||
* @returns {Mongoose.Document} Found ban Document if one exists.
|
||||
*/
|
||||
userBanSchema.statics.checkBanByIP = async function(ip){
|
||||
//Get hash of ip
|
||||
const ipHash = hashUtil.hashIP(ip);
|
||||
//Get all bans
|
||||
const banDB = await this.find({});
|
||||
//Create null variable to hold any found ban
|
||||
|
|
@ -106,7 +104,7 @@ userBanSchema.statics.checkBanByIP = async function(ip){
|
|||
const curHash = ban.ips.hashed[ipIndex];
|
||||
|
||||
//Check the current hash against the given hash
|
||||
if(ipHash == curHash){
|
||||
if(hashUtil.compareIPHash(ip, curHash)){
|
||||
//If it matches we found the ban
|
||||
foundBan = ban;
|
||||
|
||||
|
|
|
|||
|
|
@ -22,11 +22,13 @@ 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');
|
||||
|
|
@ -60,6 +62,11 @@ 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,
|
||||
|
|
@ -74,7 +81,7 @@ const userSchema = new mongoose.Schema({
|
|||
img: {
|
||||
type: mongoose.SchemaTypes.String,
|
||||
required: true,
|
||||
default: "/img/johnny.png"
|
||||
default: "/nonfree/johnny.png"
|
||||
},
|
||||
bio: {
|
||||
type: mongoose.SchemaTypes.String,
|
||||
|
|
@ -158,7 +165,7 @@ userSchema.pre('save', async function (next){
|
|||
//If the password was changed
|
||||
if(this.isModified("pass")){
|
||||
//Hash that sunnovabitch, no questions asked.
|
||||
this.pass = hashUtil.hashPassword(this.pass);
|
||||
this.pass = await hashUtil.hashPassword(this.pass);
|
||||
}
|
||||
|
||||
//If the flair was changed
|
||||
|
|
@ -179,6 +186,11 @@ userSchema.pre('save', async function (next){
|
|||
|
||||
//If rank was changed
|
||||
if(this.isModified("rank")){
|
||||
//If this rank change is above 2 (Mod or above)
|
||||
if(permissionModel.rankToNum(this.rank) > 2){
|
||||
loggerUtils.dumpSecurityLog(`${this.user}'s rank was set to ${this.rank}.`);
|
||||
}
|
||||
|
||||
//force a full log-out
|
||||
await this.killAllSessions("Your site-wide rank has changed. Sign-in required.");
|
||||
}
|
||||
|
|
@ -220,6 +232,18 @@ userSchema.post('deleteOne', {document: true}, async function (){
|
|||
});
|
||||
|
||||
//statics
|
||||
/**
|
||||
* Holds cache of usernames of profiles stored in the Legacy Profile Migration collection
|
||||
*
|
||||
* We can't directly reference migrationSchema, as it would cause a circular reference
|
||||
* To deal with this, migration schema caches it's regestered users into this array on startup.
|
||||
* Bonus pts for improved performance on registration calls
|
||||
*/
|
||||
userSchema.statics.migrationCache = {
|
||||
users: [],
|
||||
emails: []
|
||||
};
|
||||
|
||||
/**
|
||||
* Registers a new user account with given information
|
||||
* @param {Object} userObj - Object representing user to register, generated by the client
|
||||
|
|
@ -231,11 +255,31 @@ userSchema.statics.register = async function(userObj, ip){
|
|||
|
||||
//Check password confirmation matches
|
||||
if(pass == passConfirm){
|
||||
//Setup user query
|
||||
let userQuery = {user: new RegExp(user, 'i')};
|
||||
|
||||
//If we have an email
|
||||
if(email != null && email != ""){
|
||||
userQuery = {$or: [
|
||||
userQuery,
|
||||
{email: new RegExp(email, 'i')}
|
||||
]};
|
||||
}
|
||||
|
||||
//Look for a user (case insensitive)
|
||||
var userDB = await this.findOne({user: new RegExp(user, 'i')});
|
||||
var userDB = await this.findOne(userQuery);
|
||||
|
||||
//Look for a legacy profile
|
||||
let needsMigration = this.migrationCache.users.includes(user.toLowerCase());
|
||||
|
||||
//If the email isn't null and we didnt hit a migration username
|
||||
if(email != null && !needsMigration){
|
||||
//Check for migration email
|
||||
needsMigration = this.migrationCache.emails.includes(email.toLowerCase());
|
||||
}
|
||||
|
||||
//If the user is found or someones trying to impersonate tokeboi
|
||||
if(userDB || user.toLowerCase() == "tokebot"){
|
||||
if(userDB || needsMigration || user.toLowerCase() == "tokebot"){
|
||||
throw loggerUtils.exceptionSmith("User name/email already taken!", "validation");
|
||||
}else{
|
||||
//Increment the user count, pulling the id to tattoo to the user
|
||||
|
|
@ -249,9 +293,11 @@ userSchema.statics.register = async function(userObj, ip){
|
|||
|
||||
//if we submitted an email
|
||||
if(email != null){
|
||||
//Generate email request token
|
||||
const requestDB = await emailChangeModel.create({user: newUser._id, newEmail: email, ipHash: ip});
|
||||
|
||||
await mailUtil.sendAddressVerification(requestDB, newUser, email)
|
||||
//Send tokenized confirmation link to users email address
|
||||
mailUtil.sendAddressVerification(requestDB, newUser, email, true);
|
||||
}
|
||||
}
|
||||
}else{
|
||||
|
|
@ -281,7 +327,7 @@ userSchema.statics.authenticate = async function(user, pass, failLine = "Bad Use
|
|||
}
|
||||
|
||||
//Check our password is correct
|
||||
if(userDB.checkPass(pass)){
|
||||
if(await userDB.checkPass(pass)){
|
||||
return userDB;
|
||||
}else{
|
||||
//if not scream and shout
|
||||
|
|
@ -310,10 +356,11 @@ userSchema.statics.findProfile = async function(user, includeEmail = false){
|
|||
const profile = {
|
||||
id: -420,
|
||||
user: "Tokebot",
|
||||
date: (await statModel.getStats()).firstLaunch,
|
||||
tokes: await statModel.getTokeCommandCounts(),
|
||||
tokeCount: await statModel.getTokeCount(),
|
||||
img: "/img/johnny.png",
|
||||
//Look ma, no DB calls!
|
||||
date: statModel.firstLaunch,
|
||||
tokes: tokeModel.tokeMap,
|
||||
tokeCount: tokeModel.count,
|
||||
img: "/nonfree/johnny.png",
|
||||
signature: "!TOKE",
|
||||
bio: "!TOKE OR DIE!"
|
||||
};
|
||||
|
|
@ -451,8 +498,8 @@ userSchema.statics.processAgedIPRecords = async function(){
|
|||
* @param {String} pass - Password to authenticate
|
||||
* @returns {Boolean} True if authenticated
|
||||
*/
|
||||
userSchema.methods.checkPass = function(pass){
|
||||
return hashUtil.comparePassword(pass, this.pass)
|
||||
userSchema.methods.checkPass = async function(pass){
|
||||
return await hashUtil.comparePassword(pass, this.pass)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -500,6 +547,7 @@ userSchema.methods.getProfile = function(includeEmail = false){
|
|||
id: this.id,
|
||||
user: this.user,
|
||||
date: this.date,
|
||||
lastActive: this.lastActive,
|
||||
tokes: this.tokes,
|
||||
tokeCount: this.getTokeCount(),
|
||||
img: this.img,
|
||||
|
|
@ -709,8 +757,6 @@ userSchema.methods.tattooIPRecord = async function(ip){
|
|||
lastLog: new Date()
|
||||
};
|
||||
|
||||
//We should really start using for loops and stop acting like its 2008
|
||||
//Though to be quite honest this bit would be particularly brutal without them
|
||||
//For every user in the userlist
|
||||
for(let curUser of users){
|
||||
//Ensure we're not checking the user against itself
|
||||
|
|
@ -718,7 +764,7 @@ userSchema.methods.tattooIPRecord = async function(ip){
|
|||
//For every IP record in the current user
|
||||
for(let curRecord of curUser.recentIPs){
|
||||
//If it matches the current ipHash
|
||||
if(curRecord.ipHash == ipHash){
|
||||
if(hashUtil.compareIPHash(ip, curRecord.ipHash)){
|
||||
//Check if we've already marked the user as an alt
|
||||
const foundAlt = this.alts.indexOf(curUser._id);
|
||||
|
||||
|
|
@ -755,7 +801,7 @@ userSchema.methods.tattooIPRecord = async function(ip){
|
|||
//Look for matching ip record
|
||||
function checkHash(ipRecord){
|
||||
//return matching records
|
||||
return ipRecord.ipHash == ipHash;
|
||||
return hashUtil.compareIPHash(ip, ipRecord.ipHash);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -765,6 +811,9 @@ userSchema.methods.tattooIPRecord = async function(ip){
|
|||
* @param {String} reason - Reason to kill user sessions
|
||||
*/
|
||||
userSchema.methods.killAllSessions = async function(reason = "A full log-out from all devices was requested for your account."){
|
||||
//Nuke all related remember me tokens
|
||||
await rememberMeModel.deleteMany({user: this._id});
|
||||
|
||||
//get authenticated sessions
|
||||
var sessions = await this.getAuthenticatedSessions();
|
||||
|
||||
|
|
@ -782,7 +831,7 @@ userSchema.methods.killAllSessions = async function(reason = "A full log-out fro
|
|||
* @param {Object} passChange - passChange object handed down from Browser
|
||||
*/
|
||||
userSchema.methods.changePassword = async function(passChange){
|
||||
if(this.checkPass(passChange.oldPass)){
|
||||
if(await this.checkPass(passChange.oldPass)){
|
||||
if(passChange.newPass == passChange.confirmPass){
|
||||
//Note: We don't have to worry about hashing here because the schema is written to do it auto-magically
|
||||
this.pass = passChange.newPass;
|
||||
|
|
@ -835,7 +884,7 @@ userSchema.methods.nuke = async function(pass){
|
|||
}
|
||||
|
||||
//Check that the password is correct
|
||||
if(this.checkPass(pass)){
|
||||
if(await this.checkPass(pass)){
|
||||
//delete the user
|
||||
var oldUser = await this.deleteOne();
|
||||
}else{
|
||||
|
|
|
|||
106
src/server.js
106
src/server.js
|
|
@ -25,6 +25,7 @@ const fs = require('fs');
|
|||
const express = require('express');
|
||||
const session = require('express-session');
|
||||
const {createServer } = require('http');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const { Server } = require('socket.io');
|
||||
const path = require('path');
|
||||
const mongoStore = require('connect-mongo');
|
||||
|
|
@ -33,20 +34,27 @@ 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');
|
||||
|
|
@ -55,6 +63,7 @@ const channelRouter = require('./routers/channelRouter');
|
|||
const newChannelRouter = require('./routers/newChannelRouter');
|
||||
const passwordResetRouter = require('./routers/passwordResetRouter');
|
||||
const emailChangeRouter = require('./routers/emailChangeController');
|
||||
const migrateRouter = require('./routers/migrateRouter');
|
||||
//Panel
|
||||
const panelRouter = require('./routers/panelRouter');
|
||||
//Popup
|
||||
|
|
@ -66,12 +75,10 @@ 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();
|
||||
|
||||
|
|
@ -80,10 +87,14 @@ module.exports.store = mongoStore.create({mongoUrl: dbUrl});
|
|||
|
||||
//define sessionMiddleware
|
||||
const sessionMiddleware = session({
|
||||
secret: config.sessionSecret,
|
||||
secret: config.secrets.sessionSecret,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
store: module.exports.store
|
||||
store: module.exports.store,
|
||||
cookie: {
|
||||
sameSite: "strict",
|
||||
secure: config.protocol.toLowerCase() == "https"
|
||||
}
|
||||
});
|
||||
|
||||
//Declare web server
|
||||
|
|
@ -132,6 +143,14 @@ mongoose.set("sanitizeFilter", true).connect(dbUrl).then(() => {
|
|||
process.exit();
|
||||
});
|
||||
|
||||
//Static File Server, set this up first to avoid middleware running on top of it
|
||||
//Serve client-side libraries
|
||||
app.use('/lib/bootstrap-icons',express.static(path.join(__dirname, '../node_modules/bootstrap-icons'))); //Icon set
|
||||
app.use('/lib/altcha',express.static(path.join(__dirname, '../node_modules/altcha/dist_external'))); //Self-Hosted PoW-based Captcha
|
||||
app.use('/lib/hls.js',express.static(path.join(__dirname, '../node_modules/hls.js/dist'))); //HLS Media Handler
|
||||
//Server public 'www' folder
|
||||
app.use(express.static(path.join(__dirname, '../www')));
|
||||
|
||||
//Set View Engine
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', __dirname + '/views');
|
||||
|
|
@ -139,7 +158,9 @@ app.set('views', __dirname + '/views');
|
|||
//Middlware
|
||||
//Enable Express
|
||||
app.use(express.json());
|
||||
//app.use(express.urlencoded());
|
||||
|
||||
//Enable Express Ccokie-Parser
|
||||
app.use(cookieParser());
|
||||
|
||||
//Enable Express-Sessions
|
||||
app.use(sessionMiddleware);
|
||||
|
|
@ -147,9 +168,17 @@ 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);
|
||||
|
|
@ -158,6 +187,7 @@ app.use('/c', channelRouter);
|
|||
app.use('/newChannel', newChannelRouter);
|
||||
app.use('/passwordReset', passwordResetRouter);
|
||||
app.use('/emailChange', emailChangeRouter);
|
||||
app.use('/migrate', migrateRouter);
|
||||
//Panel
|
||||
app.use('/panel', panelRouter);
|
||||
//tooltip
|
||||
|
|
@ -165,39 +195,55 @@ 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);
|
||||
|
||||
//Increment launch counter
|
||||
statModel.incrementLaunchCount();
|
||||
asyncKickStart();
|
||||
|
||||
//Load default flairs
|
||||
flairModel.loadDefaults();
|
||||
/*Asyncronous Kickstarter function
|
||||
Allows us to force server startup to wait on the DB to be ready.
|
||||
Might be better if she kicked off everything at once, and ran a while loop to check when they where all done.
|
||||
This runs once at server startup, and most startups will run fairly quickly so... Not worth it?*/
|
||||
async function asyncKickStart(){
|
||||
//Lettum fuckin' know wassup
|
||||
console.log(`${config.instanceName}(Powered by Canopy ${package.canopyDisplayVersion}) is booting up!`);
|
||||
|
||||
//Load default emotes
|
||||
emoteModel.loadDefaults();
|
||||
//Run legacy migration
|
||||
await migrationModel.ingestLegacyDump();
|
||||
|
||||
//Load default toke commands
|
||||
tokeCommandModel.loadDefaults();
|
||||
//Build migration cache
|
||||
await migrationModel.buildMigrationCache();
|
||||
|
||||
//Kick off scheduled-jobs
|
||||
scheduler.kickoff();
|
||||
//Calculate Toke Map
|
||||
await tokeModel.calculateTokeMap();
|
||||
|
||||
//Hand over general-namespace socket.io connections to the channel manager
|
||||
module.exports.channelManager = new channelManager(io)
|
||||
//Load default toke commands
|
||||
await tokeCommandModel.loadDefaults();
|
||||
|
||||
//Listen Function
|
||||
webServer.listen(port, () => {
|
||||
console.log(`Opening port ${port}`);
|
||||
});
|
||||
//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`);
|
||||
});
|
||||
}
|
||||
|
|
@ -44,7 +44,7 @@ module.exports.genCaptcha = async function(difficulty = 2, uniqueSecret = ''){
|
|||
|
||||
//Generate Altcha Challenge
|
||||
return await createChallenge({
|
||||
hmacKey: [config.altchaSecret, uniqueSecret].join(''),
|
||||
hmacKey: [config.secrets.altchaSecret, uniqueSecret].join(''),
|
||||
maxNumber: 100000 * difficulty,
|
||||
expires: expiration
|
||||
});
|
||||
|
|
@ -73,5 +73,5 @@ module.exports.verify = async function(payload, uniqueSecret = ''){
|
|||
setTimeout(() => {spent.splice(payloadIndex,1);}, lifetime * 60 * 1000);
|
||||
|
||||
//Return verification results
|
||||
return await verifySolution(payload, [config.altchaSecret, uniqueSecret].join(''));
|
||||
return await verifySolution(payload, [config.secrets.altchaSecret, uniqueSecret].join(''));
|
||||
}
|
||||
|
|
@ -40,18 +40,28 @@ module.exports.securityCheck = function(){
|
|||
loggerUtil.consoleWarn("Mail transport security disabled! This server should be used for development purposes only!");
|
||||
}
|
||||
|
||||
//check password pepper
|
||||
if(!validator.isStrongPassword(config.secrets.passwordSecret) || config.secrets.passwordSecret == "CHANGE_ME"){
|
||||
loggerUtil.consoleWarn("Insecure Password Secret! Change Password Secret!");
|
||||
}
|
||||
|
||||
//check RememberMe pepper
|
||||
if(!validator.isStrongPassword(config.secrets.rememberMeSecret) || config.secrets.rememberMeSecret == "CHANGE_ME"){
|
||||
loggerUtil.consoleWarn("Insecure RememberMe Secret! Change RememberMe Secret!");
|
||||
}
|
||||
|
||||
//check session secret
|
||||
if(!validator.isStrongPassword(config.sessionSecret) || config.sessionSecret == "CHANGE_ME"){
|
||||
if(!validator.isStrongPassword(config.secrets.sessionSecret) || config.secrets.sessionSecret == "CHANGE_ME"){
|
||||
loggerUtil.consoleWarn("Insecure Session Secret! Change Session Secret!");
|
||||
}
|
||||
|
||||
//check altcha secret
|
||||
if(!validator.isStrongPassword(config.altchaSecret) || config.altchaSecret == "CHANGE_ME"){
|
||||
if(!validator.isStrongPassword(config.secrets.altchaSecret) || config.secrets.altchaSecret == "CHANGE_ME"){
|
||||
loggerUtil.consoleWarn("Insecure Altcha Secret! Change Altcha Secret!");
|
||||
}
|
||||
|
||||
//check ipHash secret
|
||||
if(!validator.isStrongPassword(config.ipSecret) || config.ipSecret == "CHANGE_ME"){
|
||||
if(!validator.isStrongPassword(config.secrets.ipSecret) || config.secrets.ipSecret == "CHANGE_ME"){
|
||||
loggerUtil.consoleWarn("Insecure IP Hashing Secret! Change IP Hashing Secret!");
|
||||
}
|
||||
|
||||
|
|
@ -64,4 +74,9 @@ module.exports.securityCheck = function(){
|
|||
if(!validator.isStrongPassword(config.mail.pass) || config.mail.pass == "CHANGE_ME"){
|
||||
loggerUtil.consoleWarn("Insecure Email Password! Change Email password!");
|
||||
}
|
||||
|
||||
//check debug mode
|
||||
if(config.debug){
|
||||
loggerUtil.consoleWarn("Debug mode enabled! Understand the risks and security implications before enabling on production servers!");
|
||||
}
|
||||
}
|
||||
|
|
@ -17,9 +17,6 @@ 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();
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const config = require('../../config.json');
|
|||
const crypto = require('node:crypto');
|
||||
|
||||
//NPM Imports
|
||||
const argon2 = require('argon2');
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
/**
|
||||
|
|
@ -28,18 +29,29 @@ const bcrypt = require('bcrypt');
|
|||
* @param {String} pass - Password to hash
|
||||
* @returns {String} Hashed/Salted password
|
||||
*/
|
||||
module.exports.hashPassword = function(pass){
|
||||
const salt = bcrypt.genSaltSync();
|
||||
return bcrypt.hashSync(pass, salt);
|
||||
module.exports.hashPassword = async function(pass){
|
||||
//Hash password with argon2id
|
||||
return await argon2.hash(pass, {secret: Buffer.from(config.secrets.passwordSecret)});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sitewide password for authenticating/comparing passwords agianst hashes
|
||||
* Sitewide method for authenticating/comparing passwords agianst hashes
|
||||
* @param {String} pass - Plaintext Password
|
||||
* @param {String} hash - Salty Hash
|
||||
* @returns {Boolean} True if authentication success
|
||||
*/
|
||||
module.exports.comparePassword = function(pass, hash){
|
||||
module.exports.comparePassword = async function(pass, hash){
|
||||
//Verify password against argon2 hash
|
||||
return await argon2.verify(hash, pass, {secret: Buffer.from(config.secrets.passwordSecret)});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sitewide method for authenticating/comparing passwords agianst hashes for legacy profiles
|
||||
* @param {String} pass - Plaintext Password
|
||||
* @param {String} hash - Salty Hash
|
||||
* @returns {Boolean} True if authentication success
|
||||
*/
|
||||
module.exports.compareLegacyPassword = function(pass, hash){
|
||||
return bcrypt.compareSync(pass, hash);
|
||||
}
|
||||
|
||||
|
|
@ -48,15 +60,67 @@ module.exports.comparePassword = function(pass, hash){
|
|||
*
|
||||
* Provides a basic level of privacy by only logging salted hashes of IP's
|
||||
* @param {String} ip - IP to hash
|
||||
* @returns {String} Hashed/Salted IP Adress
|
||||
* @param {String} salt - (optional) string to salt IP with, leave empty to default to a securely generated string encoded in base64
|
||||
* @returns {String} Hashed/Peppered/Salted IP Address
|
||||
*/
|
||||
module.exports.hashIP = function(ip){
|
||||
module.exports.hashIP= function(ip, salt){
|
||||
//Create hash object
|
||||
const hashObj = crypto.createHash('md5');
|
||||
const hashObj = crypto.createHash('sha512');
|
||||
|
||||
//add IP and salt to the hash
|
||||
hashObj.update(`${ip}${config.ipSecret}`);
|
||||
//If we wheren't provided salt
|
||||
if(salt == null){
|
||||
//Generate salt with cryptographically secure rng function
|
||||
const rawSalt = crypto.randomBytes(24);
|
||||
//Convert generated salt to base64
|
||||
salt = rawSalt.toString('base64');
|
||||
}
|
||||
|
||||
//return the IP hash as a string
|
||||
return hashObj.digest('hex');
|
||||
//Generate new salted hash
|
||||
hashObj.update(`${ip}${config.secrets.ipSecret}${salt}`);
|
||||
|
||||
//Convert hash data into a base64 string
|
||||
const hash = hashObj.digest('base64');
|
||||
|
||||
//Return salty hash
|
||||
return `${salt}$${hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)});
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ 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
|
||||
/**
|
||||
|
|
@ -25,10 +26,12 @@ module.exports.cache = new Map();
|
|||
|
||||
/**
|
||||
* Validates links and returns a marked link object that can be returned to the client to format/embed accordingly
|
||||
* @param {String} link - URL to Validate
|
||||
* @param {String} dirtyLink - URL to Validate
|
||||
* @returns {Object} Marked link object
|
||||
*/
|
||||
module.exports.markLink = async function(link){
|
||||
module.exports.markLink = async function(dirtyLink){
|
||||
const link = sanitizeUrl(dirtyLink);
|
||||
|
||||
//Check link cache for the requested link
|
||||
const cachedLink = module.exports.cache.get(link);
|
||||
|
||||
|
|
@ -44,7 +47,7 @@ module.exports.markLink = async function(link){
|
|||
var type = "malformedLink"
|
||||
|
||||
//Make sure we have an actual, factual URL
|
||||
if(validator.isURL(link)){
|
||||
if(validator.isURL(link,{require_valid_protocol: true, protocols: ['http', 'https']})){
|
||||
//The URL is valid, so this is at least a dead link
|
||||
type = 'deadLink';
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ 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');
|
||||
|
|
@ -64,8 +65,8 @@ module.exports.errorHandler = function(res, msg, type = "Generic", status = 400)
|
|||
* @param {Error} err - Exception to handle
|
||||
*/
|
||||
module.exports.localExceptionHandler = function(err){
|
||||
//If we're being verbose
|
||||
if(config.verbose){
|
||||
//If we're being verbose and this isn't just a basic bitch
|
||||
if(!err.custom && config.verbose){
|
||||
//Log the error
|
||||
module.exports.dumpError(err);
|
||||
}
|
||||
|
|
@ -172,18 +173,90 @@ 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 = function(err, date = new Date()){
|
||||
try{
|
||||
const content = `Error Date: ${date.toLocaleString()} (UTC-${date.getTimezoneOffset()/60})\nError Type: ${err.name}\nError Msg:${err.message}\nStack Trace:\n\n${err.stack}`;
|
||||
const path = `log/crash/${date.getTime()}.log`;
|
||||
module.exports.dumpError = async function(err, date = new Date(), subDir = 'crash/', muzzle = false){
|
||||
//Generate content from error
|
||||
const content = `Error Date: ${date.toLocaleString()} (UTC-${date.getTimezoneOffset()/60})\nError Type: ${err.name}\nError Msg:${err.message}\nStack Trace:\n\n${err.stack}`;
|
||||
|
||||
//Dump text to file
|
||||
module.exports.dumpLog(content, date.getTime(), subDir, muzzle);
|
||||
}
|
||||
|
||||
|
||||
module.exports.dumpSecurityLog = async function(content, date = new Date()){
|
||||
module.exports.dumpLog(content, `Incident-{${crypto.randomUUID()}}-${date.getTime()}`, 'security/', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dumps log file to log folder
|
||||
* @param {String} content - Text to dump to file
|
||||
* @param {String} name - file name to save to
|
||||
* @param {String} subDir - subdirectory inside the log folder we want to dump to
|
||||
* @param {Boolean} muzzle - Tells the function to STFU
|
||||
*/
|
||||
module.exports.dumpLog = async function(content, name, subDir = '/', muzzle = false){
|
||||
try{
|
||||
//Crash directory
|
||||
const dir = `./log/${subDir}`
|
||||
|
||||
//Double check crash folder exists
|
||||
try{
|
||||
await fs.stat(dir);
|
||||
//If we caught an error (most likely it's missing)
|
||||
}catch(err){
|
||||
if(!muzzle){
|
||||
//Shout about it
|
||||
module.exports.consoleWarn("Log folder missing, mking dir!")
|
||||
}
|
||||
|
||||
//Make it if doesn't
|
||||
await fs.mkdir(dir, {recursive: true});
|
||||
}
|
||||
|
||||
//Assemble log file path
|
||||
const path = `${dir}${name}.log`;
|
||||
|
||||
//Write content to file
|
||||
fs.writeFile(path, content);
|
||||
|
||||
module.exports.consoleWarn(`Warning: Unexpected Server Crash gracefully dumped to '${path}'... SOMETHING MAY BE VERY BROKEN!!!!`);
|
||||
if(!muzzle){
|
||||
//Whine about the error
|
||||
module.exports.consoleWarn(`Warning: Unexpected Server Crash gracefully dumped to '${path}'... SOMETHING MAY BE VERY BROKEN!!!!`);
|
||||
}
|
||||
//If somethine went really really wrong
|
||||
}catch(doubleErr){
|
||||
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);
|
||||
if(!muzzle){
|
||||
//Use humor to cope with the pain
|
||||
module.exports.consoleWarn("Yo Dawg, I herd you like errors, so I put an error in your error dump, so you can dump while you dump:");
|
||||
//Dump the original error to console
|
||||
module.exports.consoleWarn(err);
|
||||
//Dump the error we had saving that error to file to console
|
||||
module.exports.consoleWarn(doubleErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.welcomeWagon = function(count, date, tokes){
|
||||
//Inject values into ascii art
|
||||
const art = `
|
||||
\x1b[32m ! \x1b[0m
|
||||
\x1b[32m 420 \x1b[0m \x1b[32m\x1b[40m${config.instanceName}\x1b[0m\x1b[2m, Powered By:\x1b[0m
|
||||
\x1b[32m 420 \x1b[0m
|
||||
\x1b[32m WEEED \x1b[0m CCCC AAA NN N OOO PPPP Y Y
|
||||
\x1b[32m! WEEED !\x1b[0m C A A NN N O O P P Y Y
|
||||
\x1b[32mWEE EEEEE EED\x1b[0m C A A N N N O O P P Y Y
|
||||
\x1b[32m WEE EEEEE EED\x1b[0m C AAAAA N N N O O PPPP Y
|
||||
\x1b[32m WEE EEE EED\x1b[0m C A A N N N O O P Y
|
||||
\x1b[32m WEE EEE EED\x1b[0m C A A N NN O O P Y
|
||||
\x1b[32m WEEEEED\x1b[0m CCCC A A N NN OOO P Y
|
||||
\x1b[32m WEEE ! EEED\x1b[0m
|
||||
\x1b[32m !\x1b[0m \x1b[34mInitialization Complete!\x1b[0m This server has booted \x1b[4m${count}\x1b[0m time${count == 1 ? '' : 's'} and taken \x1b[4m${tokes}\x1b[0m toke${tokes == 1 ? '' : 's'}.
|
||||
\x1b[32m !\x1b[0m This server was first booted on \x1b[4m${date}\x1b[0m.`
|
||||
|
||||
//Dump art to console
|
||||
console.log(art);
|
||||
//Add some extra padding for the port printout from server.js
|
||||
process.stdout.write(' ');
|
||||
}
|
||||
|
|
@ -19,6 +19,11 @@ const config = require('../../config.json');
|
|||
|
||||
//NPM imports
|
||||
const nodeMailer = require("nodemailer");
|
||||
const validator = require('validator');
|
||||
|
||||
//local imports
|
||||
const loggerUtils = require('./loggerUtils');
|
||||
|
||||
|
||||
//Setup mail transport
|
||||
/**
|
||||
|
|
@ -43,28 +48,38 @@ const transporter = nodeMailer.createTransport({
|
|||
* @returns {Object} Sent mail info
|
||||
*/
|
||||
module.exports.mailem = async function(to, subject, body, htmlBody = false){
|
||||
//Create mail object
|
||||
const mailObj = {
|
||||
from: `"Tokebot🤖💨"<${config.mail.address}>`,
|
||||
to,
|
||||
subject
|
||||
};
|
||||
try{
|
||||
//If we have a bad email address
|
||||
if(!validator.isEmail(to)){
|
||||
//fuck off
|
||||
return;
|
||||
}
|
||||
|
||||
//If we're sending HTML
|
||||
if(htmlBody){
|
||||
//set body as html
|
||||
mailObj.html = body;
|
||||
//If we're sending plaintext
|
||||
}else{
|
||||
//Set body as plaintext
|
||||
mailObj.text = body
|
||||
//Create mail object
|
||||
const mailObj = {
|
||||
from: `"Tokebot🤖💨"<${config.mail.address}>`,
|
||||
to,
|
||||
subject
|
||||
};
|
||||
|
||||
//If we're sending HTML
|
||||
if(htmlBody){
|
||||
//set body as html
|
||||
mailObj.html = body;
|
||||
//If we're sending plaintext
|
||||
}else{
|
||||
//Set body as plaintext
|
||||
mailObj.text = body
|
||||
}
|
||||
|
||||
//Send mail based on mail object
|
||||
const sentMail = await transporter.sendMail(mailObj);
|
||||
|
||||
//return the mail info
|
||||
return sentMail;
|
||||
}catch(err){
|
||||
loggerUtils.dumpError(err, new Date(), 'crash/mail/');
|
||||
}
|
||||
|
||||
//Send mail based on mail object
|
||||
const sentMail = await transporter.sendMail(mailObj);
|
||||
|
||||
//return the mail info
|
||||
return sentMail;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -72,16 +87,40 @@ module.exports.mailem = async function(to, subject, body, htmlBody = false){
|
|||
* @param {Mongoose.Document} requestDB - DB Document Object for the current email change request token
|
||||
* @param {Mongoose.Document} userDB - DB Document Object for the user we're verifying email against
|
||||
* @param {String} newEmail - New email address to send to
|
||||
* @param {Boolean} newUser - Denotes an email going out to a new account
|
||||
* @param {Boolean} migration - Denotes an email going out to an account which was just mirated
|
||||
*/
|
||||
module.exports.sendAddressVerification = async function(requestDB, userDB, newEmail){
|
||||
module.exports.sendAddressVerification = async function(requestDB, userDB, newEmail, newUser = false, migration = false,){
|
||||
let subject = `Email Change Request - ${userDB.user}`;
|
||||
let content = `<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>`;
|
||||
}
|
||||
|
||||
|
||||
//Send the reset url via email
|
||||
await module.exports.mailem(
|
||||
newEmail,
|
||||
`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>`,
|
||||
subject,
|
||||
content,
|
||||
true
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ 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');
|
||||
|
|
@ -66,24 +67,41 @@ module.exports.yankMedia = async function(url, title){
|
|||
module.exports.refreshRawLink = async function(mediaObj){
|
||||
switch(mediaObj.type){
|
||||
case 'yt':
|
||||
//Scrape expiration from query strings
|
||||
const expires = mediaObj.rawLink.match(/expire=([0-9]+)/);
|
||||
//Went with regex for speed, but I figure I'd keep this around in case we want the accuracy of a battle-tested implementation
|
||||
//const expires = new URL(mediaObj.rawLink).searchParams.get("expire");
|
||||
//Create boolean to hold expired state
|
||||
let expired = false;
|
||||
//Create boolean to hold whether or not rawLink object is empty
|
||||
let empty = true;
|
||||
|
||||
//If we have a valid raw file link that will be good by the end of the video
|
||||
if(expires != null && (expires * 1000) > mediaObj.getEndTime()){
|
||||
//Return null to tell the calling function there is no refresh required for this video at this time
|
||||
return null;
|
||||
//For each link map in the rawLink object
|
||||
for(const key of Object.keys(mediaObj.rawLink)){
|
||||
//Ignore da wombo-combo since it's probably just the fuckin regular URL
|
||||
if(key != "combo"){
|
||||
for(const link of mediaObj.rawLink[key]){
|
||||
//Let it be known, this bitch got links
|
||||
empty = false;
|
||||
//Get expiration parameter from the link
|
||||
const expires = new URL(link[1]).searchParams.get("expire") * 1000;
|
||||
|
||||
//If this shit's already expired
|
||||
if(expires < Date.now()){
|
||||
//Set expired to true, don't directly set the bool because we don't ever want to unset this flag
|
||||
expired = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Re-fetch media metadata
|
||||
metadata = await ytdlpUtil.fetchYoutubeMetadata(mediaObj.id);
|
||||
//Refresh media rawlink from metadata
|
||||
mediaObj.rawLink = metadata[0].rawLink;
|
||||
//If the raw link object is empty or expired
|
||||
if(empty || expired){
|
||||
//Re-fetch media metadata
|
||||
metadata = await ytdlpUtil.fetchYoutubeMetadata(mediaObj.id);
|
||||
|
||||
//return media object
|
||||
return mediaObj;
|
||||
//Refresh media rawlink from metadata
|
||||
mediaObj.rawLink = metadata[0].rawLink;
|
||||
|
||||
//return media object
|
||||
return mediaObj;
|
||||
}
|
||||
}
|
||||
|
||||
//Return null to tell the calling function there is no refresh required for this media type
|
||||
|
|
@ -96,12 +114,15 @@ module.exports.refreshRawLink = async function(mediaObj){
|
|||
* Still this has some improvements like url pre-checks and the fact that it's handled serverside, recuing possibility of bad requests.
|
||||
* Some of the regex expressions for certain services have also been improved, such as youtube, and the fore.st-unique archive.org
|
||||
*
|
||||
* @param {String} url - URL to determine media type of
|
||||
* @param {String} dirtyURL - URL to determine media type of
|
||||
* @returns {Object} containing URL type and clipped ID string
|
||||
*/
|
||||
module.exports.getMediaType = async function(url){
|
||||
module.exports.getMediaType = async function(dirtyURL){
|
||||
//Sanatize our URL
|
||||
const url = sanitizeUrl(dirtyURL);
|
||||
|
||||
//Check if we have a valid url, encode it on the fly in case it's too humie-friendly
|
||||
if(!validator.isURL(encodeURI(url))){
|
||||
if(!validator.isURL(encodeURI(url,{require_valid_protocol: true}))){
|
||||
//If not toss the fucker out
|
||||
return {
|
||||
type: null,
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ module.exports.fetchDailymotionMetadata = async function(id, title){
|
|||
* @param {String} type - Link type to attach to the resulting media object
|
||||
* @returns {Array} Array of Media objects containing relevant metadata
|
||||
*/
|
||||
async function fetchVideoMetadata(link, title, type, format = 'b'){
|
||||
async function fetchVideoMetadata(link, title, type, format = 'ba,bv'){
|
||||
//Create media list
|
||||
const mediaList = [];
|
||||
|
||||
|
|
@ -109,16 +109,40 @@ async function fetchVideoMetadata(link, title, type, format = 'b'){
|
|||
|
||||
//Pull data from rawMetadata, sanatizing title to prevent XSS
|
||||
const name = validator.escape(validator.trim(rawMetadata.title));
|
||||
const rawLink = rawMetadata.requested_downloads[0].url;
|
||||
|
||||
//Create new raw link object (should we make a class? Probably over kill for a fucking method-less hashtable)
|
||||
const rawLinks = {
|
||||
audio: [],
|
||||
video: [],
|
||||
combo: []
|
||||
}
|
||||
|
||||
//for each item
|
||||
for(const link of rawMetadata.requested_downloads){
|
||||
//if there isn't video included
|
||||
if(link.vcodec == 'none'){
|
||||
//Add the link under the format within the audio map
|
||||
rawLinks.audio.push([link.format_note, link.url]);
|
||||
//if there isn't audio included
|
||||
}else if(link.acodec == 'none'){
|
||||
//Add the link under the format within the video map
|
||||
rawLinks.video.push([link.format_note, link.url]);
|
||||
//otherwise, it includes audio and video
|
||||
}else{
|
||||
//Add the link under the format within the combo map
|
||||
rawLinks.combo.push([link.format_note, link.url]);
|
||||
}
|
||||
}
|
||||
|
||||
const id = rawMetadata.id;
|
||||
|
||||
//if we where handed a null title
|
||||
if(title == null || title == ''){
|
||||
//Create new media object from file info substituting filename for title
|
||||
mediaList.push(new media(name, name, link, id, type, Number(rawMetadata.duration), rawLink));
|
||||
mediaList.push(new media(name, name, link, id, type, Number(rawMetadata.duration), rawLinks));
|
||||
}else{
|
||||
//Create new media object from file info
|
||||
mediaList.push(new media(title, name, link, id, type, Number(rawMetadata.duration), rawLink));
|
||||
mediaList.push(new media(title, name, link, id, type, Number(rawMetadata.duration), rawLinks));
|
||||
}
|
||||
|
||||
//Return list of media
|
||||
|
|
@ -136,10 +160,10 @@ async function fetchVideoMetadata(link, title, type, format = 'b'){
|
|||
* @param {String} format - Format string to hand YT-DLP, defaults to 'b'
|
||||
* @returns {Object} Metadata dump from YT-DLP
|
||||
*/
|
||||
async function ytdlpFetch(link, format = 'b'){
|
||||
async function ytdlpFetch(link, format = 'ba,ogg'){
|
||||
//return promise from ytdlp
|
||||
return ytdlp(link, {
|
||||
format,
|
||||
dumpSingleJson: true,
|
||||
format
|
||||
});
|
||||
}
|
||||
134
src/utils/presenceUtils.js
Normal file
134
src/utils/presenceUtils.js
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
/*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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -22,9 +22,9 @@ const {userModel} = require('../schemas/user/userSchema');
|
|||
const userBanModel = require('../schemas/user/userBanSchema');
|
||||
const passwordResetModel = require('../schemas/user/passwordResetSchema');
|
||||
const emailChangeModel = require('../schemas/user/emailChangeSchema');
|
||||
const rememberMeModel = require('../schemas/user/rememberMeSchema');
|
||||
const channelModel = require('../schemas/channel/channelSchema');
|
||||
const sessionUtils = require('./sessionUtils');
|
||||
const { email } = require('../validators/accountValidator');
|
||||
|
||||
/**
|
||||
* Schedules all timed jobs accross the server
|
||||
|
|
@ -42,6 +42,8 @@ module.exports.schedule = function(){
|
|||
cron.schedule('0 0 * * *', ()=>{passwordResetModel.processExpiredRequests()},{scheduled: true, timezone: "UTC"});
|
||||
//Process expired email change requests every night at midnight
|
||||
cron.schedule('0 0 * * *', ()=>{emailChangeModel.processExpiredRequests()},{scheduled: true, timezone: "UTC"});
|
||||
//Process expired remember me tokens every night at midnight
|
||||
cron.schedule('0 0 * * *', ()=>{rememberMeModel.processExpiredTokens()},{scheduled: true, timezone: "UTC"});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -58,6 +60,8 @@ module.exports.kickoff = function(){
|
|||
passwordResetModel.processExpiredRequests();
|
||||
//Process expired email change requests that may have expired since last restart
|
||||
emailChangeModel.processExpiredRequests();
|
||||
//Process expired remember me tokens that may have expired since last restart
|
||||
rememberMeModel.processExpiredTokens()
|
||||
|
||||
//Schedule jobs
|
||||
module.exports.schedule();
|
||||
|
|
|
|||
|
|
@ -14,10 +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/>.*/
|
||||
|
||||
//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 userBanModel = require('../schemas/user/userBanSchema.js');
|
||||
const rememberMeModel = require('../schemas/user/rememberMeSchema.js');
|
||||
const altchaUtils = require('../utils/altchaUtils.js');
|
||||
const loggerUtils = require('../utils/loggerUtils.js');
|
||||
|
||||
|
|
@ -41,16 +45,19 @@ const maxAttempts = 200;
|
|||
* Sole and Singular Session Authentication method.
|
||||
* All logins should happen through here, all other site-wide authentication should happen by sessions authenticated by this model.
|
||||
* This is important, as reducing authentication endpoints reduces attack surface.
|
||||
* @param {String} user - Username to login as
|
||||
* @param {String} pass - Password to authenticat session with
|
||||
*
|
||||
* Ended up not splitting this in two/three for remember-me tokens. Kind of fucked up it was actually easier this way...
|
||||
* @param {String} identifier - Identifer used to identify account, either username or token UUID
|
||||
* @param {String} secret - Secret to authenticate session with, either password or token secret
|
||||
* @param {express.Request} req - Express request object w/ session to authenticate
|
||||
* @param {Boolean} useRememberMeToken - Whether or not we're using username/pass or remember-me tokens
|
||||
* @returns Username of authticated user upon success
|
||||
*/
|
||||
module.exports.authenticateSession = async function(user, pass, req){
|
||||
module.exports.authenticateSession = async function(identifier, secret, req, useRememberMeToken = false){
|
||||
//Fuck you yoda
|
||||
try{
|
||||
//Grab previous attempts
|
||||
const attempt = failedAttempts.get(user);
|
||||
const attempt = failedAttempts.get(identifier);
|
||||
|
||||
//If we're proxied use passthrough IP
|
||||
const ip = config.proxied ? req.headers['x-forwarded-for'] : req.ip;
|
||||
|
|
@ -74,7 +81,7 @@ module.exports.authenticateSession = async function(user, pass, req){
|
|||
}
|
||||
|
||||
//If we have failed attempts
|
||||
if(attempt != null){
|
||||
if(!useRememberMeToken && attempt != null){
|
||||
//If we have more failed attempts than allowed
|
||||
if(attempt.count > maxAttempts){
|
||||
throw loggerUtils.exceptionSmith("This account has been locked for at 24 hours due to a large amount of failed log-in attempts", "unauthorized");
|
||||
|
|
@ -86,14 +93,23 @@ module.exports.authenticateSession = async function(user, pass, req){
|
|||
//Since we've already got access to the request and dont need to import anything, why bother getting it from a parameter?
|
||||
if(req.body.verification == null){
|
||||
throw loggerUtils.exceptionSmith("Verification failed!", "unauthorized");
|
||||
}else if(!altchaUtils.verify(req.body.verification, user)){
|
||||
}else if(!altchaUtils.verify(req.body.verification, identifier)){
|
||||
throw loggerUtils.exceptionSmith("Verification failed!", "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Authenticate the session
|
||||
const userDB = await userModel.authenticate(user, pass);
|
||||
//define/scope empty userDB variable
|
||||
let userDB = null;
|
||||
|
||||
//If we're using remember me tokens
|
||||
if(useRememberMeToken){
|
||||
userDB = await rememberMeModel.authenticate(identifier, secret);
|
||||
//Otherwise
|
||||
}else{
|
||||
//Fallback on to username/password authentication
|
||||
userDB = await userModel.authenticate(identifier, secret);
|
||||
}
|
||||
|
||||
//Check for user ban
|
||||
const userBanDB = await userBanModel.checkBanByUserDoc(userDB);
|
||||
|
|
@ -123,32 +139,39 @@ module.exports.authenticateSession = async function(user, pass, req){
|
|||
//Tattoo hashed IP address to user account for seven days
|
||||
userDB.tattooIPRecord(ip);
|
||||
|
||||
//If we got to here then the log-in was successful. We should clear-out any failed attempts.
|
||||
failedAttempts.delete(user);
|
||||
|
||||
//return user
|
||||
return userDB.user;
|
||||
}catch(err){
|
||||
//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(!useRememberMeToken){
|
||||
//If we got to here then the log-in was successful. We should clear-out any failed attempts.
|
||||
failedAttempts.delete(identifier);
|
||||
}
|
||||
|
||||
//Commit the failed attempt to the failed sign-in cache
|
||||
failedAttempts.set(user, attempt);
|
||||
//return user
|
||||
return userDB;
|
||||
}catch(err){
|
||||
//Failed attempts at good tokens are handled by the token schema by dropping the users effected tokens and screaming bloody murder
|
||||
//Failed attempts with bad tokens don't need to be handled as it's not like attacking a bad UUID is going to get you anywhere anywho
|
||||
//This also makes it way easier to re-use parts of this function
|
||||
if(!useRememberMeToken){
|
||||
//Look for previous failed attempts
|
||||
var attempt = failedAttempts.get(identifier);
|
||||
|
||||
//If this is the first attempt
|
||||
if(attempt == null){
|
||||
//Create new attempt object
|
||||
attempt = {
|
||||
count: 1,
|
||||
lastAttempt: new Date()
|
||||
}
|
||||
}else{
|
||||
//Create updated attempt object
|
||||
attempt = {
|
||||
count: attempt.count + 1,
|
||||
lastAttempt: new Date()
|
||||
}
|
||||
}
|
||||
|
||||
//Commit the failed attempt to the failed sign-in cache
|
||||
failedAttempts.set(identifier, attempt);
|
||||
}
|
||||
|
||||
//y33t
|
||||
throw err;
|
||||
|
|
@ -191,5 +214,53 @@ 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;
|
||||
105
src/utils/socketUtils.js
Normal file
105
src/utils/socketUtils.js
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/*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];
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ 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');
|
||||
|
|
@ -99,11 +100,15 @@ module.exports.img = function(field = 'img'){
|
|||
isURL: {
|
||||
options: {
|
||||
require_tld: false,
|
||||
require_host: false
|
||||
require_host: false,
|
||||
require_valid_protocol: true
|
||||
},
|
||||
errorMessage: "Invalid URL."
|
||||
},
|
||||
trim: true
|
||||
trim: true,
|
||||
customSanitizer: {
|
||||
options: sanitizeUrl
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -172,19 +177,41 @@ module.exports.rank = function(field = 'rank'){
|
|||
});
|
||||
}
|
||||
|
||||
module.exports.securityToken = function(field = 'token'){
|
||||
return checkSchema({
|
||||
[field]: {
|
||||
escape: true,
|
||||
trim: true,
|
||||
isHexadecimal: true,
|
||||
isLength: {
|
||||
options: {
|
||||
min: 32,
|
||||
max: 32
|
||||
}
|
||||
},
|
||||
errorMessage: "Invalid security token."
|
||||
const securityTokenSchema = {
|
||||
escape: true,
|
||||
trim: true,
|
||||
isHexadecimal: true,
|
||||
isLength: {
|
||||
options: {
|
||||
min: 64,
|
||||
max: 64
|
||||
}
|
||||
});
|
||||
},
|
||||
errorMessage: "Invalid security token."
|
||||
}
|
||||
|
||||
module.exports.securityToken = function(field = 'token'){
|
||||
return checkSchema({[field]:securityTokenSchema});
|
||||
}
|
||||
|
||||
module.exports.rememberMeID = function(field = 'rememberme.id'){
|
||||
return checkSchema({
|
||||
[field]:{
|
||||
in: ['cookies'],
|
||||
optional: true,
|
||||
isUUID: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
module.exports.rememberMeToken = function(field = 'rememberme.token'){
|
||||
//Create our own schema with blackjack and hookers
|
||||
const tokenSchema = structuredClone(securityTokenSchema);
|
||||
|
||||
//Modify as needed
|
||||
tokenSchema.in = ['cookies'];
|
||||
tokenSchema.optional = true;
|
||||
|
||||
//Return the validator
|
||||
return checkSchema({[field]:tokenSchema});
|
||||
}
|
||||
|
|
@ -83,7 +83,11 @@ module.exports.settingsMap = function(){
|
|||
},
|
||||
'settingsMap.streamURL': {
|
||||
optional: true,
|
||||
isURL: true,
|
||||
isURL: {
|
||||
options:{
|
||||
require_valid_protocol: true
|
||||
}
|
||||
},
|
||||
errorMessage: "Invalid Stream URL"
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -48,7 +48,8 @@ module.exports.link = function(field = 'link'){
|
|||
isURL: {
|
||||
options: {
|
||||
require_tld: false,
|
||||
require_host: false
|
||||
require_host: false,
|
||||
require_valid_protocol: true
|
||||
},
|
||||
errorMessage: "Invalid URL."
|
||||
},
|
||||
|
|
@ -76,7 +77,7 @@ module.exports.manualLink = function(input){
|
|||
const clean = validator.trim(input)
|
||||
|
||||
//If we have a URL return the trimmed input
|
||||
if(validator.isURL(clean)){
|
||||
if(validator.isURL(clean,{require_valid_protocol: true})){
|
||||
return clean;
|
||||
}
|
||||
|
||||
|
|
|
|||
51
src/views/about.ejs
Normal file
51
src/views/about.ejs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<%# Canopy - The next generation of stoner streaming software
|
||||
Copyright (C) 2024-2025 Rainbownapkin and the TTN Community
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <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>
|
||||
|
|
@ -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}) %>
|
||||
<%- include('partial/adminPanel/userList', {user, userList, rankEnum}) %>
|
||||
<%- include('partial/adminPanel/channelList', {chanGuide, unescape}) %>
|
||||
<%- include('partial/adminPanel/userList', {user, userList, rankEnum, unescape}) %>
|
||||
<%- include('partial/adminPanel/permList', {permList, rankEnum}) %>
|
||||
<%- include('partial/adminPanel/userBanList') %>
|
||||
<%- include('partial/adminPanel/tokeCommandList') %>
|
||||
|
|
|
|||
|
|
@ -47,12 +47,14 @@ 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>
|
||||
|
|
|
|||
|
|
@ -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"><%- channel.name %> - Channel Settings</h1>
|
||||
<h1 class="panel-title"><%= unescape(channel.name) %> - Channel Settings</h1>
|
||||
<div class="admin-panel-container-div">
|
||||
<%- include('partial/channelSettings/info.ejs', {channel}); %>
|
||||
<%- include('partial/channelSettings/info.ejs', {unescape, channel}); %>
|
||||
<%- include('partial/channelSettings/userList.ejs'); %>
|
||||
<%- include('partial/channelSettings/banList.ejs'); %>
|
||||
<%- include('partial/channelSettings/settings.ejs'); %>
|
||||
<%- include('partial/channelSettings/permList.ejs'); %>
|
||||
<%- include('partial/channelSettings/settings.ejs', {unescape, channel}); %>
|
||||
<%- include('partial/channelSettings/permList.ejs', {channel}); %>
|
||||
<%- include('partial/channelSettings/tokeCommandList.ejs'); %>
|
||||
<%- include('partial/channelSettings/emoteList.ejs'); %>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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-<%- 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>
|
||||
<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>
|
||||
</span>
|
||||
</div>
|
||||
<% }); %>
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ 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>
|
||||
|
|
|
|||
49
src/views/migrate.ejs
Normal file
49
src/views/migrate.ejs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<%# Canopy - The next generation of stoner streaming software
|
||||
Copyright (C) 2024-2025 Rainbownapkin and the TTN Community
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <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>
|
||||
|
|
@ -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-<%- 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 %>">
|
||||
<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)) %>">
|
||||
</a>
|
||||
</td>
|
||||
<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 %>
|
||||
<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) %>
|
||||
</a>
|
||||
</td>
|
||||
<td id="admin-channel-list-entry-description-<%- channel.name %>" class="large admin-list-entry-item not-first-col">
|
||||
<%- channel.description %>
|
||||
<td id="admin-channel-list-entry-description-<%= unescape(channel.name) %>" class="large admin-list-entry-item not-first-col">
|
||||
<%= unescape(channel.description) %>
|
||||
</td>
|
||||
</tr>
|
||||
<% }); %>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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-<%- curUser.user %>" class="admin-list-entry" data-name="<%- curUser.user %>" >
|
||||
<tr id="admin-user-list-entry-<%= unescape(curUser.user) %>" class="admin-list-entry" data-name="<%= unescape(curUser.user) %>" >
|
||||
<td class="admin-list-entry-item">
|
||||
<a href="/profile/<%- curUser.user %>" class="admin-list-entry-item">
|
||||
<img class="admin-list-entry-item" src="<%- curUser.img %>">
|
||||
<a href="/profile/<%= unescape(curUser.user) %>" class="admin-list-entry-item">
|
||||
<img class="admin-list-entry-item" src="<%= encodeURI(unescape(curUser.img)) %>">
|
||||
</a>
|
||||
</td>
|
||||
<td class="admin-list-entry-item not-first-col">
|
||||
<a href="/profile/<%- curUser.user %>" class="admin-list-entry-item">
|
||||
<%- curUser.id %>
|
||||
<a href="/profile/<%= encodeURI(unescape(curUser.user)) %>" class="admin-list-entry-item">
|
||||
<%= curUser.id %>
|
||||
</a>
|
||||
</td>
|
||||
<td class="admin-list-entry-item not-first-col">
|
||||
<a href="/profile/<%- curUser.user %>" class="admin-list-entry-item admin-user-list-name">
|
||||
<%- curUser.user %>
|
||||
<a href="/profile/<%= encodeURI(unescape(curUser.user)) %>" class="admin-list-entry-item admin-user-list-name">
|
||||
<%= unescape(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-<%- curUser.user %>" class="admin-user-list-rank-select">
|
||||
<select id="admin-user-list-rank-select-<%= unescape(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">
|
||||
<%- curUser.email ? curUser.email : "N/A" %>
|
||||
<%= unescape(curUser.email) ? curUser.email : "N/A" %>
|
||||
</td>
|
||||
<td class="admin-list-entry-item not-first-col">
|
||||
<%- curUser.date.toUTCString() %>
|
||||
<%= unescape(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: <%- 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>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
<% }); %>
|
||||
|
|
|
|||
|
|
@ -74,7 +74,8 @@ 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-magic" id="chat-panel-admin-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-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>
|
||||
|
|
|
|||
|
|
@ -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="<%= channel.thumbnail %>" placeholder="Thumbnail URL" style="display: none;" id="channel-info-thumbnail-prompt">
|
||||
<img class="interactive" src="<%= channel.thumbnail %> " id="channel-info-thumbnail">
|
||||
<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">
|
||||
</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"><%= channel.description %></p>
|
||||
<p class="interactive" id="channel-info-description"><%= unescape(channel.description) %></p>
|
||||
<span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -20,10 +20,11 @@ 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">
|
||||
<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">
|
||||
<%# 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">
|
||||
<%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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -14,14 +14,19 @@ GNU Affero General Public License for more details.
|
|||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>. %>
|
||||
<div id="navbar">
|
||||
<p class="navbar-item" id="instance-title"><a href="/" class="navbar-item"><%= instance %></a></p>
|
||||
<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>
|
||||
<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="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="/about">About</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></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> - <a class="navbar-item" href="/about">About</a></p>
|
||||
<% } %>
|
||||
</span>
|
||||
</div>
|
||||
39
src/views/partial/panels/pm.ejs
Normal file
39
src/views/partial/panels/pm.ejs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<%# Canopy - The next generation of stoner streaming software
|
||||
Copyright (C) 2024-2025 Rainbownapkin and the TTN Community
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <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>
|
||||
|
|
@ -18,15 +18,22 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. %>
|
|||
<% if(profile == null){ %>
|
||||
<p>Profile not found!</p>
|
||||
<% }else{ %>
|
||||
<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>
|
||||
<% 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>
|
||||
<% } %>
|
||||
</div>
|
||||
|
|
@ -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</option>
|
||||
<option value="raw">Raw File Playback (Expiremental)</option>
|
||||
<option value="embed">Official Embed</option>
|
||||
</select>
|
||||
</span>
|
||||
|
|
@ -45,5 +45,26 @@ 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>
|
||||
|
|
@ -15,11 +15,23 @@ 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"><%- profile.bio.replaceAll('\n','<br>') %></p>
|
||||
<p class="profile-item interactive" id="profile-bio-content">
|
||||
<% for(const line of splitBio){ %>
|
||||
<%= unescape(line) %><br>
|
||||
<% } %>
|
||||
</p>
|
||||
<textarea class="profile-item-prompt" id="profile-bio-prompt"></textarea>
|
||||
<% }else{ %>
|
||||
<p class="profile-item" id="profile-bio-content"><%- profile.bio.replaceAll('\n','<br>') %></p>
|
||||
<p class="profile-item" id="profile-bio-content">
|
||||
<% for(const line of splitBio){ %>
|
||||
<%= unescape(line) %><br>
|
||||
<% } %>
|
||||
</p>
|
||||
<% } %>
|
||||
</span>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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="<%- profile.img %>">
|
||||
<img class="profile-item" id="profile-img-content" src="<%= encodeURI(unescape(profile.img)) %>">
|
||||
<% if(selfProfile){ %>
|
||||
<input class="profile-item-prompt" id="profile-img-prompt">
|
||||
<% } %>
|
||||
|
|
|
|||
|
|
@ -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"><%- profile.pronouns %></span></p>
|
||||
<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>
|
||||
<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"><%- profile.pronouns %></span></p>
|
||||
<p class="profile-item profile-item-oneliner" id="profile-pronouns">Pronouns: <span class="profile-content" id="profile-pronouns-content"><%= unescape(profile.pronouns) %></span></p>
|
||||
<% } %>
|
||||
</span>
|
||||
<% } %>
|
||||
|
|
@ -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"><%= profile.email %></h4>
|
||||
<h4 class="account-settings" id="account-email-address"><%= unescape(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>
|
||||
|
|
|
|||
|
|
@ -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"><%- profile.signature %></span></p>
|
||||
<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>
|
||||
<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"><%- profile.signature %></span></p>
|
||||
<p class="profile-item profile-item-oneliner" id="profile-signature">Signature: <span class="profile-content" id="profile-signature-content"><%= unescape(profile.signature) %></span></p>
|
||||
<% } %>
|
||||
</span>
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue