Compare commits
220 commits
| 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 | ||
|
|
261dce7b29 | ||
|
|
dd39f76725 | ||
|
|
dca788dfc0 | ||
|
|
3d4e0a6621 | ||
|
|
eb8ada7fa0 | ||
|
|
de11803cea | ||
|
|
9eeed591ad | ||
|
|
92659929b9 | ||
|
|
e539b9d75d | ||
|
|
3ab6c6c715 | ||
|
|
cbd2136ca6 | ||
|
|
9650caeed0 | ||
|
|
770bdc4794 | ||
|
|
132fdabb29 | ||
|
|
306f22aa93 | ||
|
|
e9c474eaf0 | ||
|
|
08d2bf8bc9 | ||
|
|
cb5f1dbf9b | ||
|
|
edbc9909a1 | ||
|
|
f34ad4829c | ||
|
|
1aa836ba48 | ||
|
|
2e89d4e6dc | ||
|
|
0e1b48c02c | ||
|
|
c0f219276f | ||
|
|
ac06f839ea | ||
|
|
5ad20f6823 | ||
|
|
514b00a714 | ||
|
|
7d31cc9e8a | ||
|
|
944d91377b | ||
|
|
76e2f56eb4 | ||
|
|
ec37c2f59d | ||
|
|
b78d35d0f8 | ||
|
|
1d5c1037ab | ||
|
|
7b8c44158f | ||
|
|
2303c89bcf | ||
|
|
ad057916d8 | ||
|
|
fd760e4dab | ||
|
|
c64b315fdf | ||
|
|
366df357b8 | ||
|
|
8821b5cff9 | ||
|
|
370a08cb03 | ||
|
|
4001ad2f13 | ||
|
|
688afa09e8 | ||
|
|
8ce6bc5285 | ||
|
|
faaa2ff658 | ||
|
|
d26a2e0293 | ||
|
|
639b183f87 | ||
|
|
b5e54afe99 | ||
|
|
92edd74aaa | ||
|
|
8c8b2a6f0b | ||
|
|
85c1258bb6 | ||
|
|
89f78ae265 | ||
|
|
b0cca2c6fc | ||
|
|
afa57e8080 | ||
|
|
a927b31919 | ||
|
|
dd00a11b92 | ||
|
|
c6de68b474 | ||
|
|
93265b7890 | ||
|
|
e4bebce431 | ||
|
|
473b54356e | ||
|
|
1b0caa5e02 | ||
|
|
1344756449 | ||
|
|
047c368b70 | ||
|
|
aadeac03df | ||
|
|
336c746ba7 | ||
|
|
2a3740dece | ||
|
|
60cd21d938 | ||
|
|
a71f1d6cc0 | ||
|
|
9d9aa5672f | ||
|
|
0ce0685fd5 | ||
|
|
67c687a8d3 | ||
|
|
a62be27db7 | ||
|
|
781b6a8311 | ||
|
|
e2e6d975fb | ||
|
|
7badaa927c | ||
|
|
1f00bacb6f | ||
|
|
2d5afc23d7 | ||
|
|
b253f817f4 | ||
|
|
99fa549469 | ||
|
|
d3aa4b8274 | ||
|
|
23ceb74883 | ||
|
|
a6228a9fd9 | ||
|
|
8305494915 | ||
|
|
730d816c45 | ||
|
|
853f67fe15 | ||
|
|
7d3c31f0aa | ||
|
|
6639cee8ca | ||
|
|
d9bab09d53 | ||
|
|
4dff3e562b | ||
|
|
cc5c63d3b1 | ||
|
|
8b6aa69c51 | ||
|
|
46a7e9e067 | ||
|
|
af982f8611 | ||
|
|
9bf68a499b | ||
|
|
eadfa7c126 | ||
|
|
bddbb3a4a3 | ||
|
|
8baa42478a | ||
|
|
878ee4bb2d | ||
|
|
ef0344942b | ||
|
|
e34dcbdec7 | ||
|
|
dcafd8faf6 | ||
|
|
46e1b2c45b | ||
|
|
e226d45dce | ||
|
|
ee68dba673 | ||
|
|
4ed4b572f2 | ||
|
|
e46513cc1a | ||
|
|
401b9e4fee | ||
|
|
069cb423fd | ||
|
|
567eb5b574 |
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -1,5 +1,12 @@
|
|||
node_modules/
|
||||
log/*
|
||||
www/doc/*/*
|
||||
package-lock.json
|
||||
config.json
|
||||
config.json.old
|
||||
state.json
|
||||
chatexamples.txt
|
||||
chatexamples.txt
|
||||
server.cert
|
||||
server.key
|
||||
www/nonfree/*
|
||||
migration/*
|
||||
72
README.md
72
README.md
|
|
@ -1,30 +1,44 @@
|
|||
Canopy - 0.2-INDEV
|
||||
======
|
||||
|
||||
Canopy - /ˈkæ.nə.pi/:
|
||||
- The upper layer of foliage and branches of a forest, containing the majority of animal life.
|
||||
|
||||
Canopy is a community chat & synced video embedding web application, intended to replace fore.st as the server software for ourfore.st.
|
||||
This new codebase intends to solve the following issues with the current CyTube based software:
|
||||
|
||||
- Unmaintained upstream codebase.
|
||||
- Different goals.
|
||||
- Different coding styles.
|
||||
- Obsolete Technology and Dependencies.
|
||||
- General Clunk
|
||||
- Less Unique Community Identity
|
||||
|
||||
Canopy intends to be a simple node/express.js app. It leverages the piped and the internet archive REST api's, as well as yt-dlp, for metadata gathering. Persistant storage is handled by mongodb, as it's document based nature inherintly works well for cleanly storing large config documents for user/channel settings, and the low use of inter-collection references within the canopy software. All hardcore security functions like session handling, CSRF mitigation, and password hashing are handled by industry-standard open source libraries such as express-sessions, csrf-sync, and bcrypt, however it IS hobbiest software, and it should be treated as such.
|
||||
|
||||
The Canopy codebase does not, nor will it ever contain:
|
||||
- Advertisements (targetted or otherwise)
|
||||
- Proprietary Code
|
||||
- 'Analytics/Telemtry' spyware
|
||||
- The use of video sources which require proprietary 'Digital ~~Rights Management~~ Ristricitons Malware' such as Widevine.
|
||||
|
||||
Thirdparty media providers may or may not contain all of the above atrocities :P, always use an ad-blocker!
|
||||
|
||||
Our current goal is to create a cleaner, more modern, purpose-built codebase that has feature-parity with the current version of fore.st, while writing improvements where possible. Once this is accomplished, and ourfore.st has been migrated, work will continue to re-create features from TTN, while also building completely new ones as well.
|
||||
|
||||
## License
|
||||
Canopy
|
||||
======
|
||||
|
||||
<img src="https://img.shields.io/badge/developed-while%20high-339933">
|
||||
<img src="https://pride-badges.pony.workers.dev/static/v1?label=enbyware&labelColor=%23555&stripeWidth=8&stripeColors=FCF434%2CFFFFFF%2C9C59D1%2C2C2C2C">
|
||||
<img src="https://pride-badges.pony.workers.dev/static/v1?label=trans%20rights&stripeWidth=6&stripeColors=5BCEFA,F5A9B8,FFFFFF,F5A9B8,5BCEFA">
|
||||
<img src="https://pride-badges.pony.workers.dev/static/v1?label=Sponsored+by+the+Gay+Agenda&labelColor=%23555&stripeWidth=8&stripeColors=E40303%2CFF8C00%2CFFED00%2C008026%2C24408E%2C732982">
|
||||
<a href="https://git.ourfore.st/rainbownapkin/canopy/issues" target="_blank"><img src="https://git.ourfore.st/rainbownapkin/canopy/badges/issues/open.svg"></a>
|
||||
<a href="https://git.ourfore.st/rainbownapkin/canopy/issues" target="_blank"><img src="https://git.ourfore.st/rainbownapkin/canopy/badges/issues/closed.svg"></a>
|
||||
<a href="https://www.gnu.org/licenses/agpl-3.0.en.html" target="_blank"><img src="https://img.shields.io/badge/License-AGPL_v3-663366.svg"></a>
|
||||
|
||||
0.1-Alpha
|
||||
=========
|
||||
|
||||
Canopy - /ˈkæ.nə.pi/:
|
||||
- The upper layer of foliage and branches of a forest, containing the majority of animal life.
|
||||
- An honest attempt at a freedom/privacy respecting, libre, and open-source refrence implementation of what a stoner streaming service can be.
|
||||
|
||||
Canopy is a community chat & synced video embedding web application, intended to replace fore.st as the server software for ourfore.st.
|
||||
This new codebase intends to solve the following issues with the current CyTube based software:
|
||||
|
||||
- Unmaintained upstream codebase.
|
||||
- Different goals.
|
||||
- Different coding styles.
|
||||
- Obsolete Technology and Dependencies.
|
||||
- General Clunk
|
||||
- Less Unique Community Identity
|
||||
|
||||
Canopy is a simple node/express.js app, leveraging yt-dlp and the internet archive REST api for metadata gathering. Persistant storage is handled by mongodb, as it's document based nature inherintly works well for cleanly storing large config documents for user/channel settings, and the low use of inter-collection references within the canopy software. All hardcore security functions like server-side input sanatization, session handling, CSRF mitigation, and password hashing are handled by industry-standard open source libraries such as validator/express-validator, express-sessions, csrf-sync, and argon2, however it IS hobbiest software, and it should be treated as such.
|
||||
|
||||
The Canopy codebase does not, nor will it ever contain:
|
||||
- Advertisements (targetted or otherwise)
|
||||
- Proprietary Code
|
||||
- Cryptocurrency/Blockchain integration
|
||||
- 'Analytics/Telemtry' spyware
|
||||
- The use of video sources which require proprietary 'Digital ~~Rights Management~~ Ristricitons Malware' such as Widevine.
|
||||
- The use of large language models, stable diffusion, or generative AI in either development or function.
|
||||
|
||||
Thirdparty media providers may or may not contain all of the above atrocities :P (though browser-side DRM extensions will never be required), always use an ad-blocker!
|
||||
|
||||
Our current goal is to create a cleaner, more modern, purpose-built back-end that has feature-parity with the current version of fore.st, writing improvements where possible. Paired with this functionality, are a mix of engineering and artistic choices which attempt to re-create the power-user friendly UX of desktop sites from the early 2010's, with the 'aged like wine' looks that late oughts/early web 2.0 designs graced us with. Making sure that pageloads are low, and GPU use is non-existant along the way, to ensure everything is usable, even on low-end machines.
|
||||
|
||||
## License
|
||||
Canopy is written by the community, and provided under the GNU Affero General Public License v3 in order to prevent Canopy from being used in proprietary software or shitcoin scams.
|
||||
|
|
@ -1,11 +1,25 @@
|
|||
{
|
||||
"instanceName": "Canopy",
|
||||
"verbose": false,
|
||||
"port": 8080,
|
||||
"protocol": "http",
|
||||
"port": 8443,
|
||||
"proxied": true,
|
||||
"protocol": "https",
|
||||
"domain": "localhost",
|
||||
"sessionSecret": "CHANGE_ME",
|
||||
"altchaSecret": "CHANGE_ME",
|
||||
"ytdlpPath": "/home/canopy/.local/pipx/venvs/yt-dlp/bin/yt-dlp",
|
||||
"migrate": false,
|
||||
"dropLegacyTokes": false,
|
||||
"debug": false,
|
||||
"secrets":{
|
||||
"passwordSecret": "CHANGE_ME",
|
||||
"rememberMeSecret": "CHANGE_ME",
|
||||
"sessionSecret": "CHANGE_ME",
|
||||
"altchaSecret": "CHANGE_ME",
|
||||
"ipSecret": "CHANGE_ME"
|
||||
},
|
||||
"ssl":{
|
||||
"cert": "./server.cert",
|
||||
"key": "./server.key"
|
||||
},
|
||||
"db":{
|
||||
"address": "127.0.0.1",
|
||||
"port": "27017",
|
||||
|
|
@ -19,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."
|
||||
}
|
||||
70
config.example.jsonc
Normal file
70
config.example.jsonc
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
//WARNING DO NOT USE THIS CONFIG, USE THE REGULAR EXAMPLE, CANOPY DOES NOT SUPPORT JSONC CONFIGS!
|
||||
//This file merely exists to help you configure your actual config file
|
||||
{
|
||||
//Display name on the nav bar and a coupla other places
|
||||
"instanceName": "Canopy",
|
||||
//Scream about exceptions in the console
|
||||
"verbose": false,
|
||||
//Port to bind to (most linux/unix systems req root for ports below 1000, you should probably use nginx if you want port 80 or 443)
|
||||
"port": 8443,
|
||||
//Lets the server know it's sitting behind a reverse-proxy
|
||||
"proxied": true,
|
||||
//Protocol (either HTTP or HTTPS)
|
||||
"protocol": "http",
|
||||
//Domain the server is available at, used for server-side link generation
|
||||
"domain": "localhost",
|
||||
//Path to YT-DLP Executable for scraping youtube, dailymotion, and vimeo
|
||||
//Dailymotion and Vimeo could work using official apis w/o keys, but you wouldn't have any raw file playback options :P
|
||||
"ytdlpPath": "/home/canopy/.local/pipx/venvs/yt-dlp/bin/yt-dlp",
|
||||
//Enable to migrate legacy DB and toke files dumped into the ./migration/ directory
|
||||
//WARNING: The migration folder is cleared after server boot, whether or not a migration took place or this option is enabled.
|
||||
//Keep your backups in a safe place, preferably a machine that DOESN'T have open inbound ports exposed to the internet/a publically accessible reverse proxy!
|
||||
"migrate": false,
|
||||
//Drops all legacy tokes out of the statistics file since doing so manually from mongosh is a lot of typing out obnoxious parentha-glyphics.
|
||||
//Requires migration to be disabled before it takes effect.
|
||||
//WARNING: this does NOT affect user toke counts, migrated or otherwise. Use carefully!
|
||||
"dropLegacyTokes": false,
|
||||
//Enters the server into debug mode, allows specific commands to be emitted from the client-side dev console
|
||||
//Usually to get the server to dump some sort of internal data for debugging purposes.
|
||||
//Obviously, this means enabling this can have some gnar implications.
|
||||
//Probably don't enable this on your production server unless you REALLY REALLY have to, and you probably don't.
|
||||
"debug": false,
|
||||
//Server Secrets
|
||||
//Be careful with what you keep in secrets, you should use special chars, but test your deployment, as some chars may break account registration
|
||||
//An update to either kill the server and bitch about the issue in console is planned so it's not so confusing for new admins
|
||||
"secrets":{
|
||||
//Password secret used to pepper password hashes
|
||||
"passwordSecret": "CHANGE_ME",
|
||||
//Password secret used to pepper rememberMe token hashes
|
||||
"rememberMeSecret": "CHANGE_ME",
|
||||
//Session secret used to secure session keys
|
||||
"sessionSecret": "CHANGE_ME",
|
||||
//Altacha secret used to generate altcha challenges
|
||||
"altchaSecret": "CHANGE_ME",
|
||||
//IP Secret used to pepper IP Hashes
|
||||
"ipSecret": "CHANGE_ME"
|
||||
},
|
||||
//SSL cert and key locations
|
||||
"ssl":{
|
||||
"cert": "./server.cert",
|
||||
"key": "./server.key"
|
||||
},
|
||||
//DB configuration
|
||||
"db":{
|
||||
"address": "127.0.0.1",
|
||||
"port": "27017",
|
||||
"database": "canopy",
|
||||
"user": "canopy",
|
||||
"pass": "CHANGE_ME"
|
||||
},
|
||||
//maintainence email configuration
|
||||
"mail":{
|
||||
"host": "mail.42069.weed",
|
||||
"port": 465,
|
||||
"secure": true,
|
||||
"address": "toke@42069.weed",
|
||||
"pass": "CHANGE_ME"
|
||||
},
|
||||
//Fills the 'about ${instanceName}' section on the /about page, lets users know about your specific instance
|
||||
"aboutText":"<a href=\"https://ourfore.st/\">ourfore.st</a> is the one and only original canopy instance. Setup, ran, and administered by rainbownapkin herself. This site exists to provide a featureful, preformant, and comfy replacement for the TTN community."
|
||||
}
|
||||
|
|
@ -252,7 +252,6 @@
|
|||
"hooray",
|
||||
"flambe",
|
||||
"flambé",
|
||||
"tenturnyourrainsoundsoff",
|
||||
"jabroni",
|
||||
"lame",
|
||||
"yoke",
|
||||
|
|
|
|||
21
package.json
21
package.json
|
|
@ -1,25 +1,36 @@
|
|||
{
|
||||
"name": "canopy-of",
|
||||
"version": "0.2",
|
||||
"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",
|
||||
"express-session": "^1.18.0",
|
||||
"express-validator": "^7.2.0",
|
||||
"hls.js": "^1.6.2",
|
||||
"mongoose": "^8.4.3",
|
||||
"node-cron": "^3.0.3",
|
||||
"nodemailer": "^6.9.16",
|
||||
"socket.io": "^4.8.1"
|
||||
"nodemailer": "^7.0.9",
|
||||
"socket.io": "^4.8.1",
|
||||
"youtube-dl-exec": "^3.0.20"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node ./src/server.js",
|
||||
"start:dev": "nodemon ./src/server.js"
|
||||
"start:dev": "nodemon ./src/server.js",
|
||||
"build": "node node_modules/jsdoc/jsdoc.js --verbose -r src/ -R README.md -d www/doc/server/ && node node_modules/jsdoc/jsdoc.js --verbose -r www/js/channel -r README.md -d www/doc/client/"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jsdoc": "^4.0.4",
|
||||
"nodemon": "^3.1.10"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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;
|
||||
|
|
@ -16,21 +16,66 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
|
|||
|
||||
//local imports
|
||||
const connectedUser = require('./connectedUser');
|
||||
const chatBuffer = require('./chatBuffer');
|
||||
const queue = require('./media/queue');
|
||||
const channelModel = require('../../schemas/channel/channelSchema');
|
||||
const playlistHandler = require('./media/playlistHandler')
|
||||
|
||||
module.exports = class{
|
||||
/**
|
||||
* Class representing a single active channel
|
||||
*/
|
||||
class activeChannel{
|
||||
|
||||
/**
|
||||
* Instantiates an activeChannel object
|
||||
* @param {channelManager} server - Parent Server Object
|
||||
* @param {Mongoose.Document} chanDB - chanDB to rehydrate buffer from
|
||||
*/
|
||||
constructor(server, chanDB){
|
||||
/**
|
||||
* Parent Server Object
|
||||
*/
|
||||
this.server = server;
|
||||
|
||||
/**
|
||||
* Current Channel Name
|
||||
*/
|
||||
this.name = chanDB.name;
|
||||
|
||||
/**
|
||||
* List of channel-wide toke commands
|
||||
*/
|
||||
this.tokeCommands = chanDB.tokeCommands;
|
||||
//Keeping these in a map was originally a vestige but it's more preformant than an array or object so :P
|
||||
|
||||
/**
|
||||
* List of connected users
|
||||
*/
|
||||
this.userList = new Map();
|
||||
|
||||
/**
|
||||
* Child Queue Object
|
||||
*/
|
||||
this.queue = new queue(server, chanDB, this);
|
||||
this.playlistHandler = new playlistHandler(server, chanDB, this);
|
||||
|
||||
/**
|
||||
* Child Playlist Handler Object
|
||||
*/
|
||||
this.playlistHandler = new playlistHandler(server, this);
|
||||
|
||||
/**
|
||||
* Child Chat Buffer Object
|
||||
*/
|
||||
this.chatBuffer = new chatBuffer(server, chanDB, this);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handles server-side initialization for new connections to the channel
|
||||
* @param {Mongoose.Document} userDB - User Document Passthrough to save on DB Access
|
||||
* @param {Mongoose.Document} chanDB - Channnel Document Passthrough to save on DB Access
|
||||
* @param {Socket} socket - Requesting Socket
|
||||
* @returns {activeUser} active user object generated by the new connection
|
||||
*/
|
||||
async handleConnection(userDB, chanDB, socket){
|
||||
//get current user object from the userlist
|
||||
var userObj = this.userList.get(userDB.user);
|
||||
|
|
@ -60,18 +105,25 @@ module.exports = class{
|
|||
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;
|
||||
}
|
||||
|
||||
handleDisconnect(socket, reason){
|
||||
//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);
|
||||
/**
|
||||
* Handles server-side initialization for disconnecting from the channel
|
||||
* @param {Socket} socket - Requesting Socket
|
||||
*/
|
||||
handleDisconnect(socket){
|
||||
//temporarily store userObj
|
||||
let userObj = this.userList.get(socket.user.user);
|
||||
|
||||
//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;
|
||||
|
|
@ -79,7 +131,11 @@ module.exports = class{
|
|||
|
||||
//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);
|
||||
}
|
||||
|
|
@ -88,6 +144,9 @@ module.exports = class{
|
|||
this.broadcastUserList(socket.chan);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts user list to all users
|
||||
*/
|
||||
broadcastUserList(){
|
||||
//Create a userlist object with the tokebot user pre-loaded
|
||||
var userList = [{
|
||||
|
|
@ -107,6 +166,10 @@ module.exports = class{
|
|||
this.server.io.in(this.name).emit("userList", userList);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts channel emote list to connected users
|
||||
* @param {Mongoose.Document} chanDB - Channnel Document Passthrough to save on DB Access
|
||||
*/
|
||||
async broadcastChanEmotes(chanDB){
|
||||
//if we wherent handed a channel document
|
||||
if(chanDB == null){
|
||||
|
|
@ -120,4 +183,6 @@ module.exports = class{
|
|||
//Broadcast that sumbitch
|
||||
this.server.io.in(this.name).emit('chanEmotes', emoteList);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = activeChannel;
|
||||
|
|
@ -14,37 +14,70 @@ GNU Affero General Public License for more details.
|
|||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.*/
|
||||
|
||||
//Config
|
||||
const config = require('../../../config.json');
|
||||
|
||||
//Local Imports
|
||||
const channelModel = require('../../schemas/channel/channelSchema');
|
||||
const emoteModel = require('../../schemas/emoteSchema');
|
||||
const {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 mediaYanker = require('./media/yanker');
|
||||
const queueBroadcastManager = require('./media/queueBroadcastManager');
|
||||
|
||||
module.exports = class{
|
||||
/**
|
||||
* Class containing global server-side channel connection management logic
|
||||
*/
|
||||
class channelManager{
|
||||
/**
|
||||
* Instantiates object containing global server-side channel conection management logic
|
||||
* @param {Socket.io} io - Socket.io server instanced passed down from server.js
|
||||
*/
|
||||
constructor(io){
|
||||
//Set the socket.io server
|
||||
/**
|
||||
* Socket.io server instance passed down from server.js
|
||||
*/
|
||||
this.io = io;
|
||||
|
||||
//Load
|
||||
/**
|
||||
* Map containing all active channels running on the server
|
||||
*/
|
||||
this.activeChannels = new Map;
|
||||
|
||||
//Load server components
|
||||
/**
|
||||
* 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);
|
||||
//this.mediaYanker = new mediaYanker(this);
|
||||
|
||||
/**
|
||||
* Global Chat Handler Object
|
||||
*/
|
||||
this.chatHandler = new chatHandler(this);
|
||||
|
||||
/**
|
||||
* Global Auxiliary Server for Authenticated Queue Metadata Brodcasting
|
||||
*/
|
||||
this.queueBroadcastManager = new queueBroadcastManager(this.io, this)
|
||||
|
||||
//Handle connections from socket.io
|
||||
io.on("connection", this.handleConnection.bind(this) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles global server-side initialization for new connections to any channel
|
||||
* @param {Socket} socket - Requesting Socket
|
||||
*/
|
||||
async handleConnection(socket){
|
||||
try{
|
||||
//ensure unbanned ip and valid CSRF token
|
||||
if(!(await this.validateSocket(socket))){
|
||||
if(!(await socketUtils.validateSocket(socket))){
|
||||
socket.disconnect();
|
||||
return;
|
||||
}
|
||||
|
|
@ -52,7 +85,7 @@ module.exports = class{
|
|||
//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);
|
||||
|
|
@ -70,13 +103,48 @@ module.exports = class{
|
|||
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!"});
|
||||
|
|
@ -87,54 +155,20 @@ module.exports = class{
|
|||
//Flip a table if something fucks up
|
||||
return loggerUtils.socketCriticalExceptionHandler(socket, err);
|
||||
}
|
||||
}
|
||||
|
||||
async validateSocket(socket){
|
||||
//Look for ban by IP
|
||||
const ipBanDB = await userBanModel.checkBanByIP(socket.handshake.address);
|
||||
|
||||
//If this ip is randy bobandy
|
||||
if(ipBanDB != null){
|
||||
//tell it to fuck off
|
||||
socket.emit("kick", {type: "kicked", reason: "The IP address you are trying to connect from has been banned!"});
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
//Check for Cross-Site Request Forgery
|
||||
if(!csrfUtils.isRequestValid(socket.request)){
|
||||
socket.emit("kick", {type: "disconnected", reason: "Invalid CSRF Token!"});
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async authSocket(socket){
|
||||
//Find the user in the Database since the session won't store enough data to fulfill our needs :P
|
||||
const userDB = await userModel.findOne({user: socket.request.session.user.user});
|
||||
|
||||
if(userDB == null){
|
||||
throw new Error("User not found!");
|
||||
}
|
||||
|
||||
//Set socket user and channel values
|
||||
socket.user = {
|
||||
id: userDB.id,
|
||||
user: userDB.user,
|
||||
};
|
||||
|
||||
return userDB;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets active channel from a given socket
|
||||
* @param {Socket} socket - Socket to check
|
||||
* @returns {Object} Object containing users active channel name and channel document object
|
||||
*/
|
||||
async getActiveChan(socket){
|
||||
socket.chan = socket.handshake.headers.referer.split('/c/')[1].split('/')[0];
|
||||
const chanDB = (await channelModel.findOne({name: socket.chan}))
|
||||
socket.chan = socketUtils.getChannelName(socket);
|
||||
const chanDB = (await channelModel.findOne({name: socket.chan}));
|
||||
|
||||
//Check if channel exists
|
||||
if(chanDB == null){
|
||||
throw new Error("Channel not found!");
|
||||
throw loggerUtils.exceptionSmith("Channel not found", "validation");
|
||||
}
|
||||
|
||||
//Check if current channel is active
|
||||
|
|
@ -148,24 +182,73 @@ module.exports = class{
|
|||
|
||||
//Return whatever the active channel is (new or old)
|
||||
return {activeChan, chanDB};
|
||||
//return activeChan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Define Global Server-Side socket event listeners
|
||||
* @param {Socket} socket - Socket to check
|
||||
*/
|
||||
defineListeners(socket){
|
||||
//Socket Listeners
|
||||
socket.conn.on("close", (reason) => {this.handleDisconnect(socket, reason)});
|
||||
}
|
||||
|
||||
/**
|
||||
* Global server-side logic for handling disconncted sockets
|
||||
* @param {Socket} socket - Socket to check
|
||||
* @param {String} reason - Reason for disconnection
|
||||
*/
|
||||
handleDisconnect(socket, reason){
|
||||
var activeChan = this.activeChannels.get(socket.chan);
|
||||
activeChan.handleDisconnect(socket, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a disconnection event for a single active user within a given channel (when all sockets disconnect)
|
||||
* @param {connectedUser} userObj - Connected user object to handle disconnection of
|
||||
*/
|
||||
handleUserDisconnect(userObj){
|
||||
//Create array to hold
|
||||
let stillConnected = [];
|
||||
|
||||
//Crawl through all known user connections
|
||||
this.crawlConnections(userObj.user, (curUser)=>{
|
||||
//If we have a matching username from a different channel
|
||||
if(curUser.user == userObj.user && userObj.channel.name != curUser.channel.name){
|
||||
//Keep current user
|
||||
stillConnected.push(curUser);
|
||||
}
|
||||
});
|
||||
|
||||
//If we have anyone left
|
||||
if(stillConnected.length > 0){
|
||||
//save the remainder to the status map, otherwise unset the value.
|
||||
this.activeUsers.set(userObj.user, stillConnected);
|
||||
//Otherwise
|
||||
}else{
|
||||
//Delete the user from the status map
|
||||
this.activeUsers.delete(userObj.user);
|
||||
|
||||
//Mark last disconnection as user activity, as they'll no longer be marked as streaming.
|
||||
presenceUtils.handlePresence(userObj.user);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls user information by socket
|
||||
* @param {Socket} socket - Socket to check
|
||||
* @return returns related user info
|
||||
*/
|
||||
getSocketInfo(socket){
|
||||
const channel = this.activeChannels.get(socket.chan);
|
||||
return channel.userList.get(socket.user.user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls user information by socket
|
||||
* @param {Socket} socket - Socket to check
|
||||
* @return returns related user info
|
||||
*/
|
||||
getConnectedChannels(socket){
|
||||
//Create a list to hold connected channels
|
||||
var chanList = [];
|
||||
|
|
@ -185,36 +268,52 @@ module.exports = class{
|
|||
return chanList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterates through connections by a given username, and runs them through a given callback function/method
|
||||
* @param {String} user - Username to crawl connections against
|
||||
* @param {Function} cb - Callback function to run active connections of a given user against
|
||||
*/
|
||||
crawlConnections(user, cb){
|
||||
//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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kicks a user from all channels by username
|
||||
* @param {String} user - Username to kick from the server
|
||||
* @param {String} reason - Reason for kick
|
||||
*/
|
||||
kickConnections(user, reason){
|
||||
//crawl through connections and kick user
|
||||
this.crawlConnections(user,(foundUser)=>{foundUser.disconnect(reason)});
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast global emote list
|
||||
*/
|
||||
async broadcastSiteEmotes(){
|
||||
//Get emote list from DB
|
||||
const emoteList = await emoteModel.getEmotes();
|
||||
|
|
@ -222,4 +321,6 @@ module.exports = class{
|
|||
//Broadcast that sumbitch
|
||||
this.io.sockets.emit('siteEmotes', emoteList);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = channelManager;
|
||||
34
src/app/channel/chat.js
Normal file
34
src/app/channel/chat.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/>.*/
|
||||
|
||||
//local imports
|
||||
const chatMetadata = require("../chatMetadata");
|
||||
|
||||
/**
|
||||
* Class representing a single chat message
|
||||
*/
|
||||
class chat extends chatMetadata{
|
||||
/**
|
||||
* Instantiates a chat message object
|
||||
* @param {connectedUser} user - User who sent the message
|
||||
*/
|
||||
constructor(user, flair, highLevel, msg, type, links){
|
||||
//Call derived constructor
|
||||
super(user, flair, highLevel, msg, type, links);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = chat;
|
||||
198
src/app/channel/chatBuffer.js
Normal file
198
src/app/channel/chatBuffer.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/>.*/
|
||||
const config = require('../../../config.json');
|
||||
const channelModel = require('../../schemas/channel/channelSchema');
|
||||
|
||||
/**
|
||||
* Class representing a stored chat buffer
|
||||
*/
|
||||
class chatBuffer{
|
||||
/**
|
||||
* Instantiates a new chat buffer for a given channel
|
||||
* @param {channelManager} server - Parent Server Object
|
||||
* @param {Mongoose.Document} chanDB - chanDB to rehydrate buffer from
|
||||
* @param {activeChannel} channel - Parent Channel Object
|
||||
*/
|
||||
constructor(server, chanDB, channel){
|
||||
/**
|
||||
* Parent Server Object
|
||||
*/
|
||||
this.server = server;
|
||||
|
||||
/**
|
||||
* Parent CHannel Object
|
||||
*/
|
||||
this.channel = channel;
|
||||
|
||||
//If we have no chanDB.chatBuffer
|
||||
if(chanDB == null || chanDB.chatBuffer == null){
|
||||
/**
|
||||
* RAM-Based buffer containing array of previous chats
|
||||
*/
|
||||
this.buffer = [];
|
||||
//Otherwise
|
||||
}else{
|
||||
/**
|
||||
* RAM-Based buffer containing array of previous chats
|
||||
*/
|
||||
this.buffer = chanDB.chatBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inactivity Timer, goes off after x seconds of chat inactivity
|
||||
*/
|
||||
this.inactivityTimer = null;
|
||||
|
||||
/**
|
||||
* Inactivity Timer Delay
|
||||
*/
|
||||
this.inactivityDelay = 10;
|
||||
|
||||
/**
|
||||
* Goes off after x minutes of solid chatroom activity (no inactivityTimer call in x minutes)
|
||||
*/
|
||||
this.busyTimer = null;
|
||||
|
||||
/**
|
||||
* Busy Timer Delay
|
||||
*/
|
||||
this.busyDelay = 5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a given chat to the chat buffer in RAM and sets any appropriate timers for DB transactions
|
||||
* @param {chat} chat - Chat object to commit to buffer
|
||||
*/
|
||||
push(chat){
|
||||
//push chat into RAM buffer
|
||||
this.buffer.push(chat);
|
||||
|
||||
//clear existing inactivity timer
|
||||
clearTimeout(this.inactivityTimer);
|
||||
|
||||
//reset inactivity timer
|
||||
this.inactivityTimer = setTimeout(this.handleInactivity.bind(this), 1000 * this.inactivityDelay);
|
||||
|
||||
//If busy timer is unset
|
||||
if(this.busyTimer == null){
|
||||
this.busyTimer = setTimeout(this.handleBusyRoom.bind(this), 1000 * 60 * this.busyDelay);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the oldest item from the chat buffer
|
||||
*
|
||||
* Was originally created in-case we needed to trigger timing functions
|
||||
*
|
||||
* Left here since it seems like good form anywho, since this would be a private, or at least protected member in another language
|
||||
*/
|
||||
shift(){
|
||||
this.buffer.shift();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after 10 seconds of chat room inactivity
|
||||
*/
|
||||
handleInactivity(){
|
||||
this.saveDB(`${this.inactivityDelay} seconds of inactivity.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after 5 minutes of solid activity
|
||||
*/
|
||||
handleBusyRoom(){
|
||||
this.saveDB(`${this.busyDelay} minutes of activity.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears out buffer timers to prevent saving
|
||||
*/
|
||||
clearTimers(){
|
||||
//clear existing timers
|
||||
clearTimeout(this.inactivityTimer);
|
||||
clearTimeout(this.busyTimer);
|
||||
this.inactivityTimer = null;
|
||||
this.busyTimer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears RAM-Based chat buffer and saves the result to DB
|
||||
* @param {String} name - Name of user to clear chats from. Left as null or an empty string, it will clear the entire buffer.
|
||||
*/
|
||||
async clearBuffer(name){
|
||||
//Clear out DB Timers
|
||||
this.clearTimers();
|
||||
|
||||
let reason = "clearing chat";
|
||||
|
||||
//If we have a null or empty string passed as name
|
||||
if(name == null || name == ""){
|
||||
//Nuke that fcker
|
||||
this.buffer = [];
|
||||
//Otherwise
|
||||
}else{
|
||||
reason = `clearing ${name}'s chats`
|
||||
|
||||
//Iterate through chat buffer by index
|
||||
for(let chatIndex in this.buffer){
|
||||
//If the current chat we're looking at was submitted by the given user
|
||||
if(this.buffer[chatIndex].user.toLowerCase() == name.toLowerCase()){
|
||||
//Splice that fcker out
|
||||
this.buffer.splice(chatIndex, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.saveDB(reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves RAM-Based buffer to Channel Document in DB
|
||||
* @param {String} reason - Reason for DB save, formatted as 'x minutes/seconds of in/activity', used for logging purposes
|
||||
* @param {Mongoose.Document} chanDB - Channel Doc to work with, can be left empty for method to auto-find through channel name.
|
||||
*/
|
||||
async saveDB(reason, chanDB){
|
||||
//Clear out DB Timers
|
||||
this.clearTimers();
|
||||
|
||||
//if the server is in screamy boi mode
|
||||
if(config.verbose){
|
||||
//This should eventually be replaced by a per-channel logging feature that provides access to chan admins via web front-end
|
||||
console.log(`Saving chat buffer to channel ${this.channel.name} after ${reason}.`);
|
||||
}
|
||||
|
||||
|
||||
//If we wheren't handed a channel
|
||||
if(chanDB == null){
|
||||
//Now that everything is clean, we can take our time with the DB :P
|
||||
chanDB = await channelModel.findOne({name:this.channel.name});
|
||||
}
|
||||
|
||||
//If we couldn't find the channel
|
||||
if(chanDB == null){
|
||||
//FUCK
|
||||
throw loggerUtils.exceptionSmith(`Unable to find channel document ${this.channel.name} while saving chat buffer!`, "chat");
|
||||
}
|
||||
|
||||
//Set chan doc buffer to RAM buffer
|
||||
chanDB.chatBuffer = this.buffer;
|
||||
|
||||
//save chan doc to DB.
|
||||
await chanDB.save();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = chatBuffer;
|
||||
|
|
@ -14,19 +14,51 @@ GNU Affero General Public License for more details.
|
|||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.*/
|
||||
|
||||
//NPM imports
|
||||
const validator = require('validator')
|
||||
|
||||
//local imports
|
||||
const 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');
|
||||
const chat = require('./chat');
|
||||
const {userModel} = require('../../schemas/user/userSchema');
|
||||
|
||||
module.exports = class{
|
||||
/**
|
||||
* Class containing global server-side chat relay logic
|
||||
*/
|
||||
class chatHandler{
|
||||
/**
|
||||
* Instantiates a chatHandler object
|
||||
* @param {channelManager} server - Parent Server Object
|
||||
*/
|
||||
constructor(server){
|
||||
/**
|
||||
* Parent Server Object
|
||||
*/
|
||||
this.server = server;
|
||||
this.commandPreprocessor = new commandPreprocessor(server, this)
|
||||
|
||||
/**
|
||||
* Child Command Pre-Processor Object
|
||||
*/
|
||||
this.chatPreprocessor = new chatPreprocessor(
|
||||
new commandProcessor(server, this),
|
||||
new tokebot(server, this)
|
||||
);
|
||||
|
||||
/**
|
||||
* Max chat buffer message count
|
||||
*/
|
||||
this.chatBufferSize = 50;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines global server-side chat relay event listeners
|
||||
* @param {Socket} socket - Requesting Socket
|
||||
*/
|
||||
defineListeners(socket){
|
||||
socket.on("chatMessage", (data) => {this.handleChat(socket, data)});
|
||||
socket.on("setFlair", (data) => {this.setFlair(socket, data)});
|
||||
|
|
@ -35,10 +67,33 @@ module.exports = class{
|
|||
socket.on("deletePersonalEmote", (data) => {this.deletePersonalEmote(socket, data)});
|
||||
}
|
||||
|
||||
handleChat(socket, data){
|
||||
this.commandPreprocessor.preprocess(socket, data);
|
||||
/**
|
||||
* Handles incoming chat messages from client connections
|
||||
* @param {Socket} socket - Socket we're receiving the request from
|
||||
* @param {Object} data - Event payload
|
||||
*/
|
||||
async handleChat(socket, data){
|
||||
try{
|
||||
//Preprocess chat data
|
||||
const preprocessedChat = await this.chatPreprocessor.preprocess(socket, data);
|
||||
|
||||
//If send flag wasn't set to false
|
||||
if(preprocessedChat != false){
|
||||
//Send that shit!
|
||||
this.relayUserChat(socket, preprocessedChat.message, preprocessedChat.chatType, preprocessedChat.links);
|
||||
}
|
||||
//If something fucked up
|
||||
}catch(err){
|
||||
//Bitch and moan
|
||||
return loggerUtils.socketExceptionHandler(socket, err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming client request to change flair
|
||||
* @param {Socket} socket - Socket we're receiving the request from
|
||||
* @param {Object} data - Event payload
|
||||
*/
|
||||
async setFlair(socket, data){
|
||||
var userDB = await userModel.findOne({user: socket.user.user});
|
||||
|
||||
|
|
@ -58,13 +113,18 @@ module.exports = class{
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming client request to change high level
|
||||
* @param {Socket} socket - Socket we're receiving the request from
|
||||
* @param {Object} data - Event payload
|
||||
*/
|
||||
async setHighLevel(socket, data){
|
||||
var userDB = await userModel.findOne({user: socket.user.user});
|
||||
|
||||
if(userDB){
|
||||
try{
|
||||
//Set high level
|
||||
userDB.highLevel = data.highLevel;
|
||||
//Floor input to an integer and set high level
|
||||
userDB.highLevel = Math.floor(data.highLevel);
|
||||
//Save user DB Document
|
||||
await userDB.save();
|
||||
|
||||
|
|
@ -81,6 +141,11 @@ module.exports = class{
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming client request to add a personal emote
|
||||
* @param {Socket} socket - Socket we're receiving the request from
|
||||
* @param {Object} data - Event payload
|
||||
*/
|
||||
async addPersonalEmote(socket, data){
|
||||
//Sanatize and Validate input
|
||||
const name = emoteValidator.manualName(data.name);
|
||||
|
|
@ -111,6 +176,11 @@ module.exports = class{
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming client request to delete a personal emote
|
||||
* @param {Socket} socket - Socket we're receiving the request from
|
||||
* @param {Object} data - Event payload
|
||||
*/
|
||||
async deletePersonalEmote(socket, data){
|
||||
//Get user doc from DB based on socket
|
||||
const userDB = await userModel.findOne({user: socket.user.user});
|
||||
|
|
@ -121,39 +191,143 @@ module.exports = class{
|
|||
}
|
||||
}
|
||||
|
||||
//Base chat functions
|
||||
/**
|
||||
* Creates a new chatObject and relays the resulting message to the given channel
|
||||
* @param {String} user - Originating user
|
||||
* @param {String} flair - Flair ID to mark chat with
|
||||
* @param {Number} highLevel - High Level to mark chat with
|
||||
* @param {String} msg - Message Text Content
|
||||
* @param {String} type - Message Type, used for client-side chat post-processing.
|
||||
* @param {String} chan - Channel to broadcast message within
|
||||
* @param {Array} links - Array of URLs/Links to hand to the client-side chat post-processor to inject into the final message.
|
||||
*/
|
||||
relayChat(user, flair, highLevel, msg, type = 'chat', chan, links){
|
||||
this.relayChatObject(chan, new chat(user, flair, highLevel, msg, type, links));
|
||||
}
|
||||
|
||||
/**
|
||||
* Relays an existing chat object to a channel
|
||||
* @param {String} chan - Channel to broadcast message within
|
||||
* @param {chat} chat - Chat Object representing the message to broadcast to the given channel
|
||||
*/
|
||||
relayChatObject(chan, chat){
|
||||
//Send out chat
|
||||
this.server.io.in(chan).emit("chatMessage", chat);
|
||||
|
||||
const channel = this.server.activeChannels.get(chan);
|
||||
|
||||
//If chat buffer length is over mandated size
|
||||
if(channel.chatBuffer.buffer.length >= this.chatBufferSize){
|
||||
//Take out oldest chat
|
||||
channel.chatBuffer.shift();
|
||||
}
|
||||
|
||||
//Add buffer to chat
|
||||
channel.chatBuffer.push(chat);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new chatObject and relays the resulting message to the given socket
|
||||
* @param {Socket} socket - Socket we're sending a message to (sounds menacing, huh?)
|
||||
* @param {String} user - Originating user
|
||||
* @param {String} flair - Flair ID to mark chat with
|
||||
* @param {Number} highLevel - High Level to mark chat with
|
||||
* @param {String} msg - Message Text Content
|
||||
* @param {String} type - Message Type, used for client-side chat post-processing.
|
||||
* @param {String} chan - Channel to broadcast message within
|
||||
* @param {Array} links - Array of URLs/Links to hand to the client-side chat post-processor to inject into the final message.
|
||||
*/
|
||||
relayPrivateChat(socket, user, flair, highLevel, msg, type, links){
|
||||
this.relayPrivateChatObject(socket , new chat(user, flair, highLevel, msg, type, links));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming client request to delete a personal emote
|
||||
* @param {Socket} socket - Socket we're receiving the request from
|
||||
* @param {Object} data - Event payload
|
||||
*/
|
||||
relayPrivateChatObject(socket, chat){
|
||||
socket.emit("chatMessage", chat);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new chatObject and relays the resulting message to the entire server
|
||||
* @param {String} user - Originating user
|
||||
* @param {String} flair - Flair ID to mark chat with
|
||||
* @param {Number} highLevel - High Level to mark chat with
|
||||
* @param {String} msg - Message Text Content
|
||||
* @param {String} type - Message Type, used for client-side chat post-processing.
|
||||
* @param {Array} links - Array of URLs/Links to hand to the client-side chat post-processor to inject into the final message.
|
||||
*/
|
||||
relayGlobalChat(user, flair, highLevel, msg, type = 'chat', links){
|
||||
this.relayGlobalChatObject(new chat(user, flair, highLevel, msg, type, links));
|
||||
}
|
||||
|
||||
/**
|
||||
* Relays an existing chat object to the entire server
|
||||
* @param {chat} chat - Chat Object representing the message to broadcast throughout the server
|
||||
*/
|
||||
relayGlobalChatObject(chat){
|
||||
this.server.io.emit("chatMessage", chat);
|
||||
}
|
||||
|
||||
//User Chat Functions
|
||||
/**
|
||||
* Relays a chat message from a user to the rest of the channel based on socket
|
||||
* @param {Socket} socket - Socket we're receiving the request from
|
||||
* @param {String} msg - Message Text Content
|
||||
* @param {String} type - Message Type, used for client-side chat post-processing.
|
||||
* @param {Array} links - Array of URLs/Links to hand to the client-side chat post-processor to inject into the final message.
|
||||
*/
|
||||
relayUserChat(socket, msg, type, links){
|
||||
const user = this.server.getSocketInfo(socket);
|
||||
this.relayChat(user.user, user.flair, user.highLevel, msg, type, socket.chan, links)
|
||||
}
|
||||
|
||||
relayChat(user, flair, highLevel, msg, type = 'chat', chan, links){
|
||||
this.server.io.in(chan).emit("chatMessage", {user, flair, highLevel, msg, type, links});
|
||||
}
|
||||
|
||||
relayServerWisper(socket, user, flair, highLevel, msg, type, links){
|
||||
socket.emit("chatMessage", {user, flair, highLevel, msg, type, links});
|
||||
}
|
||||
|
||||
relayGlobalChat(user, flair, highLevel, msg, type = 'chat', links){
|
||||
this.server.io.emit("chatMessage", {user, flair, highLevel, msg, type, links});
|
||||
this.relayChat(user.user, user.flair, user.highLevel, msg, type, socket.chan, links);
|
||||
}
|
||||
|
||||
//Toke Chat Functions
|
||||
/**
|
||||
* Broadcasts toke callout to the server
|
||||
* @param {String} msg - Message Text Content
|
||||
* @param {Array} links - Array of URLs/Links to hand to the client-side chat post-processor to inject into the final message.
|
||||
*/
|
||||
relayTokeCallout(msg, links){
|
||||
this.relayGlobalChat("Tokebot", "", '∞', msg, "toke", links);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts toke callout to the server
|
||||
* @param {Socket} socket - Socket we're sending the whisper to
|
||||
* @param {String} msg - Message Text Content
|
||||
* @param {Array} links - Array of URLs/Links to hand to the client-side chat post-processor to inject into the final message.
|
||||
*/
|
||||
relayTokeWhisper(socket, msg, links){
|
||||
this.relayServerWisper(socket, "Tokebot", "", '∞', msg, "tokewhisper", links);
|
||||
this.relayPrivateChat(socket, "Tokebot", "", '∞', msg, "tokewhisper", links);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts toke whisper to the server
|
||||
* @param {String} msg - Message Text Content
|
||||
* @param {Array} links - Array of URLs/Links to hand to the client-side chat post-processor to inject into the final message.
|
||||
*/
|
||||
relayGlobalTokeWhisper(msg, links){
|
||||
this.relayGlobalChat("Tokebot", "", '∞', msg, "tokewhisper", links);
|
||||
}
|
||||
|
||||
//Announcement Functions
|
||||
/**
|
||||
* Broadcasts announcement to the server
|
||||
* @param {String} msg - Message Text Content
|
||||
* @param {Array} links - Array of URLs/Links to hand to the client-side chat post-processor to inject into the final message.
|
||||
*/
|
||||
relayServerAnnouncement(msg, links){
|
||||
this.relayGlobalChat("Server", "", '∞', msg, "announcement", links);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts announcement to a given channel
|
||||
* @param {String} msg - Message Text Content
|
||||
* @param {Array} links - Array of URLs/Links to hand to the client-side chat post-processor to inject into the final message.
|
||||
*/
|
||||
relayChannelAnnouncement(chan, msg, links){
|
||||
const activeChan = this.server.activeChannels.get(chan);
|
||||
|
||||
|
|
@ -163,6 +337,12 @@ module.exports = class{
|
|||
}
|
||||
}
|
||||
|
||||
//Misc Functions
|
||||
/**
|
||||
* Clears chat for a given channel, targets specified user or entire channel if none found/specified.
|
||||
* @param {String} user - User chats to clear
|
||||
* @param {String} chan - Channel to broadcast message within
|
||||
*/
|
||||
clearChat(chan, user){
|
||||
const activeChan = this.server.activeChannels.get(chan);
|
||||
|
||||
|
|
@ -172,8 +352,14 @@ module.exports = class{
|
|||
|
||||
//If no user was entered OR the user was found
|
||||
if(user == null || target != null){
|
||||
//Send command out to browsers to drop chats from buffer
|
||||
this.server.io.in(chan).emit("clearChat", {user});
|
||||
|
||||
//Clear serverside buffer, down to the DB
|
||||
activeChan.chatBuffer.clearBuffer(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = chatHandler;
|
||||
|
|
@ -1,315 +0,0 @@
|
|||
/*Canopy - The next generation of stoner streaming software
|
||||
Copyright (C) 2024-2025 Rainbownapkin and the TTN Community
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.*/
|
||||
|
||||
//NPM Imports
|
||||
const validator = require('validator');//No express here, so regular validator it is!
|
||||
|
||||
//Local Imports
|
||||
const tokebot = require('./tokebot');
|
||||
const linkUtils = require('../../utils/linkUtils');
|
||||
const permissionModel = require('../../schemas/permissionSchema');
|
||||
const channelModel = require('../../schemas/channel/channelSchema');
|
||||
|
||||
module.exports = class commandPreprocessor{
|
||||
constructor(server, chatHandler){
|
||||
this.server = server;
|
||||
this.chatHandler = chatHandler;
|
||||
this.commandProcessor = new commandProcessor(server, chatHandler);
|
||||
this.tokebot = new tokebot(server, chatHandler);
|
||||
}
|
||||
|
||||
async preprocess(socket, data){
|
||||
//Set 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);
|
||||
}
|
||||
}
|
||||
|
||||
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}));
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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(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);
|
||||
}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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
sendChat(commandObj){
|
||||
//FUCKIN' SEND IT!
|
||||
this.chatHandler.relayUserChat(commandObj.socket, commandObj.message, commandObj.chatType, commandObj.links);
|
||||
}
|
||||
}
|
||||
|
||||
class commandProcessor{
|
||||
constructor(server, chatHandler){
|
||||
this.server = server;
|
||||
this.chatHandler = chatHandler;
|
||||
}
|
||||
|
||||
//Command keywords get run through .toLowerCase(), so we should use lowercase method names for command methods
|
||||
whisper(preprocessor){
|
||||
//splice out our command
|
||||
preprocessor.commandArray.splice(0,2);
|
||||
|
||||
//Mark out the current message as a whisper
|
||||
preprocessor.chatType = 'whisper';
|
||||
|
||||
//Make sure to throw the send flag
|
||||
return true
|
||||
}
|
||||
|
||||
spoiler(preprocessor){
|
||||
//splice out our command
|
||||
preprocessor.commandArray.splice(0,2);
|
||||
|
||||
//Mark out the current message as a spoiler
|
||||
preprocessor.chatType = 'spoiler';
|
||||
|
||||
//Make sure to throw the send flag
|
||||
return true
|
||||
}
|
||||
|
||||
strikethrough(preprocessor){
|
||||
//splice out our command
|
||||
preprocessor.commandArray.splice(0,2);
|
||||
|
||||
//Mark out the current message as a spoiler
|
||||
preprocessor.chatType = 'strikethrough';
|
||||
|
||||
//Make sure to throw the send flag
|
||||
return true
|
||||
}
|
||||
|
||||
bold(preprocessor){
|
||||
//splice out our command
|
||||
preprocessor.commandArray.splice(0,2);
|
||||
|
||||
//Mark out the current message as a spoiler
|
||||
preprocessor.chatType = 'bold';
|
||||
|
||||
//Make sure to throw the send flag
|
||||
return true
|
||||
}
|
||||
|
||||
italics(preprocessor){
|
||||
//splice out our command
|
||||
preprocessor.commandArray.splice(0,2);
|
||||
|
||||
//Mark out the current message as a spoiler
|
||||
preprocessor.chatType = 'italics';
|
||||
|
||||
//Make sure to throw the send flag
|
||||
return true
|
||||
}
|
||||
|
||||
async announce(preprocessor){
|
||||
//Get the current channel from the database
|
||||
const chanDB = await channelModel.findOne({name: preprocessor.socket.chan});
|
||||
|
||||
//Check if the user has permission, and publicly shame them if they don't (lmao)
|
||||
if(chanDB != null && await chanDB.permCheck(preprocessor.socket.user, 'announce')){
|
||||
//splice out our command
|
||||
preprocessor.commandArray.splice(0,2);
|
||||
|
||||
//Prep the message using pre-processor functions chat-handling
|
||||
await preprocessor.prepMessage();
|
||||
|
||||
//send it
|
||||
this.chatHandler.relayChannelAnnouncement(preprocessor.socket.chan, preprocessor.message, preprocessor.links);
|
||||
|
||||
//throw send flag
|
||||
return false;
|
||||
}
|
||||
|
||||
//throw send flag
|
||||
return true;
|
||||
}
|
||||
|
||||
async serverannounce(preprocessor){
|
||||
//Check if the user has permission, and publicly shame them if they don't (lmao)
|
||||
if(await permissionModel.permCheck(preprocessor.socket.user, 'announce')){
|
||||
//splice out our command
|
||||
preprocessor.commandArray.splice(0,2);
|
||||
|
||||
//Prep the message using pre-processor functions for chat-handling
|
||||
await preprocessor.prepMessage();
|
||||
|
||||
//send it
|
||||
this.chatHandler.relayServerAnnouncement(preprocessor.message, preprocessor.links);
|
||||
|
||||
//throw send flag
|
||||
return false;
|
||||
}
|
||||
|
||||
//throw send flag
|
||||
return true;
|
||||
}
|
||||
|
||||
async clear(preprocessor){
|
||||
//Get the current channel from the database
|
||||
const chanDB = await channelModel.findOne({name: preprocessor.socket.chan});
|
||||
|
||||
//Check if the user has permission, and publicly shame them if they don't (lmao)
|
||||
if(await chanDB.permCheck(preprocessor.socket.user, 'clearChat')){
|
||||
//Send off the command
|
||||
this.chatHandler.clearChat(preprocessor.socket.chan, preprocessor.argumentArray[1]);
|
||||
//throw send flag
|
||||
return false;
|
||||
}
|
||||
|
||||
//throw send flag
|
||||
return true;
|
||||
}
|
||||
|
||||
async kick(preprocessor){
|
||||
//Get the current channel from the database
|
||||
const chanDB = await channelModel.findOne({name: preprocessor.socket.chan});
|
||||
|
||||
//Check if the user has permission, and publicly shame them if they don't (lmao)
|
||||
if(await chanDB.permCheck(preprocessor.socket.user, 'kickUser')){
|
||||
//Get username from argument array
|
||||
const username = preprocessor.argumentArray[1];
|
||||
|
||||
//Get channel
|
||||
const channel = this.server.activeChannels.get(preprocessor.socket.chan);
|
||||
|
||||
//get initiator and target user objects
|
||||
const initiator = channel.userList.get(preprocessor.socket.user.user);
|
||||
const target = channel.userList.get(username);
|
||||
|
||||
//get initiator and target override abilities
|
||||
const override = await permissionModel.overrideCheck(preprocessor.socket.user, 'kickUser');
|
||||
const targetOverride = await permissionModel.overrideCheck(target, 'kickUser');
|
||||
|
||||
//If there is no user
|
||||
if(target == null){
|
||||
//silently drop the command
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
//If the user is capable of overriding this permission based on site permissions
|
||||
if(override || targetOverride){
|
||||
//If the site rank is equal
|
||||
if(permissionModel.rankToNum(initiator.rank) == permissionModel.rankToNum(target.rank)){
|
||||
//compare chan rank
|
||||
if(permissionModel.rankToNum(initiator.chanRank) <= permissionModel.rankToNum(target.chanRank)){
|
||||
//shame the person running it
|
||||
return true;
|
||||
}
|
||||
//otherwise
|
||||
}else{
|
||||
//compare site rank
|
||||
if(permissionModel.rankToNum(initiator.rank) <= permissionModel.rankToNum(target.rank)){
|
||||
//shame the person running it
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}else{
|
||||
//If the target has a higher chan rank than the initiator
|
||||
if(permissionModel.rankToNum(initiator.chanRank) <= permissionModel.rankToNum(target.chanRank)){
|
||||
//shame the person running it
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//Splice out kick
|
||||
preprocessor.commandArray.splice(0,4)
|
||||
|
||||
//Get collect reason
|
||||
var reason = preprocessor.commandArray.join('');
|
||||
|
||||
//If no reason was given
|
||||
if(reason == ''){
|
||||
//Fill in a generic reason
|
||||
reason = "You have been kicked from the channel!";
|
||||
}
|
||||
|
||||
//Kick the user
|
||||
target.disconnect(reason);
|
||||
|
||||
//throw send flag
|
||||
return false;
|
||||
}
|
||||
|
||||
//throw send flag
|
||||
return true;
|
||||
}
|
||||
}
|
||||
294
src/app/channel/commandProcessor.js
Normal file
294
src/app/channel/commandProcessor.js
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
/*Canopy - The next generation of stoner streaming software
|
||||
Copyright (C) 2024-2025 Rainbownapkin and the TTN Community
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.*/
|
||||
|
||||
//Local Imports
|
||||
const permissionModel = require('../../schemas/permissionSchema');
|
||||
const channelModel = require('../../schemas/channel/channelSchema');
|
||||
|
||||
/**
|
||||
* Class representing global server-side chat/command processing logic
|
||||
*/
|
||||
class commandProcessor{
|
||||
/**
|
||||
* Instantiates a commandProcessor object
|
||||
* @param {channelManager} server - Parent Server Object
|
||||
* @param {chatHandler} chatHandler - Parent Chat Handler Object
|
||||
*/
|
||||
constructor(server, chatHandler){
|
||||
this.server = server;
|
||||
this.chatHandler = chatHandler;
|
||||
}
|
||||
|
||||
//Command keywords get run through .toLowerCase(), so we should use lowercase method names for command methods
|
||||
/**
|
||||
* Command Processor method to handle the '!whisper' command
|
||||
* @param {Object} commandObj - Object representing a single given command/chat request
|
||||
* @returns {Boolean} True to enable send flag
|
||||
*/
|
||||
whisper(commandObj){
|
||||
//splice out our command
|
||||
commandObj.commandArray.splice(0,2);
|
||||
|
||||
//Mark out the current message as a whisper
|
||||
commandObj.chatType = 'whisper';
|
||||
|
||||
//Make sure to throw the send flag
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Command Processor method to handle the '!spoiler' command
|
||||
* @param {Object} commandObj - Object representing a single given command/chat request
|
||||
* @returns {Boolean} True to enable send flag
|
||||
*/
|
||||
spoiler(commandObj){
|
||||
//splice out our command
|
||||
commandObj.commandArray.splice(0,2);
|
||||
|
||||
//Mark out the current message as a spoiler
|
||||
commandObj.chatType = 'spoiler';
|
||||
|
||||
//Make sure to throw the send flag
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Command Processor method to handle the '!strikethrough' command
|
||||
* @param {Object} commandObj - Object representing a single given command/chat request
|
||||
* @returns {Boolean} True to enable send flag
|
||||
*/
|
||||
strikethrough(commandObj){
|
||||
//splice out our command
|
||||
commandObj.commandArray.splice(0,2);
|
||||
|
||||
//Mark out the current message as a spoiler
|
||||
commandObj.chatType = 'strikethrough';
|
||||
|
||||
//Make sure to throw the send flag
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Command Processor method to handle the '!bold' command
|
||||
* @param {Object} commandObj - Object representing a single given command/chat request
|
||||
* @returns {Boolean} True to enable send flag
|
||||
*/
|
||||
bold(commandObj){
|
||||
//splice out our command
|
||||
commandObj.commandArray.splice(0,2);
|
||||
|
||||
//Mark out the current message as a spoiler
|
||||
commandObj.chatType = 'bold';
|
||||
|
||||
//Make sure to throw the send flag
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Command Processor method to handle the '!italics' command
|
||||
* @param {Object} commandObj - Object representing a single given command/chat request
|
||||
* @returns {Boolean} True to enable send flag
|
||||
*/
|
||||
italics(commandObj){
|
||||
//splice out our command
|
||||
commandObj.commandArray.splice(0,2);
|
||||
|
||||
//Mark out the current message as a spoiler
|
||||
commandObj.chatType = 'italics';
|
||||
|
||||
//Make sure to throw the send flag
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Command Processor method to handle the '!announce' command
|
||||
* @param {Object} commandObj - Object representing a single given command/chat request
|
||||
* @returns {Boolean} True to enable send flag on un-authorized call to shame the user
|
||||
*/
|
||||
async announce(commandObj, preprocessor){
|
||||
//Get the current channel from the database
|
||||
const chanDB = await channelModel.findOne({name: commandObj.socket.chan});
|
||||
|
||||
//Check if the user has permission, and publicly shame them if they don't (lmao)
|
||||
if(chanDB != null && await chanDB.permCheck(commandObj.socket.user, 'announce')){
|
||||
//splice out our command
|
||||
commandObj.commandArray.splice(0,2);
|
||||
|
||||
//Prep the message using pre-processor functions chat-handling
|
||||
await preprocessor.prepMessage(commandObj);
|
||||
|
||||
//send it
|
||||
this.chatHandler.relayChannelAnnouncement(commandObj.socket.chan, commandObj.message, commandObj.links);
|
||||
|
||||
//throw send flag
|
||||
return false;
|
||||
}
|
||||
|
||||
//throw send flag
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command Processor method to handle the '!serverannounce' command
|
||||
* @param {Object} commandObj - Object representing a single given command/chat request
|
||||
* @returns {Boolean} True to enable send flag on un-authorized call to shame the user
|
||||
*/
|
||||
async serverannounce(commandObj, preprocessor){
|
||||
//Check if the user has permission, and publicly shame them if they don't (lmao)
|
||||
if(await permissionModel.permCheck(commandObj.socket.user, 'announce')){
|
||||
//splice out our command
|
||||
commandObj.commandArray.splice(0,2);
|
||||
|
||||
//Prep the message using pre-processor functions for chat-handling
|
||||
await preprocessor.prepMessage(commandObj);
|
||||
|
||||
//send it
|
||||
this.chatHandler.relayServerAnnouncement(commandObj.message, commandObj.links);
|
||||
|
||||
//disble send flag
|
||||
return false;
|
||||
}
|
||||
|
||||
//throw send flag
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command Processor method to handle the '!resettoke' command
|
||||
* @param {Object} commandObj - Object representing a single given command/chat request
|
||||
* @returns {Boolean} True to enable send flag on un-authorized call to shame the user
|
||||
*/
|
||||
async resettoke(commandObj, preprocessor){
|
||||
//Check if the user has permission, and publicly shame them if they don't (lmao)
|
||||
if(await permissionModel.permCheck(commandObj.socket.user, 'resetToke')){
|
||||
//Acknowledge command
|
||||
this.chatHandler.relayTokeWhisper(commandObj.socket, 'Toke cooldown reset.');
|
||||
|
||||
//Tell tokebot to reset the toke
|
||||
preprocessor.tokebot.resetToke();
|
||||
|
||||
//disable send flag
|
||||
return false;
|
||||
}
|
||||
|
||||
//throw send flag
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command Processor method to handle the '!clear' command
|
||||
* @param {Object} commandObj - Object representing a single given command/chat request
|
||||
* @returns {Boolean} True to enable send flag on un-authorized call to shame the user
|
||||
*/
|
||||
async clear(commandObj){
|
||||
//Get the current channel from the database
|
||||
const chanDB = await channelModel.findOne({name: commandObj.socket.chan});
|
||||
|
||||
//Check if the user has permission, and publicly shame them if they don't (lmao)
|
||||
if(await chanDB.permCheck(commandObj.socket.user, 'clearChat')){
|
||||
//Send off the command
|
||||
this.chatHandler.clearChat(commandObj.socket.chan, commandObj.argumentArray[1]);
|
||||
//disable send flag
|
||||
return false;
|
||||
}
|
||||
|
||||
//throw send flag
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command Processor method to handle the '!kick' command
|
||||
* @param {Object} commandObj - Object representing a single given command/chat request
|
||||
* @returns {Boolean} True to enable send flag on un-authorized call to shame the user
|
||||
*/
|
||||
async kick(commandObj){
|
||||
//Get the current channel from the database
|
||||
const chanDB = await channelModel.findOne({name: commandObj.socket.chan});
|
||||
|
||||
//Check if the user has permission, and publicly shame them if they don't (lmao)
|
||||
if(await chanDB.permCheck(commandObj.socket.user, 'kickUser')){
|
||||
//Get username from argument array
|
||||
const username = commandObj.argumentArray[1];
|
||||
|
||||
//Get channel
|
||||
const channel = this.server.activeChannels.get(commandObj.socket.chan);
|
||||
|
||||
//get initiator and target user objects
|
||||
const initiator = channel.userList.get(commandObj.socket.user.user);
|
||||
const target = channel.userList.get(username);
|
||||
|
||||
//get initiator and target override abilities
|
||||
const override = await permissionModel.overrideCheck(commandObj.socket.user, 'kickUser');
|
||||
const targetOverride = await permissionModel.overrideCheck(target, 'kickUser');
|
||||
|
||||
//If there is no user
|
||||
if(target == null){
|
||||
//silently drop the command
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
//If the user is capable of overriding this permission based on site permissions
|
||||
if(override || targetOverride){
|
||||
//If the site rank is equal
|
||||
if(permissionModel.rankToNum(initiator.rank) == permissionModel.rankToNum(target.rank)){
|
||||
//compare chan rank
|
||||
if(permissionModel.rankToNum(initiator.chanRank) <= permissionModel.rankToNum(target.chanRank)){
|
||||
//shame the person running it
|
||||
return true;
|
||||
}
|
||||
//otherwise
|
||||
}else{
|
||||
//compare site rank
|
||||
if(permissionModel.rankToNum(initiator.rank) <= permissionModel.rankToNum(target.rank)){
|
||||
//shame the person running it
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}else{
|
||||
//If the target has a higher chan rank than the initiator
|
||||
if(permissionModel.rankToNum(initiator.chanRank) <= permissionModel.rankToNum(target.chanRank)){
|
||||
//shame the person running it
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//Splice out kick
|
||||
commandObj.commandArray.splice(0,4)
|
||||
|
||||
//Get collect reason
|
||||
var reason = commandObj.commandArray.join('');
|
||||
|
||||
//If no reason was given
|
||||
if(reason == ''){
|
||||
//Fill in a generic reason
|
||||
reason = "You have been kicked from the channel!";
|
||||
}
|
||||
|
||||
//Kick the user
|
||||
target.disconnect(reason);
|
||||
|
||||
//throw send flag
|
||||
return false;
|
||||
}
|
||||
|
||||
//throw send flag
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = commandProcessor;
|
||||
|
|
@ -15,24 +15,84 @@ You should have received a copy of the GNU Affero General Public License
|
|||
along with this program. If not, see <https://www.gnu.org/licenses/>.*/
|
||||
|
||||
//local imports
|
||||
const config = require('../../../config.json');
|
||||
const channelModel = require('../../schemas/channel/channelSchema');
|
||||
const permissionModel = require('../../schemas/permissionSchema');
|
||||
const flairModel = require('../../schemas/flairSchema');
|
||||
const emoteModel = require('../../schemas/emoteSchema');
|
||||
const { userModel } = require('../../schemas/user/userSchema');
|
||||
|
||||
module.exports = class{
|
||||
/**
|
||||
* Class representing a single user connected to a channel
|
||||
*/
|
||||
class connectedUser{
|
||||
/**
|
||||
* Instantiates a connectedUser object
|
||||
* @param {Mongoose.Document} userDB - User document to re-hydrate user from
|
||||
* @param {PemissionModel.chanRank} chanRank - Enum representing user channel rank
|
||||
* @param {String} - Channel the user is connecting to
|
||||
* @param {Socket} socket - Socket associated with the users connection
|
||||
*/
|
||||
constructor(userDB, chanRank, channel, socket){
|
||||
/**
|
||||
* User ID Number
|
||||
*/
|
||||
this.id = userDB.id;
|
||||
|
||||
/**
|
||||
* User Name
|
||||
*/
|
||||
this.user = userDB.user;
|
||||
|
||||
/**
|
||||
* User Rank
|
||||
*/
|
||||
this.rank = userDB.rank;
|
||||
|
||||
/**
|
||||
* User High-Level
|
||||
*/
|
||||
this.highLevel = userDB.highLevel;
|
||||
this.flair = userDB.flair.name;
|
||||
|
||||
//Check to make sure users flair entry from DB is good
|
||||
if(userDB.flair != null){
|
||||
//Set flair from DB
|
||||
/**
|
||||
* User Flair
|
||||
*/
|
||||
this.flair = userDB.flair.name;
|
||||
//Otherwise
|
||||
}else{
|
||||
//Gracefully default to classic
|
||||
/**
|
||||
* User Flair
|
||||
*/
|
||||
this.flair = 'classic';
|
||||
}
|
||||
|
||||
/**
|
||||
* User Channel-Rank
|
||||
*/
|
||||
this.chanRank = chanRank;
|
||||
|
||||
/**
|
||||
* Connected Channel
|
||||
*/
|
||||
this.channel = channel;
|
||||
|
||||
/**
|
||||
* List of active sockets to current channel
|
||||
*/
|
||||
this.sockets = [socket.id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles server-side initialization for new connections from a specific user
|
||||
* @param {Mongoose.Document} userDB - User Document Passthrough to save on DB Access
|
||||
* @param {Mongoose.Document} chanDB - Channnel Document Passthrough to save on DB Access
|
||||
* @param {Socket} socket - Requesting Socket
|
||||
* @returns {activeUser} active user object generated by the new connection
|
||||
*/
|
||||
async handleConnection(userDB, chanDB, socket){
|
||||
//send metadata to client
|
||||
this.sendClientMetadata();
|
||||
|
|
@ -48,10 +108,24 @@ module.exports = class{
|
|||
//Send out the currently playing item
|
||||
this.channel.queue.sendMedia(socket);
|
||||
|
||||
//Tattoo hashed IP address to user account for seven days
|
||||
await userDB.tattooIPRecord(socket.handshake.address);
|
||||
//If we're proxied
|
||||
if(config.proxied){
|
||||
//Tattoo hashed IP address from reverse proxy to user account for seven days
|
||||
await userDB.tattooIPRecord(socket.handshake.headers['x-forwarded-for']);
|
||||
}else{
|
||||
//Tattoo hashed IP address to user account for seven days
|
||||
await userDB.tattooIPRecord(socket.handshake.address);
|
||||
}
|
||||
|
||||
|
||||
//Return active user object for use by activeChannel and channelManager objects
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterates through all known connections for a given user, running them through a supplied callback function
|
||||
* @param {Function} cb - Callback to call against found sockets for a given user
|
||||
*/
|
||||
socketCrawl(cb){
|
||||
//Crawl through user's sockets (lol)
|
||||
this.sockets.forEach((sockid) => {
|
||||
|
|
@ -62,22 +136,33 @@ module.exports = class{
|
|||
});
|
||||
}
|
||||
|
||||
//My brain keeps going back to using dynamic per-user namespaces for this
|
||||
//but everytime i look into it I come to the conclusion that it's a bad idea, then I toy with making chans namespaces
|
||||
//and using per-user channels for this, but what of gold or mod-only features? or games?
|
||||
//No matter what it'd probably end up hacky, as namespaces where meant for splitting app logic not user comms (like rooms).
|
||||
//at the end of the day there has to be some penance for decent multi-session handling on-top of a library that doesn't do it.
|
||||
//Having to crawl through these sockets is that. Because the other ways seem more gross somehow.
|
||||
emit(eventName, args){
|
||||
|
||||
/**
|
||||
* Emits an event to all known sockets for a given user
|
||||
*
|
||||
* My brain keeps going back to using dynamic per-user namespaces for this
|
||||
* but everytime i look into it I come to the conclusion that it's a bad idea, then I toy with making chans namespaces
|
||||
* and using per-user channels for this, but what of gold or mod-only features? or games?
|
||||
* No matter what it'd probably end up hacky, as namespaces where meant for splitting app logic not user comms (like rooms).
|
||||
* at the end of the day there has to be some penance for decent multi-session handling on-top of a library that doesn't do it.
|
||||
* Having to crawl through these sockets is that. Because the other ways seem more gross somehow.
|
||||
* @param {String} eventName - Event name to emit to client sockets
|
||||
* @param {Object} data - Data to emit to client sockets
|
||||
*/
|
||||
emit(eventName, data){
|
||||
this.socketCrawl((socket)=>{
|
||||
//Ensure our socket is initialized
|
||||
if(socket != null){
|
||||
socket.emit(eventName, args);
|
||||
socket.emit(eventName, data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//generic disconnect function, defaults to kick
|
||||
/**
|
||||
* Disconnects all sockets for a given user
|
||||
* @param {String} reason - Reason for being disconnected
|
||||
* @param {String} type - Disconnection Type
|
||||
*/
|
||||
disconnect(reason, type = "Disconnected"){
|
||||
this.emit("kick",{type, reason});
|
||||
this.socketCrawl((socket)=>{socket.disconnect()});
|
||||
|
|
@ -85,6 +170,11 @@ module.exports = class{
|
|||
|
||||
//This is the big first push upon connection
|
||||
//It should only fire once, so things that only need to be sent once can be slapped into here
|
||||
/**
|
||||
* Sends glut of required initial metadata to the client upon a new connection
|
||||
* @param {Mongoose.Document} userDB - User Document Passthrough to save on DB Access
|
||||
* @param {Mongoose.Document} chanDB - Channnel Document Passthrough to save on DB Access
|
||||
*/
|
||||
async sendClientMetadata(userDB, chanDB){
|
||||
//Get flairList from DB and setup flairList array
|
||||
const flairListDB = await flairModel.find({});
|
||||
|
|
@ -136,16 +226,19 @@ module.exports = class{
|
|||
}
|
||||
});
|
||||
|
||||
//Get schedule as a temporary array
|
||||
const queue = await this.channel.queue.prepQueue(chanDB);
|
||||
|
||||
//Get schedule lock status
|
||||
const queueLock = this.channel.queue.locked;
|
||||
|
||||
//Get chat buffer
|
||||
const chatBuffer = this.channel.chatBuffer.buffer;
|
||||
|
||||
//Send off the metadata to our user's clients
|
||||
this.emit("clientMetadata", {user: userObj, flairList, queue, queueLock});
|
||||
this.emit("clientMetadata", {user: userObj, flairList, queueLock, chatBuffer});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send copy of site emotes to the user
|
||||
*/
|
||||
async sendSiteEmotes(){
|
||||
//Get emote list from DB
|
||||
const emoteList = await emoteModel.getEmotes();
|
||||
|
|
@ -154,6 +247,10 @@ module.exports = class{
|
|||
this.emit('siteEmotes', emoteList);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send copy of channel emotes to the user
|
||||
* @param {Mongoose.Document} chanDB - Channnel Document Passthrough to save on DB Access
|
||||
*/
|
||||
async sendChanEmotes(chanDB){
|
||||
//if we wherent handed a channel document
|
||||
if(chanDB == null){
|
||||
|
|
@ -168,6 +265,10 @@ module.exports = class{
|
|||
this.emit('chanEmotes', emoteList);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send copy of channel emotes to the user
|
||||
* @param {Mongoose.Document} userDB - User Document Passthrough to save on DB Access
|
||||
*/
|
||||
async sendPersonalEmotes(userDB){
|
||||
//if we wherent handed a user document
|
||||
if(userDB == null){
|
||||
|
|
@ -182,6 +283,10 @@ module.exports = class{
|
|||
this.emit('personalEmotes', emoteList);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send copy of channel emotes to the user
|
||||
* @param {Mongoose.Document} userDB - User Document Passthrough to save on DB Access
|
||||
*/
|
||||
async sendUsedTokes(userDB){
|
||||
//if we wherent handed a user document
|
||||
if(userDB == null){
|
||||
|
|
@ -195,6 +300,10 @@ module.exports = class{
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set flair for a given user and broadcast update to clients
|
||||
* @param {String} flair - Flair string to update user's flair to
|
||||
*/
|
||||
updateFlair(flair){
|
||||
this.flair = flair;
|
||||
|
||||
|
|
@ -202,6 +311,10 @@ module.exports = class{
|
|||
this.sendClientMetadata();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set high level for a given user and broadcast update to clients
|
||||
* @param {Number} highLevel - Number to update user's high-level to
|
||||
*/
|
||||
updateHighLevel(highLevel){
|
||||
this.highLevel = highLevel;
|
||||
|
||||
|
|
@ -209,4 +322,6 @@ module.exports = class{
|
|||
this.channel.broadcastUserList();
|
||||
this.sendClientMetadata();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = connectedUser;
|
||||
|
|
@ -14,16 +14,64 @@ 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 crypto = require('node:crypto');
|
||||
|
||||
module.exports = class{
|
||||
constructor(title, fileName, url, id, type, duration){
|
||||
/**
|
||||
* Object representing a piece of media
|
||||
*/
|
||||
class media{
|
||||
/**
|
||||
* Creates a new media object from scraped information
|
||||
* @param {String} title - Chosen title of media
|
||||
* @param {String} fileName - Original filename/title of media provided by source
|
||||
* @param {String} url - Original URL to file
|
||||
* @param {String} id - Video ID from source (IE: youtube watch code/archive.org file path)
|
||||
* @param {String} type - Original video source
|
||||
* @param {Number} duration - Length of media in seconds
|
||||
* @param {String} rawLink - URLs to raw file copies of media, not applicable to all sources, not saved to the DB
|
||||
*/
|
||||
constructor(title, fileName, url, id, type, duration, rawLink){
|
||||
/**
|
||||
* Chosen title of media
|
||||
*/
|
||||
this.title = title;
|
||||
|
||||
/**
|
||||
* Original filename/title of media provided by source
|
||||
*/
|
||||
this.fileName = fileName
|
||||
|
||||
/**
|
||||
* Original URL to file
|
||||
*/
|
||||
this.url = url;
|
||||
|
||||
/**
|
||||
* Video ID from source (IE: youtube watch code/archive.org file path)
|
||||
*/
|
||||
this.id = id;
|
||||
|
||||
/**
|
||||
* Original video source
|
||||
*/
|
||||
this.type = type;
|
||||
|
||||
/**
|
||||
* Length of media in seconds
|
||||
*/
|
||||
this.duration = duration;
|
||||
|
||||
if(rawLink == null){
|
||||
/**
|
||||
* URL to raw file copy of media, not applicable to all sources
|
||||
*/
|
||||
this.rawLink = {
|
||||
audio: [],
|
||||
video: [],
|
||||
combo: [['default',url]]
|
||||
};
|
||||
}else{
|
||||
this.rawLink = rawLink;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = media;
|
||||
|
|
@ -24,14 +24,31 @@ const yanker = require('../../../utils/media/yanker');
|
|||
const channelModel = require('../../../schemas/channel/channelSchema');
|
||||
const { userModel } = require('../../../schemas/user/userSchema');
|
||||
|
||||
module.exports = class{
|
||||
constructor(server, chanDB, channel){
|
||||
//Set server
|
||||
/**
|
||||
* Class containing playlist management logic for a single channel
|
||||
*/
|
||||
class playlistHandler{
|
||||
/**
|
||||
* Instantiates a new object to handle playlist management for a single channel
|
||||
* @param {channelManager} server - Parent server object
|
||||
* @param {activeChannel} channel - Parent Channel object for desired channel queue
|
||||
*/
|
||||
constructor(server, channel){
|
||||
/**
|
||||
* Parent Server Object
|
||||
*/
|
||||
this.server = server
|
||||
//Set channel
|
||||
|
||||
/**
|
||||
* Parent Channel Object for desired channel queue
|
||||
*/
|
||||
this.channel = channel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines server-side socket.io listeners for newly connected sockets
|
||||
* @param {Socket} socket - Newly connected socket to define listeners against
|
||||
*/
|
||||
defineListeners(socket){
|
||||
//Channel Playlist Listeners
|
||||
socket.on("getChannelPlaylists", () => {this.getChannelPlaylists(socket)});
|
||||
|
|
@ -59,6 +76,12 @@ module.exports = class{
|
|||
}
|
||||
|
||||
//Validation/Sanatization functions
|
||||
/**
|
||||
* Validates client requests to create a playlist
|
||||
* @param {Socket} socket - Newly connected socket to define listeners against
|
||||
* @param {Object} data - Data handed over from the client
|
||||
* @returns {Object} returns validated titles
|
||||
*/
|
||||
createPlaylistValidator(socket, data){
|
||||
//Create empty array to hold titles
|
||||
const safeTitles = [];
|
||||
|
|
@ -89,14 +112,20 @@ module.exports = class{
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates client requests to add media to a playlist
|
||||
* @param {Socket} socket - Newly connected socket to define listeners against
|
||||
* @param {String} URL - URL String handed over from the client
|
||||
* @returns {Array} List of media objects which where added
|
||||
*/
|
||||
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!
|
||||
|
|
@ -118,6 +147,12 @@ module.exports = class{
|
|||
return mediaList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates client requests to queue media from a playlist
|
||||
* @param {Socket} socket - Newly connected socket to define listeners against
|
||||
* @param {Object} data - Data handed over from the client
|
||||
* @returns {Number} returns validated start time on success
|
||||
*/
|
||||
queueFromChannelPlaylistValidator(socket, data){
|
||||
//Validate UUID
|
||||
if(typeof data.uuid != 'string' || !validator.isUUID(data.uuid)){
|
||||
|
|
@ -131,6 +166,12 @@ module.exports = class{
|
|||
return this.channel.queue.getStart(data.start)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates client requests to rename the playlist validator
|
||||
* @param {Socket} socket - Newly connected socket to define listeners against
|
||||
* @param {Object} data - Data handed over from the client
|
||||
* @returns {String} returns escaped/trimmed name upon success
|
||||
*/
|
||||
renameChannelPlaylistValidator(socket, data){
|
||||
//If the title is too long
|
||||
if(typeof data.name != 'string' || !validator.isLength(data.name, {min: 1, max:30})){
|
||||
|
|
@ -144,6 +185,11 @@ module.exports = class{
|
|||
return validator.escape(validator.trim(data.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates client requests to change default titles for a given playlist
|
||||
* @param {Object} data - Data handed over from the client
|
||||
* @returns {Array} Array of strings containing valid titles from the output
|
||||
*/
|
||||
changeDefaultTitlesValidator(data){
|
||||
//Create empty array to hold titles
|
||||
const safeTitles = [];
|
||||
|
|
@ -161,6 +207,11 @@ module.exports = class{
|
|||
return safeTitles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates client requests to rename the playlist validator
|
||||
* @param {Socket} socket - Newly connected socket to define listeners against
|
||||
* @param {Object} data - Data handed over from the client
|
||||
*/
|
||||
deletePlaylistMediaValidator(socket, data){
|
||||
//If we don't have a valid UUID
|
||||
if(typeof data.uuid != 'string' || !validator.isUUID(data.uuid)){
|
||||
|
|
@ -174,6 +225,11 @@ module.exports = class{
|
|||
}
|
||||
|
||||
//Get playlist functions
|
||||
/**
|
||||
* Sends channel playlist data to a requesting socket
|
||||
* @param {Socket} socket - Newly connected socket to define listeners against
|
||||
* @param {Mongoose.Document} chanDB - Channnel Document Passthrough to save on DB Access
|
||||
*/
|
||||
async getChannelPlaylists(socket, chanDB){
|
||||
try{
|
||||
//if we wherent handed a channel document
|
||||
|
|
@ -189,6 +245,11 @@ module.exports = class{
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends user playlist data to a requesting socket
|
||||
* @param {Socket} socket - Newly connected socket to define listeners against
|
||||
* @param {Mongoose.Document} userDB - Channnel Document Passthrough to save on DB Access
|
||||
*/
|
||||
async getUserPlaylists(socket, userDB){
|
||||
try{
|
||||
//if we wherent handed a user document
|
||||
|
|
@ -205,6 +266,12 @@ module.exports = class{
|
|||
}
|
||||
|
||||
//Create playlist functions
|
||||
/**
|
||||
* Creates a new channel playlist
|
||||
* @param {Socket} socket - Requesting socket
|
||||
* @param {Object} data - Data handed over from the client
|
||||
* @param {Mongoose.Document} chanDB - Channnel Document Passthrough to save on DB Access
|
||||
*/
|
||||
async createChannelPlaylist(socket, data, chanDB){
|
||||
try{
|
||||
//Validate Data
|
||||
|
|
@ -248,6 +315,12 @@ module.exports = class{
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new user playlist
|
||||
* @param {Socket} socket - Requesting socket
|
||||
* @param {Object} data - Data handed over from the client
|
||||
* @param {Mongoose.Document} userDB - User Document Passthrough to save on DB Access
|
||||
*/
|
||||
async createUserPlaylist(socket, data, userDB){
|
||||
try{
|
||||
//Validate Data
|
||||
|
|
@ -290,6 +363,12 @@ module.exports = class{
|
|||
}
|
||||
|
||||
//Delete playlist functions
|
||||
/**
|
||||
* Deletes a user playlist
|
||||
* @param {Socket} socket - Requesting socket
|
||||
* @param {Object} data - Data handed over from the client
|
||||
* @param {Mongoose.Document} userDB - User Document Passthrough to save on DB Access
|
||||
*/
|
||||
async deleteChannelPlaylist(socket, data, chanDB){
|
||||
try{
|
||||
//if we wherent handed a channel document
|
||||
|
|
@ -318,6 +397,12 @@ module.exports = class{
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a Channel playlist
|
||||
* @param {Socket} socket - Requesting socket
|
||||
* @param {Object} data - Data handed over from the client
|
||||
* @param {Mongoose.Document} chanDB - Channnel Document Passthrough to save on DB Access
|
||||
*/
|
||||
async deleteUserPlaylist(socket, data, userDB){
|
||||
try{
|
||||
//if we wherent handed a user document
|
||||
|
|
@ -345,6 +430,12 @@ module.exports = class{
|
|||
}
|
||||
|
||||
//Add Media Functions
|
||||
/**
|
||||
* Adds media to channel playlist
|
||||
* @param {Socket} socket - Requesting socket
|
||||
* @param {Object} data - Data handed over from the client
|
||||
* @param {Mongoose.Document} chanDB - Channnel Document Passthrough to save on DB Access
|
||||
*/
|
||||
async addToChannelPlaylist(socket, data, chanDB){
|
||||
try{
|
||||
//if we wherent handed a channel document
|
||||
|
|
@ -390,6 +481,12 @@ module.exports = class{
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds media to user playlist
|
||||
* @param {Socket} socket - Requesting socket
|
||||
* @param {Object} data - Data handed over from the client
|
||||
* @param {Mongoose.Document} userDB - User Document Passthrough to save on DB Access
|
||||
*/
|
||||
async addToUserPlaylist(socket, data, userDB){
|
||||
try{
|
||||
//Validate URL and pull media
|
||||
|
|
@ -432,6 +529,12 @@ module.exports = class{
|
|||
}
|
||||
|
||||
//Queuing Functions
|
||||
/**
|
||||
* Queues an entire channel playlist
|
||||
* @param {Socket} socket - Requesting socket
|
||||
* @param {Object} data - Data handed over from the client
|
||||
* @param {Mongoose.Document} chanDB - Channel Document Passthrough to save on DB Access
|
||||
*/
|
||||
async queueChannelPlaylist(socket, data, chanDB){
|
||||
try{
|
||||
//if we wherent handed a channel document
|
||||
|
|
@ -479,6 +582,13 @@ module.exports = class{
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues an entire user playlist
|
||||
* @param {Socket} socket - Requesting socket
|
||||
* @param {Object} data - Data handed over from the client
|
||||
* @param {Mongoose.Document} userDB - User Document Passthrough to save on DB Access
|
||||
* @param {Mongoose.Document} chanDB - Channel Document Passthrough to save on DB Access
|
||||
*/
|
||||
async queueUserPlaylist(socket, data, userDB, chanDB){
|
||||
try{
|
||||
//if we wherent handed a user document
|
||||
|
|
@ -532,6 +642,12 @@ module.exports = class{
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues media from a given channel playlist
|
||||
* @param {Socket} socket - Requesting socket
|
||||
* @param {Object} data - Data handed over from the client
|
||||
* @param {Mongoose.Document} chanDB - Channel Document Passthrough to save on DB Access
|
||||
*/
|
||||
async queueFromChannelPlaylist(socket, data, chanDB){
|
||||
try{
|
||||
//Validate data
|
||||
|
|
@ -576,6 +692,13 @@ module.exports = class{
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues media from a given user playlist
|
||||
* @param {Socket} socket - Requesting socket
|
||||
* @param {Object} data - Data handed over from the client
|
||||
* @param {Mongoose.Document} userDB - User Document Passthrough to save on DB Access
|
||||
* @param {Mongoose.Document} chanDB - Channel Document Passthrough to save on DB Access
|
||||
*/
|
||||
async queueFromUserPlaylist(socket, data, userDB, chanDB){
|
||||
try{
|
||||
//Validate data
|
||||
|
|
@ -626,6 +749,12 @@ module.exports = class{
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues random media from a given channel playlist
|
||||
* @param {Socket} socket - Requesting socket
|
||||
* @param {Object} data - Data handed over from the client
|
||||
* @param {Mongoose.Document} chanDB - Channel Document Passthrough to save on DB Access
|
||||
*/
|
||||
async queueRandomFromChannelPlaylist(socket, data, chanDB){
|
||||
try{
|
||||
//if we wherent handed a channel document
|
||||
|
|
@ -667,6 +796,13 @@ module.exports = class{
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues random media from a given user playlist
|
||||
* @param {Socket} socket - Requesting socket
|
||||
* @param {Object} data - Data handed over from the client
|
||||
* @param {Mongoose.Document} userDB - User Document Passthrough to save on DB Access
|
||||
* @param {Mongoose.Document} chanDB - Channel Document Passthrough to save on DB Access
|
||||
*/
|
||||
async queueRandomFromUserPlaylist(socket, data, userDB, chanDB){
|
||||
try{
|
||||
//if we wherent handed a channel document
|
||||
|
|
@ -715,6 +851,12 @@ module.exports = class{
|
|||
}
|
||||
|
||||
//Rename playlist functions
|
||||
/**
|
||||
* Renames a channel playlist
|
||||
* @param {Socket} socket - Requesting socket
|
||||
* @param {Object} data - Data handed over from the client
|
||||
* @param {Mongoose.Document} chanDB - Channel Document Passthrough to save on DB Access
|
||||
*/
|
||||
async renameChannelPlaylist(socket, data, chanDB){
|
||||
try{
|
||||
//Validate and Sanatize name
|
||||
|
|
@ -766,6 +908,12 @@ module.exports = class{
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a user playlist
|
||||
* @param {Socket} socket - Requesting socket
|
||||
* @param {Object} data - Data handed over from the client
|
||||
* @param {Mongoose.Document} userDB - User Document Passthrough to save on DB Access
|
||||
*/
|
||||
async renameUserPlaylist(socket, data, userDB){
|
||||
try{
|
||||
//Validate and Sanatize name
|
||||
|
|
@ -816,6 +964,12 @@ module.exports = class{
|
|||
}
|
||||
|
||||
//Change default title list functions
|
||||
/**
|
||||
* Changes default titles for a given channel playlist
|
||||
* @param {Socket} socket - Requesting socket
|
||||
* @param {Object} data - Data handed over from the client
|
||||
* @param {Mongoose.Document} chanDB - Channel Document Passthrough to save on DB Access
|
||||
*/
|
||||
async changeDefaultTitlesChannelPlaylist(socket, data, chanDB){
|
||||
try{
|
||||
//if we wherent handed a channel document
|
||||
|
|
@ -850,6 +1004,12 @@ module.exports = class{
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes default titles for a given user playlist
|
||||
* @param {Socket} socket - Requesting socket
|
||||
* @param {Object} data - Data handed over from the client
|
||||
* @param {Mongoose.Document} userDB - User Document Passthrough to save on DB Access
|
||||
*/
|
||||
async changeDefaultTitlesUserPlaylist(socket, data, userDB){
|
||||
try{
|
||||
//if we wherent handed a user document
|
||||
|
|
@ -884,6 +1044,12 @@ module.exports = class{
|
|||
}
|
||||
|
||||
//Delete playlist media functions
|
||||
/**
|
||||
* Deletes media from a given channel playlist
|
||||
* @param {Socket} socket - Requesting socket
|
||||
* @param {Object} data - Data handed over from the client
|
||||
* @param {Mongoose.Document} chanDB - Channel Document Passthrough to save on DB Access
|
||||
*/
|
||||
async deleteChannelPlaylistMedia(socket, data, chanDB){
|
||||
try{
|
||||
//Validate UUID
|
||||
|
|
@ -927,6 +1093,12 @@ module.exports = class{
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes media from a given user playlist
|
||||
* @param {Socket} socket - Requesting socket
|
||||
* @param {Object} data - Data handed over from the client
|
||||
* @param {Mongoose.Document} userDB - User Document Passthrough to save on DB Access
|
||||
*/
|
||||
async deleteUserPlaylistMedia(socket, data, userDB){
|
||||
try{
|
||||
//Validate UUID
|
||||
|
|
@ -967,4 +1139,6 @@ module.exports = class{
|
|||
return loggerUtils.socketExceptionHandler(socket, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = playlistHandler;
|
||||
File diff suppressed because it is too large
Load diff
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;
|
||||
|
|
@ -17,17 +17,40 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
|
|||
//Local Imports
|
||||
const media = require('./media');
|
||||
|
||||
module.exports = class extends media{
|
||||
constructor(title, fileName, url, id, type, duration, startTime, startTimeStamp, earlyEnd, uuid){
|
||||
/**
|
||||
* Class extending media which represents a queued piece of media
|
||||
* @extends media
|
||||
*/
|
||||
class queuedMedia extends media{
|
||||
/**
|
||||
* Creates a new queued media object
|
||||
* @param {Number} startTime - JS Epoch representing start time
|
||||
* @param {Number} startTimeStamp - Media start time stamp in seconds (relative to duration)
|
||||
* @param {Number} earlyEnd - Media end timestamp in seconds (relative to duration)
|
||||
* @param {String} uuid - Media object's unique identifier
|
||||
*/
|
||||
constructor(title, fileName, url, id, type, duration, rawLink, startTime, startTimeStamp = 0, earlyEnd, uuid){
|
||||
//Call derived constructor
|
||||
super(title, fileName, url, id, type, duration);
|
||||
//Set media start time
|
||||
super(title, fileName, url, id, type, duration, rawLink);
|
||||
|
||||
/**
|
||||
* JS Epoch (millis) representing start time
|
||||
*/
|
||||
this.startTime = startTime;
|
||||
//Set the media start time stamp
|
||||
|
||||
/**
|
||||
* Media start time stamp in seconds (relative to duration)
|
||||
*/
|
||||
this.startTimeStamp = startTimeStamp;
|
||||
//Create empty variable to hold early end if media is stopped early
|
||||
|
||||
/**
|
||||
* Media ent timestamp in seconds (relative to duration)
|
||||
*/
|
||||
this.earlyEnd = earlyEnd;
|
||||
//Set status for discriminator key
|
||||
|
||||
/**
|
||||
* Media status type
|
||||
*/
|
||||
this.status = 'queued';
|
||||
|
||||
//If we have a null uuid (can't use default argument because of 'this')
|
||||
|
|
@ -36,11 +59,21 @@ module.exports = class extends media{
|
|||
//That way even if we have six copies of the same video queued, we can still uniquely idenitify each instance
|
||||
this.genUUID();
|
||||
}else{
|
||||
/**
|
||||
* Media object's unique identifier
|
||||
*/
|
||||
this.uuid = uuid;
|
||||
}
|
||||
}
|
||||
|
||||
//statics
|
||||
/**
|
||||
* Creates a queuedMedia object from a media object
|
||||
* @param {media} media - Media object to queue
|
||||
* @param {Number} startTime - Start time formatted as a JS Epoch
|
||||
* @param {Number} startTimeStamp - Start time stamp in seconds
|
||||
* @returns {queuedMedia} queuedMedia object created from given media object
|
||||
*/
|
||||
static fromMedia(media, startTime, startTimeStamp){
|
||||
//Create and return queuedMedia object from given media object and arguments
|
||||
return new this(
|
||||
|
|
@ -50,10 +83,17 @@ module.exports = class extends media{
|
|||
media.id,
|
||||
media.type,
|
||||
media.duration,
|
||||
media.rawLink,
|
||||
startTime,
|
||||
startTimeStamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts array of media objects into array of queuedMedia objects
|
||||
* @param {Array} mediaList - Array of media objects to queue
|
||||
* @param {Number} start - Start time formatted as JS Epoch
|
||||
* @returns Array of converted queued media objects
|
||||
*/
|
||||
static fromMediaArray(mediaList, start){
|
||||
//Queued Media List
|
||||
const queuedMediaList = [];
|
||||
|
|
@ -72,13 +112,40 @@ module.exports = class extends media{
|
|||
}
|
||||
|
||||
//methods
|
||||
/**
|
||||
* Generates new unique identifier for queued media
|
||||
*/
|
||||
genUUID(){
|
||||
this.uuid = crypto.randomUUID();
|
||||
}
|
||||
|
||||
getEndTime(){
|
||||
/**
|
||||
* Generates a unique clone of a given media object
|
||||
* @returns unique clone of media object
|
||||
*/
|
||||
clone(){
|
||||
return new queuedMedia(
|
||||
this.title,
|
||||
this.fileName,
|
||||
this.url,
|
||||
this.id,
|
||||
this.type,
|
||||
this.duration,
|
||||
this.rawLink,
|
||||
this.startTime,
|
||||
this.startTimeStamp,
|
||||
this.earlyEnd
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* return the end time of a given queuedMedia object
|
||||
* @param {boolean} fullTime - Overrides early ends
|
||||
* @returns end time of given queuedMedia object
|
||||
*/
|
||||
getEndTime(fullTime = false){
|
||||
//If we have an early ending
|
||||
if(this.earlyEnd == null){
|
||||
if(this.earlyEnd == null || fullTime){
|
||||
//Calculate our ending
|
||||
return this.startTime + ((this.duration - this.startTimeStamp) * 1000);
|
||||
}else{
|
||||
|
|
@ -86,4 +153,6 @@ module.exports = class extends media{
|
|||
return this.startTime + (this.earlyEnd * 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = queuedMedia;
|
||||
|
|
@ -16,46 +16,102 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
|
|||
|
||||
//Local Imports
|
||||
const tokeCommandModel = require('../../schemas/tokebot/tokeCommandSchema');
|
||||
const tokeModel = require('../../schemas/tokebot/tokeSchema');
|
||||
const {userModel} = require('../../schemas/user/userSchema');
|
||||
const statModel = require('../../schemas/statSchema');
|
||||
const statSchema = require('../../schemas/statSchema');
|
||||
|
||||
|
||||
module.exports = class tokebot{
|
||||
/**
|
||||
* Class containing global server-side tokebot logic
|
||||
*/
|
||||
class tokebot{
|
||||
/**
|
||||
* Instantiates a tokebot object
|
||||
* @param {channelManager} server - Parent Server Object
|
||||
* @param {chatHandler} chatHandler - Parent Chat Handler Object
|
||||
*/
|
||||
constructor(server, chatHandler){
|
||||
//Set parents
|
||||
/**
|
||||
* Parent Server Object
|
||||
*/
|
||||
this.server = server;
|
||||
|
||||
/**
|
||||
* Parent Chat Handler
|
||||
*/
|
||||
this.chatHandler = chatHandler;
|
||||
|
||||
//Set timeouts to null
|
||||
/**
|
||||
* Toke Timer
|
||||
*/
|
||||
this.tokeTimer = null;
|
||||
|
||||
/**
|
||||
* Cooldown Timer
|
||||
*/
|
||||
this.cooldownTimer = null;
|
||||
|
||||
//Set start times
|
||||
/**
|
||||
* Toke time
|
||||
*/
|
||||
this.tokeTime = 60;
|
||||
|
||||
/**
|
||||
* Cooldown Time
|
||||
*/
|
||||
this.cooldownTime = 120;
|
||||
|
||||
//Create counter variable
|
||||
/**
|
||||
* Toke Counter
|
||||
*/
|
||||
this.tokeCounter = 0;
|
||||
|
||||
/**
|
||||
* Cooldown Counter
|
||||
*/
|
||||
this.cooldownCounter = 0;
|
||||
|
||||
//Create tokers list
|
||||
/**
|
||||
* List of current tokers
|
||||
*/
|
||||
this.tokers = new Map();
|
||||
|
||||
//Load in toke commands from the DB
|
||||
this.refreshCommands();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads toke commands from DB into RAM-based toke command store
|
||||
*/
|
||||
async refreshCommands(){
|
||||
//Pull Command Strings from DB
|
||||
this.tokeCommands = await tokeCommandModel.getCommandStrings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes toke commands from Command Pre-Processor
|
||||
* @param {Object} commandObj - Object representing a single given command/chat request, passed down from the Command Pre-Processor
|
||||
* @returns {Boolean} True if the toke is an invalid toke command (tells Command Pre-Processor to send command as chat)
|
||||
*/
|
||||
tokeProcessor(commandObj){
|
||||
//Check for site-wide toke commands
|
||||
if(this.tokeCommands.indexOf(commandObj.argumentArray[0].toLowerCase()) != -1){
|
||||
//Seems lame to set a bool in an if statement but this would've made a really ugly turinary
|
||||
var foundToke = true;
|
||||
}else if(commandObj.argumentArray[0].toLowerCase() == 'r'){
|
||||
//Find the users active channel
|
||||
const activeChan = this.server.activeChannels.get(commandObj.socket.chan);
|
||||
|
||||
//Combile site-wide and channel tokes into one list
|
||||
const tokeList = this.tokeCommands.concat(activeChan.tokeCommands);
|
||||
|
||||
//Pick a random number between 0 and one less than the number of tokes
|
||||
const foundIndex = Math.round(Math.random() * (tokeList.length - 1));
|
||||
|
||||
//Set override command argument 0 w/ the found toke
|
||||
commandObj.argumentArray[0] = tokeList[foundIndex];
|
||||
|
||||
//throw toke flag
|
||||
var foundToke = true;
|
||||
}else{
|
||||
//Find the users active channel
|
||||
const activeChan = this.server.activeChannels.get(commandObj.socket.chan);
|
||||
|
|
@ -105,13 +161,16 @@ module.exports = class tokebot{
|
|||
}
|
||||
|
||||
//Toke command found, and there isn't any extra text, don't send as chat (re-create fore.st tokebot behaviour)
|
||||
return (commandObj.command != `!${commandObj.argumentArray[0]}`)
|
||||
return (commandObj.command != `!${commandObj.argumentArray[0]}` && commandObj.command != '!r');
|
||||
}else{
|
||||
//No toke found, send it down the line, because shaming the user is funny
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called each second during the toke. Handles decrementing the timer variable, and countdown end logic.
|
||||
*/
|
||||
countdown(){
|
||||
//If we're in the last three seconds
|
||||
if(this.tokeCounter <= 3 && this.tokeCounter > 0){
|
||||
|
|
@ -131,8 +190,8 @@ module.exports = 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;
|
||||
|
|
@ -154,6 +213,10 @@ module.exports = class tokebot{
|
|||
this.tokeTimer = setTimeout(this.countdown.bind(this), 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* This method seems to be a vestage from a bygone era. We should remove it after documenting shit.
|
||||
* I would now, but I don't want to break shit in a comment-only commit.
|
||||
*/
|
||||
async asyncFinisher(){
|
||||
//Grab a copy of the tokers map before it gets cleared out
|
||||
const tokers = this.tokers;
|
||||
|
|
@ -162,6 +225,9 @@ module.exports = class tokebot{
|
|||
await userModel.tattooToke(tokers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs every second for 60 seconds after a toke
|
||||
*/
|
||||
cooldown(){
|
||||
//If the cooldown timer isn't over
|
||||
if(this.cooldownCounter > 0){
|
||||
|
|
@ -176,4 +242,17 @@ module.exports = class tokebot{
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets toke cooldowns early upon authorized request
|
||||
*/
|
||||
resetToke(){
|
||||
//Set cooldown to 0
|
||||
this.cooldownCounter = 0;
|
||||
|
||||
//Null out the timer
|
||||
this.cooldownTimer = null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = tokebot;
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -38,9 +38,12 @@ module.exports.post = async function(req, res){
|
|||
//Get sanatized/validated data
|
||||
const {email, pass} = matchedData(req);
|
||||
|
||||
//If we're proxied use passthrough IP
|
||||
const ip = config.proxied ? req.headers['x-forwarded-for'] : req.ip;
|
||||
|
||||
//Check to make sure the user is logged in
|
||||
if(req.session.user == null){
|
||||
errorHandler(res, "Invalid user!");
|
||||
return errorHandler(res, "Invalid user!");
|
||||
}
|
||||
|
||||
//Authenticate and find user model from DB
|
||||
|
|
@ -48,15 +51,26 @@ 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
|
||||
const requestDB = await emailChangeModel.create({user: userDB._id, newEmail: email, ipHash: req.ip});
|
||||
const requestDB = await emailChangeModel.create({user: userDB._id, newEmail: email, ipHash: ip});
|
||||
|
||||
//Don't wait on mailer to get back to the browser
|
||||
res.sendStatus(200);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
//try to authenticate the session, and return a successful code if it works
|
||||
await sessionUtils.authenticateSession(user, pass, req);
|
||||
const data = matchedData(req);
|
||||
|
||||
//try to authenticate the session, throwing an error and breaking the current code block if user is un-authorized
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -40,6 +40,9 @@ module.exports.post = async function(req, res){
|
|||
//Verify Altcha Payload
|
||||
const verified = await altchaUtils.verify(req.body.verification);
|
||||
|
||||
//If we're proxied use passthrough IP
|
||||
const ip = config.proxied ? req.headers['x-forwarded-for'] : req.ip;
|
||||
|
||||
//If altcha verification failed
|
||||
if(!verified){
|
||||
return errorHandler(res, 'Altcha verification failed, Please refresh the page!', 'unauthorized');
|
||||
|
|
@ -63,7 +66,7 @@ module.exports.post = async function(req, res){
|
|||
}
|
||||
|
||||
//Generate the password reset link
|
||||
const requestDB = await passwordResetModel.create({user: userDB._id, ipHash: req.ip});
|
||||
const requestDB = await passwordResetModel.create({user: userDB._id, ipHash: ip});
|
||||
|
||||
//Send the reset url via email
|
||||
const mailInfo = await mailUtils.mailem(
|
||||
|
|
|
|||
|
|
@ -43,6 +43,10 @@ module.exports.post = async function(req, res){
|
|||
return errorHandler(res, 'Altcha verification failed, Please refresh the page!', 'unauthorized');
|
||||
}
|
||||
|
||||
|
||||
//If we're proxied use passthrough IP
|
||||
const ip = config.proxied ? req.headers['x-forwarded-for'] : req.ip;
|
||||
|
||||
//Would prefer to stick this in userModel.statics.register() but we end up with circular dependencies >:(
|
||||
const nukedBans = await userBanModel.checkProcessedBans(user.user);
|
||||
|
||||
|
|
@ -53,22 +57,44 @@ module.exports.post = async function(req, res){
|
|||
}
|
||||
|
||||
//Look for ban by IP
|
||||
const ipBanDB = await userBanModel.checkBanByIP(req.ip);
|
||||
const ipBanDB = await userBanModel.checkBanByIP(ip);
|
||||
|
||||
//If this ip is randy bobandy
|
||||
if(ipBanDB != null){
|
||||
//Make the code and message look pretty (kinda) at the same time
|
||||
const banMsg = [
|
||||
'The IP address you are trying to register an account from has been banned.',
|
||||
'If you beleive this to be an error feel free to reach out to your server administrator.',
|
||||
'Otherwise, fuck off :)'
|
||||
];
|
||||
|
||||
//Make the number a little prettier despite the lack of precision since we're not doing calculations here :P
|
||||
const expiration = ipBanDB.getDaysUntilExpiration() < 1 ? 0 : ipBanDB.getDaysUntilExpiration();
|
||||
let banMsg = [];
|
||||
|
||||
//If the ban is permanent
|
||||
if(ipBanDB.permanent){
|
||||
//tell it to fuck off
|
||||
//Make the code and message look pretty (kinda) at the same time
|
||||
banMsg = [
|
||||
'The IP address you are trying to register an account from has been permanently banned.',
|
||||
'Your cleartext IP has been saved to the database.',
|
||||
`Any associated accounts will be nuked in ${expiration} day(s).`,
|
||||
'If you beleive this to be an error feel free to reach out to your server administrator.',
|
||||
'Otherwise, fuck off :)'
|
||||
];
|
||||
}else{
|
||||
//tell it to fuck off
|
||||
//Make the code and message look pretty (kinda) at the same time
|
||||
banMsg = [
|
||||
'The IP address you are trying to register an account from has been temporarily banned.',
|
||||
`Your cleartext IP has been saved to the database until the ban expires in ${expiration} day(s).`,
|
||||
'If you beleive this to be an error feel free to reach out to your server administrator.',
|
||||
'Otherwise, fuck off :)'
|
||||
];
|
||||
}
|
||||
|
||||
//tell it to fuck off
|
||||
return errorHandler(res, banMsg.join('<br>'), 'unauthorized');
|
||||
}
|
||||
|
||||
await userModel.register(user, req.ip);
|
||||
//Register off of given IP
|
||||
await userModel.register(user, ip);
|
||||
|
||||
return res.sendStatus(200);
|
||||
}else{
|
||||
res.status(400);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ GNU Affero General Public License for more details.
|
|||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.*/
|
||||
|
||||
//config
|
||||
const config = require('../../../../config.json');
|
||||
|
||||
//npm imports
|
||||
const {validationResult, matchedData} = require('express-validator');
|
||||
|
||||
|
|
@ -34,6 +37,9 @@ module.exports.post = async function(req, res){
|
|||
//Find user from input
|
||||
const userDB = await userModel.findOne({user});
|
||||
|
||||
//If we're proxied use passthrough IP
|
||||
const ip = config.proxied ? req.headers['x-forwarded-for'] : req.ip;
|
||||
|
||||
//If there is no user
|
||||
if(userDB == null){
|
||||
//Scream
|
||||
|
|
@ -41,7 +47,7 @@ module.exports.post = async function(req, res){
|
|||
}
|
||||
|
||||
//Generate the password reset link
|
||||
const requestDB = await passwordResetModel.create({user: userDB._id, ipHash: req.ip});
|
||||
const requestDB = await passwordResetModel.create({user: userDB._id, ipHash: ip});
|
||||
|
||||
//send URL
|
||||
res.status(200);
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ module.exports.post = async function(req, res){
|
|||
const channel = await channelModel.findOne({name: data.chanName});
|
||||
|
||||
if(channel == null){
|
||||
throw new Error("Chanenl does not exist!");
|
||||
throw loggerUtils.exceptionSmith("Chanenl does not exist!", "validation");
|
||||
}
|
||||
|
||||
await channel.nuke(data.confirm);
|
||||
|
|
|
|||
90
src/controllers/api/channel/descriptionController.js
Normal file
90
src/controllers/api/channel/descriptionController.js
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
/*Canopy - The next generation of stoner streaming software
|
||||
Copyright (C) 2024-2025 Rainbownapkin and the TTN Community
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.*/
|
||||
|
||||
//NPM Imports
|
||||
const {validationResult, matchedData} = require('express-validator');
|
||||
|
||||
//local imports
|
||||
const channelModel = require('../../../schemas/channel/channelSchema');
|
||||
const {exceptionHandler} = require('../../../utils/loggerUtils');
|
||||
|
||||
//get thumby
|
||||
module.exports.get = async function(req, res){
|
||||
try{
|
||||
//Pull validated result
|
||||
const validResult = validationResult(req);
|
||||
|
||||
//if everything validated proper
|
||||
if(validResult.isEmpty()){
|
||||
//Get matched data
|
||||
const data = matchedData(req);
|
||||
//pull channel
|
||||
const chanDB = await channelModel.findOne({name: data.chanName});
|
||||
|
||||
//Null check channel
|
||||
if(chanDB == null){
|
||||
throw loggerUtils.exceptionSmith("Channel not found.", "validation");
|
||||
}
|
||||
|
||||
//return thumby
|
||||
res.status(200);
|
||||
return res.send({description: chanDB.description});
|
||||
}else{
|
||||
res.status(400);
|
||||
res.send({errors: validResult.array()})
|
||||
}
|
||||
}catch(err){
|
||||
exceptionHandler(res, err);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//Post function
|
||||
module.exports.post = async function(req, res){
|
||||
try{
|
||||
//Pull validated result
|
||||
const validResult = validationResult(req);
|
||||
|
||||
//if everything validated proper
|
||||
if(validResult.isEmpty()){
|
||||
//Get matched data
|
||||
const data = matchedData(req);
|
||||
//pull channel
|
||||
const chanDB = await channelModel.findOne({name: data.chanName});
|
||||
|
||||
//Null check channel
|
||||
if(chanDB == null){
|
||||
throw loggerUtils.exceptionSmith("Channel not found.", "validation");
|
||||
}
|
||||
|
||||
//Set thumbnail
|
||||
chanDB.description = data.description;
|
||||
|
||||
//Save channel doc
|
||||
await chanDB.save();
|
||||
|
||||
//return thumby
|
||||
res.status(200);
|
||||
return res.send({description: chanDB.description});
|
||||
}else{
|
||||
res.status(400);
|
||||
res.send({errors: validResult.array()})
|
||||
}
|
||||
}catch(err){
|
||||
exceptionHandler(res, err);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -33,7 +33,7 @@ module.exports.get = async function(req, res){
|
|||
|
||||
|
||||
if(channel == null){
|
||||
throw new Error("Channel not found.");
|
||||
throw loggerUtils.exceptionSmith("Channel not found.", "validation");
|
||||
}
|
||||
|
||||
res.status(200);
|
||||
|
|
@ -64,7 +64,7 @@ module.exports.post = async function(req, res){
|
|||
var permError = null;
|
||||
|
||||
if(chanDB == null){
|
||||
throw new Error("Channel not found.");
|
||||
throw loggerUtils.exceptionSmith("Channel not found.", "validation");
|
||||
}
|
||||
|
||||
//For each permission submitted
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ module.exports.get = async function(req, res){
|
|||
const channel = await channelModel.findOne({name: data.chanName});
|
||||
|
||||
if(channel == null){
|
||||
throw new Error("Channel not found.");
|
||||
throw loggerUtils.exceptionSmith("Channel not found.", "validation");
|
||||
}
|
||||
|
||||
res.status(200);
|
||||
|
|
@ -56,7 +56,7 @@ module.exports.post = async function(req, res){
|
|||
const settingsMap = new Map(Object.entries(data.settingsMap));
|
||||
|
||||
if(channel == null){
|
||||
throw new Error("Channel not found.");
|
||||
throw loggerUtils.exceptionSmith("Channel not found.", "validation");
|
||||
}
|
||||
|
||||
res.status(200);
|
||||
|
|
|
|||
90
src/controllers/api/channel/thumbnailController.js
Normal file
90
src/controllers/api/channel/thumbnailController.js
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
/*Canopy - The next generation of stoner streaming software
|
||||
Copyright (C) 2024-2025 Rainbownapkin and the TTN Community
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.*/
|
||||
|
||||
//NPM Imports
|
||||
const {validationResult, matchedData} = require('express-validator');
|
||||
|
||||
//local imports
|
||||
const channelModel = require('../../../schemas/channel/channelSchema');
|
||||
const {exceptionHandler} = require('../../../utils/loggerUtils');
|
||||
|
||||
//get thumby
|
||||
module.exports.get = async function(req, res){
|
||||
try{
|
||||
//Pull validated result
|
||||
const validResult = validationResult(req);
|
||||
|
||||
//if everything validated proper
|
||||
if(validResult.isEmpty()){
|
||||
//Get matched data
|
||||
const data = matchedData(req);
|
||||
//pull channel
|
||||
const chanDB = await channelModel.findOne({name: data.chanName});
|
||||
|
||||
//Null check channel
|
||||
if(chanDB == null){
|
||||
throw loggerUtils.exceptionSmith("Channel not found.", "validation");
|
||||
}
|
||||
|
||||
//return thumby
|
||||
res.status(200);
|
||||
return res.send({thumbnail: chanDB.thumbnail});
|
||||
}else{
|
||||
res.status(400);
|
||||
res.send({errors: validResult.array()})
|
||||
}
|
||||
}catch(err){
|
||||
exceptionHandler(res, err);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//Post function
|
||||
module.exports.post = async function(req, res){
|
||||
try{
|
||||
//Pull validated result
|
||||
const validResult = validationResult(req);
|
||||
|
||||
//if everything validated proper
|
||||
if(validResult.isEmpty()){
|
||||
//Get matched data
|
||||
const data = matchedData(req);
|
||||
//pull channel
|
||||
const chanDB = await channelModel.findOne({name: data.chanName});
|
||||
|
||||
//Null check channel
|
||||
if(chanDB == null){
|
||||
throw loggerUtils.exceptionSmith("Channel not found.", "validation");
|
||||
}
|
||||
|
||||
//Set thumbnail
|
||||
chanDB.thumbnail = data.thumbnail;
|
||||
|
||||
//Save channel doc
|
||||
await chanDB.save();
|
||||
|
||||
//return thumby
|
||||
res.status(200);
|
||||
return res.send({thumbnail: chanDB.thumbnail});
|
||||
}else{
|
||||
res.status(400);
|
||||
res.send({errors: validResult.array()})
|
||||
}
|
||||
}catch(err){
|
||||
exceptionHandler(res, err);
|
||||
}
|
||||
|
||||
}
|
||||
31
src/controllers/api/refreshCSRFTokenController.js
Normal file
31
src/controllers/api/refreshCSRFTokenController.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/>.*/
|
||||
|
||||
//local imports
|
||||
const {exceptionHandler, errorHandler} = require('../../utils/loggerUtils');
|
||||
const csrfUtils = require('../../utils/csrfUtils');
|
||||
|
||||
//api account functions
|
||||
module.exports.get = async function(req, res){
|
||||
try{
|
||||
//Set status to 200
|
||||
res.status(200);
|
||||
//Generate and send token based on the request
|
||||
res.send({token: csrfUtils.generateToken(req)});
|
||||
}catch(err){
|
||||
return exceptionHandler(res, err);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
@ -36,10 +39,10 @@ module.exports.get = async function(req, res){
|
|||
delete chanDB.permissions._doc._id;
|
||||
|
||||
if(chanDB == null){
|
||||
throw new Error("Channel not found.");
|
||||
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)});
|
||||
}
|
||||
|
|
@ -16,9 +16,5 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
|
|||
|
||||
//root index functions
|
||||
module.exports.get = async function(req, res){
|
||||
try{
|
||||
res.render(`partial/popup${req.url}`, {});
|
||||
}catch(err){
|
||||
return res.sendStatus(400);
|
||||
}
|
||||
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()})
|
||||
|
|
|
|||
20
src/controllers/panel/settingsController.js
Normal file
20
src/controllers/panel/settingsController.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/settings', {});
|
||||
}
|
||||
|
|
@ -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,7 +22,7 @@ const { Router } = require('express');
|
|||
//Models
|
||||
const permissionModel = require("../../schemas/permissionSchema");
|
||||
const channelModel = require("../../schemas/channel/channelSchema");
|
||||
//Valudators
|
||||
//Validators
|
||||
const channelValidator = require("../../validators/channelValidator");
|
||||
const accountValidator = require("../../validators/accountValidator");
|
||||
const {channelPermissionValidator} = require("../../validators/permissionsValidator");
|
||||
|
|
@ -30,6 +30,8 @@ const tokebotValidator = require("../../validators/tokebotValidator");
|
|||
const emoteValidator = require("../../validators/emoteValidator");
|
||||
//Controllers
|
||||
const registerController = require("../../controllers/api/channel/registerController");
|
||||
const thumbnailController = require("../../controllers/api/channel/thumbnailController");
|
||||
const descriptionController = require("../../controllers/api/channel/descriptionController");
|
||||
const listController = require("../../controllers/api/channel/listController");
|
||||
const settingsController = require("../../controllers/api/channel/settingsController");
|
||||
const permissionsController = require("../../controllers/api/channel/permissionsController")
|
||||
|
|
@ -42,8 +44,10 @@ const emoteController = require('../../controllers/api/channel/emoteController')
|
|||
//globals
|
||||
const router = Router();
|
||||
|
||||
//user authentication middleware
|
||||
//Set validator functions
|
||||
router.use("/register",permissionModel.reqPermCheck("registerChannel"));
|
||||
router.use("/thumbnail",channelValidator.name("chanName"));
|
||||
router.use("/description",channelValidator.name("chanName"));
|
||||
router.use("/settings", channelValidator.name('chanName'));
|
||||
router.use("/permissions", channelValidator.name('chanName'));
|
||||
router.use("/rank", channelValidator.name('chanName'));
|
||||
|
|
@ -55,6 +59,12 @@ router.use("/emote", channelValidator.name('chanName'));
|
|||
//routing functions
|
||||
//register
|
||||
router.post('/register', channelValidator.name(), channelValidator.description(), channelValidator.thumbnail(), registerController.post);
|
||||
//Thumbnail
|
||||
router.get('/thumbnail', thumbnailController.get);
|
||||
router.post('/thumbnail', channelValidator.thumbnail(), thumbnailController.post);
|
||||
//Description
|
||||
router.get('/description', descriptionController.get);
|
||||
router.post('/description', channelValidator.description(), descriptionController.post);
|
||||
//list
|
||||
router.get('/list', channelModel.reqPermCheck("manageChannel"), listController.get);
|
||||
//settings
|
||||
|
|
|
|||
|
|
@ -18,14 +18,19 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
|
|||
const { Router } = require('express');
|
||||
|
||||
//local imports
|
||||
const csrfUtil = require('../utils/csrfUtils');
|
||||
const accountRouter = require("./api/accountRouter");
|
||||
const channelRouter = require("./api/channelRouter");
|
||||
const adminRouter = require("./api/adminRouter");
|
||||
const csrfUtil = require('../utils/csrfUtils');
|
||||
const refreshCSRFTokenController = require("../controllers/api/refreshCSRFTokenController");
|
||||
|
||||
//globals
|
||||
const router = Router();
|
||||
|
||||
|
||||
//CSRF token request controller
|
||||
router.get('/refreshToken', refreshCSRFTokenController.get);
|
||||
|
||||
//Apply Cross-Site Request Forgery protection to API calls
|
||||
router.use(csrfUtil.csrfSynchronisedProtection);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -19,12 +19,12 @@ const { Router } = require('express');
|
|||
|
||||
|
||||
//local imports
|
||||
const popupController = require("../controllers/popupController");
|
||||
const migrateController = require("../controllers/migrateController");
|
||||
|
||||
//globals
|
||||
const router = Router();
|
||||
|
||||
//routing functions
|
||||
router.get('/*', popupController.get);
|
||||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ const emoteController = require("../controllers/panel/emoteController");
|
|||
const popoutContainerController = require("../controllers/panel/popoutContainerController");
|
||||
const profileController = require("../controllers/panel/profileController");
|
||||
const queueController = require("../controllers/panel/queueController");
|
||||
const settingsController = require("../controllers/panel/settingsController");
|
||||
const pmController = require("../controllers/panel/pmController");
|
||||
//Validators
|
||||
const accountValidator = require("../validators/accountValidator");
|
||||
|
||||
|
|
@ -36,5 +38,7 @@ router.get('/emote', emoteController.get);
|
|||
router.get('/popoutContainer', popoutContainerController.get);
|
||||
router.get('/profile', accountValidator.user(), profileController.get);
|
||||
router.get('/queue', queueController.get);
|
||||
router.get('/settings', settingsController.get);
|
||||
router.get('/pm', pmController.get);
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
|
|||
//NPM Imports
|
||||
const {mongoose} = require('mongoose');
|
||||
|
||||
/**
|
||||
* DB Schema for Documents representing a user ban from a single channel
|
||||
*/
|
||||
const channelBanSchema = new mongoose.Schema({
|
||||
user: {
|
||||
type: mongoose.SchemaTypes.ObjectID,
|
||||
|
|
@ -41,6 +44,10 @@ const channelBanSchema = new mongoose.Schema({
|
|||
});
|
||||
|
||||
//methods
|
||||
/**
|
||||
* Calculates days until ban expiration
|
||||
* @returns {Number} Days until the given ban expires
|
||||
*/
|
||||
channelBanSchema.methods.getDaysUntilExpiration = function(){
|
||||
//Get ban date
|
||||
const expirationDate = new Date(this.banDate);
|
||||
|
|
|
|||
|
|
@ -17,10 +17,17 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
|
|||
//NPM Imports
|
||||
const {mongoose} = require('mongoose');
|
||||
|
||||
//This originally belonged to the permissionSchema, but this avoids circular dependencies.
|
||||
/**
|
||||
* Rank Enum, lists all known permission ranks from lowest to highest.
|
||||
*
|
||||
* This originally belonged to the permissionSchema, but this avoids circular dependencies.
|
||||
*/
|
||||
const rankEnum = ["anon", "user", "gold", "bot", "mod", "admin"];
|
||||
|
||||
//Since this is intended to be used as a child schema for multiple parent schemas, we won't export it as a model
|
||||
/**
|
||||
* DB Schema for Sub-Document representing permission structure for a single channel
|
||||
*/
|
||||
const channelPermissionSchema = new mongoose.Schema({
|
||||
manageChannel: {
|
||||
type: mongoose.SchemaTypes.String,
|
||||
|
|
@ -82,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,
|
||||
|
|
|
|||
|
|
@ -31,9 +31,13 @@ const channelPermissionSchema = require('./channelPermissionSchema');
|
|||
const channelBanSchema = require('./channelBanSchema');
|
||||
const queuedMediaSchema = require('./media/queuedMediaSchema');
|
||||
const playlistSchema = require('./media/playlistSchema');
|
||||
const chatSchema = require('./chatSchema');
|
||||
//Utils
|
||||
const { exceptionHandler, errorHandler } = require('../../utils/loggerUtils');
|
||||
|
||||
/**
|
||||
* DB Schema for Documents containing de-hydrated representations of Canopy Stream/Chat Channels
|
||||
*/
|
||||
const channelSchema = new mongoose.Schema({
|
||||
id: {
|
||||
type: mongoose.SchemaTypes.Number,
|
||||
|
|
@ -42,19 +46,21 @@ const channelSchema = new mongoose.Schema({
|
|||
name: {
|
||||
type: mongoose.SchemaTypes.String,
|
||||
required: true,
|
||||
maxLength: 50,
|
||||
//Calculate max length by the validator max length and the size of an escaped character
|
||||
maxLength: 50 * 6,
|
||||
default: 0
|
||||
},
|
||||
description: {
|
||||
type: mongoose.SchemaTypes.String,
|
||||
required: true,
|
||||
maxLength: 1000,
|
||||
//Calculate max length by the validator max length and the size of an escaped character
|
||||
maxLength: 1000 * 6,
|
||||
default: 0
|
||||
},
|
||||
thumbnail: {
|
||||
type: mongoose.SchemaTypes.String,
|
||||
required: true,
|
||||
default: "/img/johnny.png"
|
||||
default: "/nonfree/johnny.png"
|
||||
},
|
||||
settings: {
|
||||
hidden: {
|
||||
|
|
@ -62,6 +68,10 @@ const channelSchema = new mongoose.Schema({
|
|||
required: true,
|
||||
default: true
|
||||
},
|
||||
streamURL: {
|
||||
type: mongoose.SchemaTypes.String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
permissions: {
|
||||
type: channelPermissionSchema,
|
||||
|
|
@ -105,20 +115,29 @@ const channelSchema = new mongoose.Schema({
|
|||
scheduled: [queuedMediaSchema],
|
||||
//We should consider moving archived media and channel playlists to their own collections/models for preformances sake
|
||||
archived: [queuedMediaSchema],
|
||||
playlists: [playlistSchema]
|
||||
playlists: [playlistSchema],
|
||||
liveRemainder: {
|
||||
type: mongoose.SchemaTypes.UUID,
|
||||
required: false
|
||||
}
|
||||
},
|
||||
//Thankfully we don't have to keep track of alts, ips, or deleted users so this should be a lot easier than site-wide bans :P
|
||||
banList: [channelBanSchema]
|
||||
banList: [channelBanSchema],
|
||||
chatBuffer: [chatSchema]
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Channel pre-save function. Ensures name requirements (for some reason, we should move that to the schema probably), kicks users after rank change, and handles housekeeping after adding tokes/emotes
|
||||
*/
|
||||
channelSchema.pre('save', async function (next){
|
||||
if(this.isModified("name")){
|
||||
if(this.name.match(/^[a-z0-9_\-.]+$/i) == null){
|
||||
throw new Error("Username must only contain alpha-numerics and the following symbols: '-_.'");
|
||||
throw loggerUtils.exceptionSmith("Username must only contain alpha-numerics and the following symbols: '-_.'", "validation");
|
||||
}
|
||||
}
|
||||
|
||||
//This entire block is just about finding users after rank-change and making sure they get kicked
|
||||
//Getting the affected user would be a million times easier elsewhere
|
||||
//But this ensures it happens every time channel rank gets changed no matter what
|
||||
if(this.isModified('rankList') && this.rankList != null){
|
||||
|
|
@ -215,13 +234,18 @@ channelSchema.pre('save', async function (next){
|
|||
});
|
||||
|
||||
//statics
|
||||
/**
|
||||
* Registers a new channel to the DB
|
||||
* @param {Object} channelObj - Channel Object from Browser to register
|
||||
* @param {Mongoose.Document} ownerObj - DB Docuement representing user
|
||||
*/
|
||||
channelSchema.statics.register = async function(channelObj, ownerObj){
|
||||
const {name, description, thumbnail} = channelObj;
|
||||
|
||||
const chanDB = await this.findOne({ name });
|
||||
|
||||
if(chanDB){
|
||||
throw new Error("Channel name already taken!");
|
||||
throw loggerUtils.exceptionSmith("Channel name already taken!", "validation");
|
||||
}else{
|
||||
const id = await statModel.incrementChannelCount();
|
||||
const rankList = [{
|
||||
|
|
@ -229,10 +253,28 @@ channelSchema.statics.register = async function(channelObj, ownerObj){
|
|||
rank: "admin"
|
||||
}];
|
||||
|
||||
const newChannel = await this.create((thumbnail ? {id, name, description, thumbnail, rankList} : {id, name, description, rankList}));
|
||||
const newChannelObj = {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
thumbnail,
|
||||
rankList,
|
||||
media: {
|
||||
nowPlaying: null,
|
||||
scheduledMedia: [],
|
||||
archived: []
|
||||
}
|
||||
};
|
||||
|
||||
const newChannel = await this.create(newChannelObj);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates Network-Friendly Browser-Digestable list of channels
|
||||
* @param {Boolean} includeHidden - Whether or not to include hidden channels within the list
|
||||
* @returns {Array} List of Network-Friendly Browser-Digestable Objects representing channels on the server
|
||||
*/
|
||||
channelSchema.statics.getChannelList = async function(includeHidden = false){
|
||||
const chanDB = await this.find({});
|
||||
var chanGuide = [];
|
||||
|
|
@ -255,9 +297,16 @@ channelSchema.statics.getChannelList = async function(includeHidden = false){
|
|||
}
|
||||
|
||||
//Middleware for rank checks
|
||||
//Man, it would be really nice if express middleware actually supported async functions, you know, as if it where't still 2015 >:(
|
||||
//Also holy shit, sharing a function between two middleware functions is a nightmare
|
||||
//I'd rather just have this check chanField for '/c/' to handle channels in URL, fuck me this was obnoxious to write
|
||||
/**
|
||||
* Configurable Express Middleware for Per-Channel Endpoint Authorization
|
||||
*
|
||||
* Man, it would be really nice if express middleware actually supported async functions, you know, as if it where't still 2015 >:(
|
||||
* Also holy shit, sharing a function between two middleware functions is a nightmare
|
||||
* I'd rather just have this check chanField for '/c/' to handle channels in URL, fuck me this was obnoxious to write
|
||||
* @param {String} - Permission to check against
|
||||
* @param {String} - Name of channel to authorize against
|
||||
* @returns {Function} Express middleware function with arguments injected into logic
|
||||
*/
|
||||
channelSchema.statics.reqPermCheck = function(perm, chanField = "chanName"){
|
||||
return (req, res, next)=>{
|
||||
try{
|
||||
|
|
@ -302,26 +351,41 @@ channelSchema.statics.reqPermCheck = function(perm, chanField = "chanName"){
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedulable Function for Processing and Deleting Expired Channel-level User Bans
|
||||
*/
|
||||
channelSchema.statics.processExpiredBans = async function(){
|
||||
const chanDB = await this.find({});
|
||||
|
||||
chanDB.forEach((channel) => {
|
||||
channel.banList.forEach(async (ban, i) => {
|
||||
for(let chanIndex in chanDB){
|
||||
//Pull channel from channels by index
|
||||
const channel = chanDB[chanIndex];
|
||||
|
||||
//channel.banList.forEach(async (ban, banIndex) => {
|
||||
for(let banIndex in channel.banList){
|
||||
//Pull ban from channel ban list
|
||||
const ban = channel.banList[banIndex];
|
||||
|
||||
//ignore permanent and non-expired bans
|
||||
if(ban.expirationDays >= 0 && ban.getDaysUntilExpiration() <= 0){
|
||||
//Get the index of the ban
|
||||
channel.banList.splice(i,1);
|
||||
return await channel.save();
|
||||
channel.banList.splice(banIndex,1);
|
||||
await channel.save();
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//methods
|
||||
/**
|
||||
* Updates settings map for a given channel document
|
||||
* @param {Map} settingsMap - Map of settings updates to apply against channel document
|
||||
* @returns {Map} Map of all channel settings
|
||||
*/
|
||||
channelSchema.methods.updateSettings = async function(settingsMap){
|
||||
settingsMap.forEach((value, key) => {
|
||||
if(this.settings[key] == null){
|
||||
throw new Error("Invalid channel setting.");
|
||||
throw loggerUtils.exceptionSmith("Invalid channel setting.", "validation");
|
||||
}
|
||||
|
||||
this.settings[key] = value;
|
||||
|
|
@ -332,6 +396,11 @@ channelSchema.methods.updateSettings = async function(settingsMap){
|
|||
return this.settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crawls through channel rank and runs a callback against the requested user's rank sub-doc
|
||||
* @param {Mongoose.Document} userDB - User DB Document to run the callback against
|
||||
* @param {Function} cb - Callback Function to call against the given users rank sub-doc
|
||||
*/
|
||||
channelSchema.methods.rankCrawl = async function(userDB,cb){
|
||||
//Crawl through channel rank list
|
||||
//TODO: replace this with rank check function shared with setRank
|
||||
|
|
@ -344,6 +413,12 @@ channelSchema.methods.rankCrawl = async function(userDB,cb){
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets users rank by User Doc
|
||||
* @param {Mongoose.Document} userDB - DB Document of user's channel rank to change
|
||||
* @param {String} rank - Channel rank to set user to
|
||||
* @returns {Array} Channel Rank List
|
||||
*/
|
||||
channelSchema.methods.setRank = async function(userDB,rank){
|
||||
//Create variable to store found ranks
|
||||
var foundRankIndex = null;
|
||||
|
|
@ -377,6 +452,10 @@ channelSchema.methods.setRank = async function(userDB,rank){
|
|||
return this.rankList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates Network-Friendly Browser-Digestable channel rank list
|
||||
* @returns {Array} Network-Friendly Browser-Digestable channel rank list
|
||||
*/
|
||||
channelSchema.methods.getRankList = async function(){
|
||||
//Create an empty array to hold the user list
|
||||
const rankList = new Map()
|
||||
|
|
@ -425,6 +504,11 @@ channelSchema.methods.getRankList = async function(){
|
|||
return rankList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets channel rank by user document
|
||||
* @param {Mongoose.Document} userDB - DB Document of User to pull Channel Rank of
|
||||
* @returns {String} Channel rank of requested user
|
||||
*/
|
||||
channelSchema.methods.getChannelRankByUserDoc = async function(userDB = null){
|
||||
var foundRank = null;
|
||||
|
||||
|
|
@ -451,11 +535,22 @@ channelSchema.methods.getChannelRankByUserDoc = async function(userDB = null){
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets channel rank by username
|
||||
* @param {String} user - Username of user to pull channel rank of
|
||||
* @returns {String} Channel rank of requested user
|
||||
*/
|
||||
channelSchema.methods.getChannelRank = async function(user){
|
||||
const userDB = await userModel.findOne({user: user.user});
|
||||
return await this.getChannelRankByUserDoc(userDB);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates a permission check against a specific channel permission for a given user by username
|
||||
* @param {String} user - Username of user to check against
|
||||
* @param {String} perm - Name of channel Permission to check against
|
||||
* @returns {Boolean} Whether or not the given user passes the given channel perm check
|
||||
*/
|
||||
channelSchema.methods.permCheck = async function (user, perm){
|
||||
//Set userDB to null if we wheren't passed a real user
|
||||
if(user != null){
|
||||
|
|
@ -467,6 +562,12 @@ channelSchema.methods.permCheck = async function (user, perm){
|
|||
return await this.permCheckByUserDoc(userDB, perm)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates a permission check against a specific channel permission for a given user by DB Document
|
||||
* @param {Mongoose.Document} userDB - DB Document of user to check against
|
||||
* @param {String} perm - Name of channel Permission to check against
|
||||
* @returns {Boolean} Whether or not the given user passes the given channel perm check
|
||||
*/
|
||||
channelSchema.methods.permCheckByUserDoc = async function(userDB, perm){
|
||||
//Get site-wide rank as number, default to anon for anonymous users
|
||||
const rank = userDB ? permissionModel.rankToNum(userDB.rank) : permissionModel.rankToNum("anon");
|
||||
|
|
@ -484,6 +585,11 @@ channelSchema.methods.permCheckByUserDoc = async function(userDB, perm){
|
|||
return (permCheck || overrideCheck);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates channel-wide permission map for a given user by user doc
|
||||
* @param {Mongoose.Document} userDB - DB Document representing a single user account
|
||||
* @returns {Object} Object containing two maps, one for channel perms, another for site-wide perms
|
||||
*/
|
||||
channelSchema.methods.getPermMapByUserDoc = async function(userDB){
|
||||
//Grap site-wide permissions
|
||||
const sitePerms = await permissionModel.getPerms();
|
||||
|
|
@ -505,6 +611,11 @@ channelSchema.methods.getPermMapByUserDoc = async function(userDB){
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a specific user has been issued a channel-specific ban by DB doc
|
||||
* @param {Mongoose.Document} userDB - DB Document representing a single user account
|
||||
* @returns {Object} Found ban, if one exists
|
||||
*/
|
||||
channelSchema.methods.checkBanByUserDoc = async function(userDB){
|
||||
var foundBan = null;
|
||||
|
||||
|
|
@ -533,6 +644,10 @@ channelSchema.methods.checkBanByUserDoc = async function(userDB){
|
|||
return foundBan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates Network-Friendly Browser-Digestable list of channel emotes
|
||||
* @returns {Array} Network-Friendly Browser-Digestable list of channel emotes
|
||||
*/
|
||||
channelSchema.methods.getEmotes = function(){
|
||||
//Create an empty array to hold our emote list
|
||||
const emoteList = [];
|
||||
|
|
@ -551,6 +666,10 @@ channelSchema.methods.getEmotes = function(){
|
|||
return emoteList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates Network-Friendly Browser-Digestable list of channel playlists
|
||||
* @returns {Array} Network-Friendly Browser-Digestable list of channel playlists
|
||||
*/
|
||||
channelSchema.methods.getPlaylists = function(){
|
||||
//Create an empty array to hold our emote list
|
||||
const playlists = [];
|
||||
|
|
@ -565,6 +684,10 @@ channelSchema.methods.getPlaylists = function(){
|
|||
return playlists;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crawls through channel playlists, running a given callback function against each one
|
||||
* @param {Function} cb - Callback function to run against channel playlists
|
||||
*/
|
||||
channelSchema.methods.playlistCrawl = function(cb){
|
||||
for(let listIndex in this.media.playlists){
|
||||
//Grab the associated playlist
|
||||
|
|
@ -575,6 +698,11 @@ channelSchema.methods.playlistCrawl = function(cb){
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds channel playlist by playlist name
|
||||
* @param {String} name - name of given playlist to find
|
||||
* @returns {Mongoose.Document} - Sub-Document representing a single playlist
|
||||
*/
|
||||
channelSchema.methods.getPlaylistByName = function(name){
|
||||
//Create null value to hold our found playlist
|
||||
let foundPlaylist = null;
|
||||
|
|
@ -594,6 +722,10 @@ channelSchema.methods.getPlaylistByName = function(name){
|
|||
return foundPlaylist;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes channel playlist by playlist name
|
||||
* @param {String} name - name of given playlist to Delete
|
||||
*/
|
||||
channelSchema.methods.deletePlaylistByName = async function(name){
|
||||
//Find the playlist
|
||||
let playlist = this.getPlaylistByName(name);
|
||||
|
|
@ -605,6 +737,10 @@ channelSchema.methods.deletePlaylistByName = async function(name){
|
|||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates Network-Friendly Browser-Digestable list of Channel-Wide user bans
|
||||
* @returns {Array} Network-Friendly Browser-Digestable list of Channel-Wide user bans
|
||||
*/
|
||||
channelSchema.methods.getChanBans = async function(){
|
||||
//Create an empty list to hold our found bans
|
||||
var banList = [];
|
||||
|
|
@ -645,16 +781,22 @@ channelSchema.methods.getChanBans = async function(){
|
|||
return banList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Issues channel-wide ban to user based on user DB document
|
||||
* @param {Mongoose.Document} userDB - DB Document representing a single user account to ban
|
||||
* @param {Number} expirationDays - Days until ban expiration
|
||||
* @param {Boolean} banAlts - Whether or not to ban alts
|
||||
*/
|
||||
channelSchema.methods.banByUserDoc = async function(userDB, expirationDays, banAlts){
|
||||
//Throw a shitfit if the user doesn't exist
|
||||
if(userDB == null){
|
||||
throw new Error("Cannot ban non-existant user!");
|
||||
throw loggerUtils.exceptionSmith("Cannot ban non-existant user!", "validation");
|
||||
}
|
||||
|
||||
const foundBan = await this.checkBanByUserDoc(userDB);
|
||||
|
||||
if(foundBan != null){
|
||||
throw new Error("User already banned!");
|
||||
throw loggerUtils.exceptionSmith("User already banned!", "validation");
|
||||
}
|
||||
|
||||
//Create a new ban document based on input
|
||||
|
|
@ -682,21 +824,33 @@ channelSchema.methods.banByUserDoc = async function(userDB, expirationDays, banA
|
|||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Syntatic sugar for banning users by username
|
||||
* @param {String} user - Username of user to ban
|
||||
* @param {Number} expirationDays - Days until ban expiration
|
||||
* @param {Boolean} banAlts - Whether or not to ban alts
|
||||
* @returns {Promise} promise from this.banByUserDoc
|
||||
*/
|
||||
channelSchema.methods.ban = async function(user, expirationDays, banAlts){
|
||||
const userDB = await userModel.find({user});
|
||||
return await this.banByUserDoc(userDB, expirationDays, banAlts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Un-Bans user by DB Document
|
||||
* @param {Mongoose.Document} userDB - DB Document representing a single user account to un-ban
|
||||
* @returns {Mongoose.Document} Saved channel document
|
||||
*/
|
||||
channelSchema.methods.unbanByUserDoc = async function(userDB){
|
||||
//Throw a shitfit if the user doesn't exist
|
||||
if(userDB == null){
|
||||
throw new Error("Cannot ban non-existant user!");
|
||||
throw loggerUtils.exceptionSmith("Cannot ban non-existant user!", "validation");
|
||||
}
|
||||
|
||||
const foundBan = await this.checkBanByUserDoc(userDB);
|
||||
|
||||
if(foundBan == null){
|
||||
throw new Error("User already unbanned!");
|
||||
throw loggerUtils.exceptionSmith("User already unbanned!", "validation");
|
||||
}
|
||||
|
||||
//You know I can't help but feel like an asshole for looking for the index of something I just pulled out of an array using forEach...
|
||||
|
|
@ -707,23 +861,32 @@ channelSchema.methods.unbanByUserDoc = async function(userDB){
|
|||
return await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Syntatic sugar for un-banning by username
|
||||
* @param {String} user - Username of user to un-ban
|
||||
* @returns {Mongoose.Document} Saved channel document
|
||||
*/
|
||||
channelSchema.methods.unban = async function(user){
|
||||
const userDB = await userModel.find({user});
|
||||
return await this.unbanByUserDoc(userDB);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nukes channel upon channel-admin request
|
||||
* @param {String} confirm - Channel name to confirm deletion of channel
|
||||
*/
|
||||
channelSchema.methods.nuke = async function(confirm){
|
||||
if(confirm == "" || confirm == null){
|
||||
throw new Error("Empty Confirmation String!");
|
||||
throw loggerUtils.exceptionSmith("Empty Confirmation String!", "validation");
|
||||
}else if(confirm != this.name){
|
||||
throw new Error("Bad Confirmation String!");
|
||||
throw loggerUtils.exceptionSmith("Bad Confirmation String!", "validation");
|
||||
}
|
||||
|
||||
//Annoyingly there isnt a good way to do this from 'this'
|
||||
var oldChan = await this.deleteOne();
|
||||
|
||||
if(oldChan.deletedCount == 0){
|
||||
throw new Error("Server Error: Unable to delete channel! Please report this error to your server administrator, and with timestamp.");
|
||||
throw loggerUtils.exceptionSmith("Server Error: Unable to delete channel! Please report this error to your server administrator, and with timestamp.", "internal");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
56
src/schemas/channel/chatSchema.js
Normal file
56
src/schemas/channel/chatSchema.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/*Canopy - The next generation of stoner streaming software
|
||||
Copyright (C) 2024-2025 Rainbownapkin and the TTN Community
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.*/
|
||||
|
||||
//NPM Imports
|
||||
const {mongoose} = require('mongoose');
|
||||
|
||||
const linkSchema = new mongoose.Schema({
|
||||
link: mongoose.SchemaTypes.String,
|
||||
type: mongoose.SchemaTypes.String
|
||||
});
|
||||
|
||||
/**
|
||||
* DB Schema for documents representing a single chat message
|
||||
*/
|
||||
const chatSchema = new mongoose.Schema({
|
||||
user: {
|
||||
type: mongoose.SchemaTypes.String,
|
||||
required: true,
|
||||
},
|
||||
flair: {
|
||||
type: mongoose.SchemaTypes.String,
|
||||
//Leave this as unreq'd for internal type chats that have no flair
|
||||
},
|
||||
highLevel: {
|
||||
type: mongoose.SchemaTypes.Number,
|
||||
default: 0,
|
||||
required: true,
|
||||
},
|
||||
msg: {
|
||||
type: mongoose.SchemaTypes.String,
|
||||
required: true,
|
||||
},
|
||||
type: {
|
||||
type: mongoose.SchemaTypes.String,
|
||||
required: true,
|
||||
},
|
||||
links: {
|
||||
type: [linkSchema],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
module.exports = chatSchema;
|
||||
|
|
@ -17,6 +17,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
|
|||
//NPM Imports
|
||||
const {mongoose} = require('mongoose');
|
||||
|
||||
/**
|
||||
* DB Schema representing a single piece of media
|
||||
*/
|
||||
const mediaSchema = new mongoose.Schema({
|
||||
title: {
|
||||
type: mongoose.SchemaTypes.String,
|
||||
|
|
|
|||
|
|
@ -21,11 +21,13 @@ const {mongoose} = require('mongoose');
|
|||
const mediaSchema = require('./mediaSchema');
|
||||
const media = require('../../../app/channel/media/media');
|
||||
|
||||
/**
|
||||
* DB Schema for documents represnting a piece of media held in a playlist
|
||||
*/
|
||||
const playlistMediaProperties = new mongoose.Schema({
|
||||
uuid: {
|
||||
type: mongoose.SchemaTypes.UUID,
|
||||
required:true,
|
||||
unique: true,
|
||||
default: crypto.randomUUID()
|
||||
}
|
||||
},
|
||||
|
|
@ -34,6 +36,9 @@ const playlistMediaProperties = new mongoose.Schema({
|
|||
});
|
||||
|
||||
//Schema Middleware
|
||||
/**
|
||||
* Pre-save function for playlist meda, ensures unique UUID
|
||||
*/
|
||||
playlistMediaProperties.pre('save', async function (next){
|
||||
//If the UUID was modified in anyway
|
||||
if(this.isModified("uuid")){
|
||||
|
|
@ -46,7 +51,10 @@ playlistMediaProperties.pre('save', async function (next){
|
|||
});
|
||||
|
||||
//methods
|
||||
//Rehydrate to a full phat media object
|
||||
/**
|
||||
* Rehydrate to a full phat media object
|
||||
* @returns {media} A full phat media object, re-hydrated from the DB
|
||||
*/
|
||||
playlistMediaProperties.methods.rehydrate = function(){
|
||||
//Return item as a full phat, standard media object
|
||||
return new media(
|
||||
|
|
@ -59,7 +67,10 @@ playlistMediaProperties.methods.rehydrate = function(){
|
|||
);
|
||||
}
|
||||
|
||||
//Dehydrate to minified flat network-friendly object
|
||||
/**
|
||||
* Dehydrate to minified flat network-friendly object
|
||||
* @returns {Object} Network-Friendly Browser-Digestable object representing media from a playlist
|
||||
*/
|
||||
playlistMediaProperties.methods.dehydrate = function(){
|
||||
return {
|
||||
title: this.title,
|
||||
|
|
|
|||
|
|
@ -20,11 +20,13 @@ const {mongoose} = require('mongoose');
|
|||
//Local Imports
|
||||
const playlistMediaSchema = require('./playlistMediaSchema');
|
||||
|
||||
/**
|
||||
* DB Schema for Documents representing playlists full of media
|
||||
*/
|
||||
const playlistSchema = new mongoose.Schema({
|
||||
name: {
|
||||
type: mongoose.SchemaTypes.String,
|
||||
required: true,
|
||||
unique: true
|
||||
},
|
||||
media: [playlistMediaSchema],
|
||||
defaultTitles:[{
|
||||
|
|
@ -35,6 +37,10 @@ const playlistSchema = new mongoose.Schema({
|
|||
});
|
||||
|
||||
//methods
|
||||
/**
|
||||
* Dehydrate to minified flat network-friendly object
|
||||
* @returns {Object} Network-Friendly Browser-Digestable object representing media from a playlist
|
||||
*/
|
||||
playlistSchema.methods.dehydrate = function(){
|
||||
//Create empty array to hold media
|
||||
const mediaArray = [];
|
||||
|
|
@ -52,6 +58,10 @@ playlistSchema.methods.dehydrate = function(){
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add media to the given playlist Document
|
||||
* @param {Array} mediaList - Array of media Objects to add to playlist
|
||||
*/
|
||||
playlistSchema.methods.addMedia = function(mediaList){
|
||||
//For every piece of media in the list
|
||||
for(let media of mediaList){
|
||||
|
|
@ -63,6 +73,11 @@ playlistSchema.methods.addMedia = function(mediaList){
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets Media from a playlist by UUID
|
||||
* @param {String} uuid - UUID of media to pull
|
||||
* @returns {media} media with matching UUID
|
||||
*/
|
||||
playlistSchema.methods.findMediaByUUID = function(uuid){
|
||||
//For every piece of media in the current playlist
|
||||
for(let media of this.media){
|
||||
|
|
@ -74,6 +89,10 @@ playlistSchema.methods.findMediaByUUID = function(uuid){
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes media from a given playlist
|
||||
* @param {String} uuid - UUID of media to delete
|
||||
*/
|
||||
playlistSchema.methods.deleteMedia = function(uuid){
|
||||
//Create new array to hold list of media to be kept
|
||||
const keptMedia = [];
|
||||
|
|
@ -91,6 +110,11 @@ playlistSchema.methods.deleteMedia = function(uuid){
|
|||
this.media = keptMedia;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick title based on default title's list and media's given title
|
||||
* @param {String} title - Title to use if there are no default titles.
|
||||
* @returns {String} Chosen title based on result of function
|
||||
*/
|
||||
playlistSchema.methods.pickDefaultTitle = function(title){
|
||||
//If we don't have default titles in this playlist
|
||||
if(this.defaultTitles.length <= 0){
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ const {mongoose} = require('mongoose');
|
|||
const mediaSchema = require('./mediaSchema');
|
||||
const queuedMedia = require('../../../app/channel/media/queuedMedia');
|
||||
|
||||
/**
|
||||
* DB Schema for documents representing a queued media object
|
||||
*/
|
||||
const queuedProperties = new mongoose.Schema({
|
||||
startTime: {
|
||||
type: mongoose.SchemaTypes.Number,
|
||||
|
|
@ -37,7 +40,6 @@ const queuedProperties = new mongoose.Schema({
|
|||
uuid: {
|
||||
type: mongoose.SchemaTypes.UUID,
|
||||
required: true,
|
||||
unique: true
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -45,7 +47,10 @@ const queuedProperties = new mongoose.Schema({
|
|||
});
|
||||
|
||||
//Methods
|
||||
//Rehydrate to a full phat queued media object
|
||||
/**
|
||||
* Rehydrate to a full phat queued media object
|
||||
* @returns {queuedMedia} A full phat queued media object, re-hydrated from the DB
|
||||
*/
|
||||
queuedProperties.methods.rehydrate = function(){
|
||||
return new queuedMedia(
|
||||
this.title,
|
||||
|
|
@ -54,6 +59,8 @@ queuedProperties.methods.rehydrate = function(){
|
|||
this.id,
|
||||
this.type,
|
||||
this.duration,
|
||||
//We don't save raw links that are stored seperate from the standard URL as they tend to expire.
|
||||
undefined,
|
||||
this.startTime,
|
||||
this.startTimeStamp,
|
||||
this.earlyEnd,
|
||||
|
|
|
|||
|
|
@ -21,12 +21,19 @@ const {mongoose} = require('mongoose');
|
|||
const defaultEmote = require("../../defaultEmotes.json");
|
||||
const server = require('../server');
|
||||
|
||||
/**
|
||||
* "Enum" for emote type property
|
||||
*/
|
||||
const typeEnum = ["image", "video"];
|
||||
|
||||
/**
|
||||
* DB Schema for documents represnting site-wide emotes
|
||||
*/
|
||||
const emoteSchema = new mongoose.Schema({
|
||||
name:{
|
||||
type: mongoose.SchemaTypes.String,
|
||||
required: true
|
||||
required: true,
|
||||
maxLength: 14,
|
||||
},
|
||||
link:{
|
||||
type: mongoose.SchemaTypes.String,
|
||||
|
|
@ -40,19 +47,29 @@ const emoteSchema = new mongoose.Schema({
|
|||
}
|
||||
});
|
||||
|
||||
//post-save function
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
});
|
||||
|
||||
//post-delete function (document not query)
|
||||
/**
|
||||
* Post-Delete function, ensures all deleted emotes are removed from actively connected clients
|
||||
*/
|
||||
emoteSchema.post('deleteOne', {document: true}, async function (next){
|
||||
//broadcast updated emotes
|
||||
server.channelManager.broadcastSiteEmotes();
|
||||
});
|
||||
|
||||
//statics
|
||||
/**
|
||||
* Loads un-loaded emotes from defaultEmotes.json
|
||||
*/
|
||||
emoteSchema.statics.loadDefaults = async function(){
|
||||
//Make sure registerEmote function is happy
|
||||
const _this = this;
|
||||
|
|
@ -83,6 +100,10 @@ emoteSchema.statics.loadDefaults = async function(){
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a network-friendly browser-digestable list of emotes
|
||||
* @returns {Object} - network-friendly browser-digestable list of emotes
|
||||
*/
|
||||
emoteSchema.statics.getEmotes = async function(){
|
||||
//Create an empty array to hold our emote list
|
||||
const emoteList = [];
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ const {mongoose} = require('mongoose');
|
|||
const permissionModel = require("./permissionSchema");
|
||||
const defaultFlair = require("../../defaultFlair.json");
|
||||
|
||||
/**
|
||||
* DB Schema for documents representing chat flair
|
||||
*/
|
||||
const flairSchema = new mongoose.Schema({
|
||||
name:{
|
||||
type: mongoose.SchemaTypes.String,
|
||||
|
|
@ -38,6 +41,9 @@ const flairSchema = new mongoose.Schema({
|
|||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Function which runs on server startup to load un-loaded flairs from defaultFlair.json into the DB
|
||||
*/
|
||||
flairSchema.statics.loadDefaults = async function(){
|
||||
//Make sure registerFlair function is happy
|
||||
const _this = this;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ const {errorHandler} = require('../utils/loggerUtils');
|
|||
//We could update all references but quite honestly I that would be uglier, this should have a copy too...
|
||||
const rankEnum = channelPermissionSchema.statics.rankEnum;
|
||||
|
||||
/**
|
||||
* DB Schema for the singular site-wide permission document
|
||||
*/
|
||||
const permissionSchema = new mongoose.Schema({
|
||||
adminPanel: {
|
||||
type: mongoose.SchemaTypes.String,
|
||||
|
|
@ -51,6 +54,12 @@ const permissionSchema = new mongoose.Schema({
|
|||
default: "admin",
|
||||
required: true
|
||||
},
|
||||
resetToke: {
|
||||
type: mongoose.SchemaTypes.String,
|
||||
enum: rankEnum,
|
||||
default: "admin",
|
||||
required: true
|
||||
},
|
||||
editTokeCommands: {
|
||||
type: mongoose.SchemaTypes.String,
|
||||
enum: rankEnum,
|
||||
|
|
@ -91,11 +100,21 @@ const permissionSchema = new mongoose.Schema({
|
|||
type: channelPermissionSchema,
|
||||
default: () => ({})
|
||||
},
|
||||
debug: {
|
||||
type: mongoose.SchemaTypes.String,
|
||||
enum: rankEnum,
|
||||
default: "admin",
|
||||
required: true
|
||||
},
|
||||
});
|
||||
|
||||
//Statics
|
||||
permissionSchema.statics.rankEnum = rankEnum;
|
||||
|
||||
/**
|
||||
* Returns the server's singular permission document from the DB
|
||||
* @returns {Mongoose.Document} - The server's singular permission document
|
||||
*/
|
||||
permissionSchema.statics.getPerms = async function(){
|
||||
//Not sure why 'this' didn't work from here when calling this, I'm assuming it's because I'm doing it from middleware
|
||||
//which is probably binding shit to this function, either way this works :P
|
||||
|
|
@ -120,10 +139,21 @@ permissionSchema.statics.getPerms = async function(){
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts rank name to number
|
||||
* @param {String} rank - rank to check
|
||||
* @returns {Number} Rank level
|
||||
*/
|
||||
permissionSchema.statics.rankToNum = function(rank){
|
||||
return rankEnum.indexOf(rank);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check users rank against a given permission by username
|
||||
* @param {String} user - Username of the user to check against a perm
|
||||
* @param {String} perm - Permission to check user against
|
||||
* @returns {Boolean} Whether or not the user is authorized for the permission in question
|
||||
*/
|
||||
permissionSchema.statics.permCheck = async function(user, perm){
|
||||
//Check if the user is null
|
||||
if(user != null){
|
||||
|
|
@ -136,6 +166,12 @@ permissionSchema.statics.permCheck = async function(user, perm){
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Syntatic sugar for perms.CheckByUserDoc so we don't have to get the document ourselves
|
||||
* @param {Mongoose.Document} user - User document to check perms against
|
||||
* @param {String} perm - Permission to check user against
|
||||
* @returns {Boolean} Whether or not the user is authorized for the permission in question
|
||||
*/
|
||||
permissionSchema.statics.permCheckByUserDoc = async function(user, perm){
|
||||
//Get permission list
|
||||
const perms = await this.getPerms();
|
||||
|
|
@ -143,6 +179,12 @@ permissionSchema.statics.permCheckByUserDoc = async function(user, perm){
|
|||
return perms.permCheckByUserDoc(user, perm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check users rank by a given permission by username
|
||||
* @param {String} user - Username of the user to check against a perm
|
||||
* @param {String} perm - Permission to check user against
|
||||
* @returns {Boolean} Whether or not the user is authorized for the permission in question
|
||||
*/
|
||||
permissionSchema.statics.overrideCheck = async function(user, perm){
|
||||
//Check if the user is null
|
||||
if(user != null){
|
||||
|
|
@ -155,6 +197,13 @@ permissionSchema.statics.overrideCheck = async function(user, perm){
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Syntatic sugar for perms.overrideCheckByUSerDoc so we don't have to seperately get the perm doc
|
||||
* Checks channel perm override against a given user by username
|
||||
* @param {String} user - Username of the user to check against a perm
|
||||
* @param {String} perm - Permission to check user against
|
||||
* @returns {Boolean} Whether or not the user is authorized for the permission in question
|
||||
*/
|
||||
permissionSchema.statics.overrideCheckByUserDoc = async function(user, perm){
|
||||
//Get permission list
|
||||
const perms = await this.getPerms();
|
||||
|
|
@ -163,6 +212,11 @@ permissionSchema.statics.overrideCheckByUserDoc = async function(user, perm){
|
|||
}
|
||||
|
||||
//Middleware for rank checks
|
||||
/**
|
||||
* Configurable express middleware which checks user's request against a given permission
|
||||
* @param {String} perm - Permission to check
|
||||
* @returns {Function} Express middlewhere function with given permission injected into it
|
||||
*/
|
||||
permissionSchema.statics.reqPermCheck = function(perm){
|
||||
return (req, res, next)=>{
|
||||
permissionSchema.statics.permCheck(req.session.user, perm).then((access) => {
|
||||
|
|
@ -177,6 +231,12 @@ permissionSchema.statics.reqPermCheck = function(perm){
|
|||
|
||||
//methods
|
||||
//these are good to have even for single-doc collections since we can loop through them without finding them in the database each time
|
||||
/**
|
||||
* Checks permission against a single user by document
|
||||
* @param {Mongoose.Document} userDB - User doc to rank check against
|
||||
* @param {String} perm - Permission to check user doc against
|
||||
* @returns {Boolean} True if authorized
|
||||
*/
|
||||
permissionSchema.methods.permCheckByUserDoc = function(userDB, perm){
|
||||
//Set user to anon rank if no rank was found for the given user
|
||||
if(userDB == null || userDB.rank == null){
|
||||
|
|
@ -195,10 +255,16 @@ permissionSchema.methods.permCheckByUserDoc = function(userDB, perm){
|
|||
return (userRank >= requiredRank);
|
||||
}else{
|
||||
//if not scream and shout
|
||||
throw new Error(`Permission check '${perm}' not found!`);
|
||||
throw loggerUtils.exceptionSmith(`Permission check '${perm}' not found!`, "Validation");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks channel override permission against a single user by document
|
||||
* @param {Mongoose.Document} userDB - User doc to rank check against
|
||||
* @param {String} perm - Channel Override Permission to check user doc against
|
||||
* @returns {Boolean} True if authorized
|
||||
*/
|
||||
permissionSchema.methods.overrideCheckByUserDoc = function(userDB, perm){
|
||||
//Set user to anon rank if no rank was found for the given user
|
||||
if(userDB == null || userDB.rank == null){
|
||||
|
|
@ -217,10 +283,15 @@ permissionSchema.methods.overrideCheckByUserDoc = function(userDB, perm){
|
|||
return (userRank >= requiredRank);
|
||||
}else{
|
||||
//if not scream and shout
|
||||
throw new Error(`Permission check '${perm}' not found!`);
|
||||
throw loggerUtils.exceptionSmith(`Permission check '${perm}' not found!`, "validation");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns entire permission map marked with booleans
|
||||
* @param {Mongoose.Document} userDB - User Doc to generate perm map against
|
||||
* @returns {Map} Permission map containing booleans for each permission's authorization for a given user doc
|
||||
*/
|
||||
permissionSchema.methods.getPermMapByUserDoc = function(userDB){
|
||||
//Pull permissions keys
|
||||
let permTree = this.schema.tree;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,12 @@ const {mongoose} = require('mongoose');
|
|||
|
||||
//Local Imports
|
||||
const config = require('./../../config.json');
|
||||
const tokeSchema = require('./tokebot/tokeSchema');
|
||||
const loggerUtils = require('./../utils/loggerUtils');
|
||||
|
||||
/**
|
||||
* DB Schema for single document for keeping track of server stats
|
||||
*/
|
||||
const statSchema = new mongoose.Schema({
|
||||
//This does NOT handle deleted accounts/channels. Use userModel.estimatedDocumentCount() for number of active users.
|
||||
userCount: {
|
||||
|
|
@ -41,26 +46,24 @@ const statSchema = new mongoose.Schema({
|
|||
type: mongoose.SchemaTypes.Date,
|
||||
required: true,
|
||||
default: new Date()
|
||||
},
|
||||
tokes: [{
|
||||
toke: {
|
||||
type: mongoose.SchemaTypes.Map,
|
||||
required: true,
|
||||
default: new Map()
|
||||
},
|
||||
date: {
|
||||
type: mongoose.SchemaTypes.Date,
|
||||
required: true,
|
||||
default: new Date()
|
||||
}
|
||||
}]
|
||||
}
|
||||
});
|
||||
|
||||
//statics
|
||||
|
||||
/**
|
||||
* Set placeholder variable to hold cached firstLaunch date from stat document
|
||||
*/
|
||||
statSchema.statics.firstLaunch = null;
|
||||
|
||||
/**
|
||||
* Get's servers sole stat document from the DB
|
||||
* @returns {Mongoose.Document} Server's sole statistics document
|
||||
*/
|
||||
statSchema.statics.getStats = async function(){
|
||||
//Get the first document we find
|
||||
var stats = await this.findOne({});
|
||||
|
||||
|
||||
if(stats){
|
||||
//If we found something then the statistics document exist and this is it,
|
||||
//So long as no one else has fucked with the database it should be the only one. (is this forshadowing for a future bug?)
|
||||
|
|
@ -78,6 +81,9 @@ statSchema.statics.getStats = async function(){
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increments Lunach count upon server launch and prints out amount of launches since server initialization
|
||||
*/
|
||||
statSchema.statics.incrementLaunchCount = async function(){
|
||||
//get our statistics document
|
||||
const stats = await this.getStats();
|
||||
|
|
@ -86,11 +92,17 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increments user count upon new user registration
|
||||
* @returns {Number} Number of users before count was incremented
|
||||
*/
|
||||
statSchema.statics.incrementUserCount = async function(){
|
||||
//get our statistics document
|
||||
const stats = await this.getStats();
|
||||
|
|
@ -105,6 +117,10 @@ statSchema.statics.incrementUserCount = async function(){
|
|||
return oldCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increments channel count upon new channel registration
|
||||
* @returns {Number} Number of channels before count was incremented
|
||||
*/
|
||||
statSchema.statics.incrementChannelCount = async function(){
|
||||
//get our statistics document
|
||||
const stats = await this.getStats();
|
||||
|
|
@ -119,51 +135,4 @@ statSchema.statics.incrementChannelCount = async function(){
|
|||
return oldCount;
|
||||
}
|
||||
|
||||
statSchema.statics.tattooToke = async function(toke){
|
||||
//Get the statistics document
|
||||
const stats = await this.getStats();
|
||||
|
||||
//Add the toke to the stat document
|
||||
stats.tokes.push({toke});
|
||||
|
||||
//Save the stat document
|
||||
await stats.save();
|
||||
}
|
||||
|
||||
statSchema.statics.getTokeCount = async function(){
|
||||
//get stats doc
|
||||
const stats = await this.getStats();
|
||||
|
||||
//return toke count
|
||||
return stats.tokes.length;
|
||||
}
|
||||
|
||||
statSchema.statics.getTokeCommandCounts = async function(){
|
||||
//get stats doc
|
||||
const stats = await this.getStats()
|
||||
//Create empty map to hold toke command counts
|
||||
const count = new Map();
|
||||
|
||||
//for each toke
|
||||
stats.tokes.forEach((toke) => {
|
||||
//For each toke command called in the current toke
|
||||
toke.toke.forEach((command) => {
|
||||
//Get the current count for the current command
|
||||
var curCount = count.get(command);
|
||||
|
||||
//if the current count is null
|
||||
if(curCount == null){
|
||||
//Set it to one
|
||||
count.set(command, 1);
|
||||
}else{
|
||||
//Set it to ++curCount
|
||||
count.set(command, ++curCount);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
//return the toke command count
|
||||
return count;
|
||||
}
|
||||
|
||||
module.exports = mongoose.model("statistics", statSchema);
|
||||
|
|
@ -20,7 +20,11 @@ const {mongoose} = require('mongoose');
|
|||
//Local Imports
|
||||
const defaultTokes = require("../../../defaultTokes.json");
|
||||
const server = require('../../server');
|
||||
const loggerUtils = require('../../utils/loggerUtils');
|
||||
|
||||
/**
|
||||
* Mongoose Schema representing a toke command
|
||||
*/
|
||||
const tokeCommandSchema = new mongoose.Schema({
|
||||
command:{
|
||||
type: mongoose.SchemaTypes.String,
|
||||
|
|
@ -28,15 +32,25 @@ 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){
|
||||
|
||||
//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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -44,9 +58,12 @@ tokeCommandSchema.pre('save', async function (next){
|
|||
next();
|
||||
});
|
||||
|
||||
/**
|
||||
* Pre-Delete middleware, ensures tokebot removes all old toke commands
|
||||
*/
|
||||
tokeCommandSchema.pre('deleteOne', {document: true}, async function (next){
|
||||
//Get server tokebot object (isn't this a fun dot crawler? Why hasn't anyone asked me to stop writing software yet?)
|
||||
const tokebot = server.channelManager.chatHandler.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);
|
||||
|
|
@ -55,6 +72,10 @@ tokeCommandSchema.pre('deleteOne', {document: true}, async function (next){
|
|||
next();
|
||||
});
|
||||
|
||||
/**
|
||||
* Pulls command strings from DB and reports back
|
||||
* @returns {Array} Array of toke commands pulled from the DB
|
||||
*/
|
||||
tokeCommandSchema.statics.getCommandStrings = async function(){
|
||||
//Get all toke commands in the DB
|
||||
const tokeDB = await this.find({});
|
||||
|
|
@ -71,12 +92,16 @@ tokeCommandSchema.statics.getCommandStrings = async function(){
|
|||
return tokeArray;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads default tokes into the DB from flat file upon launch
|
||||
*/
|
||||
tokeCommandSchema.statics.loadDefaults = async function(){
|
||||
//Make sure registerToke function is happy
|
||||
const _this = this;
|
||||
|
||||
//Ensure default comes first (.bind(this) doesn't seem to work here...)
|
||||
await registerToke(defaultTokes.default);
|
||||
|
||||
//For each entry in the defaultTokes.json file
|
||||
defaultTokes.array.forEach(registerToke);
|
||||
|
||||
|
|
@ -93,9 +118,9 @@ tokeCommandSchema.statics.loadDefaults = async function(){
|
|||
|
||||
}catch(err){
|
||||
if(toke != null){
|
||||
console.log(`Error loading toke command: '!${toke}'`);
|
||||
loggerUtils.dumpError(err);
|
||||
}else{
|
||||
console.log("Error, null toke!");
|
||||
loggerUtils.consoleWarn("Error, null toke!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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);
|
||||
|
|
@ -30,8 +30,14 @@ const {mongoose} = require('mongoose');
|
|||
const hashUtil = require('../../utils/hashUtils');
|
||||
const mailUtils = require('../../utils/mailUtils');
|
||||
|
||||
/**
|
||||
* Email change token retention time
|
||||
*/
|
||||
const daysToExpire = 7;
|
||||
|
||||
/**
|
||||
* DB Schema for Document representing a single email change request
|
||||
*/
|
||||
const emailChangeSchema = new mongoose.Schema({
|
||||
user: {
|
||||
type: mongoose.SchemaTypes.ObjectID,
|
||||
|
|
@ -46,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,
|
||||
|
|
@ -60,7 +66,9 @@ const emailChangeSchema = new mongoose.Schema({
|
|||
});
|
||||
|
||||
|
||||
//Presave function
|
||||
/**
|
||||
* Pre-Save function, ensures IP's are hashed and previous requests are deleted upon request creation
|
||||
*/
|
||||
emailChangeSchema.pre('save', async function (next){
|
||||
//If we're saving an ip
|
||||
if(this.isModified('ipHash')){
|
||||
|
|
@ -76,22 +84,31 @@ emailChangeSchema.pre('save', async function (next){
|
|||
next();
|
||||
});
|
||||
|
||||
//statics
|
||||
/**
|
||||
* Schedulable function for processing expired email change requests
|
||||
*/
|
||||
emailChangeSchema.statics.processExpiredRequests = async function(){
|
||||
//Pull all requests from the DB
|
||||
//Tested finding request by date, but mongoose kept throwing casting errors.
|
||||
//This seems to be an intermittent issue online. Maybe it will work in a future version?
|
||||
const requestDB = await this.find({});
|
||||
|
||||
//Fire em all off at once without waiting for the last one to complete since we don't fuckin' need to
|
||||
requestDB.forEach(async (request) => {
|
||||
for(let requestIndex in requestDB){
|
||||
//Pull request from requestDB by index
|
||||
const request = requestDB[requestIndex];
|
||||
|
||||
//If the request hasn't been processed and it's been expired
|
||||
if(request.getDaysUntilExpiration() <= 0){
|
||||
//Delete the request
|
||||
await this.deleteOne({_id: request._id});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
//methods
|
||||
/**
|
||||
* Consumes email change token, changing email address on a given user
|
||||
*/
|
||||
emailChangeSchema.methods.consume = async function(){
|
||||
//Populate the user reference
|
||||
await this.populate('user');
|
||||
|
|
@ -132,9 +149,13 @@ emailChangeSchema.methods.consume = async function(){
|
|||
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates email change URL from a given token
|
||||
* @returns {String} Email change URL generated from token
|
||||
*/
|
||||
emailChangeSchema.methods.getChangeURL = function(){
|
||||
//Check for default port based on protocol
|
||||
if((config.protocol == 'http' && config.port == 80) || (config.protocol == 'https' && config.port == 443)){
|
||||
if((config.protocol == 'http' && config.port == 80) || (config.protocol == 'https' && config.port == 443 || config.proxied)){
|
||||
//Return path
|
||||
return `${config.protocol}://${config.domain}/emailChange?token=${this.token}`;
|
||||
}else{
|
||||
|
|
@ -143,6 +164,10 @@ emailChangeSchema.methods.getChangeURL = function(){
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates days until token expiration
|
||||
* @returns {Number} Days until token expiration
|
||||
*/
|
||||
emailChangeSchema.methods.getDaysUntilExpiration = function(){
|
||||
//Get request date
|
||||
const expirationDate = new Date(this.date);
|
||||
|
|
|
|||
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);
|
||||
|
|
@ -27,10 +27,17 @@ const crypto = require("node:crypto");
|
|||
const {mongoose} = require('mongoose');
|
||||
|
||||
//Local Imports
|
||||
const hashUtil = require('../../utils/hashUtils');
|
||||
const hashUtil = require('../../utils/hashUtils.js');
|
||||
const loggerUtils = require('../../utils/loggerUtils.js')
|
||||
|
||||
/**
|
||||
* Password reset token retention time
|
||||
*/
|
||||
const daysToExpire = 7;
|
||||
|
||||
/**
|
||||
* DB Schema for documents containing a single expiring password reset token
|
||||
*/
|
||||
const passwordResetSchema = new mongoose.Schema({
|
||||
user: {
|
||||
type: mongoose.SchemaTypes.ObjectID,
|
||||
|
|
@ -41,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,
|
||||
|
|
@ -55,7 +62,9 @@ const passwordResetSchema = new mongoose.Schema({
|
|||
});
|
||||
|
||||
|
||||
//Presave function
|
||||
/**
|
||||
* Pre-save function, ensures IP's are hashed before saving
|
||||
*/
|
||||
passwordResetSchema.pre('save', async function (next){
|
||||
//If we're saving an ip
|
||||
if(this.isModified('ipHash')){
|
||||
|
|
@ -63,29 +72,47 @@ passwordResetSchema.pre('save', async function (next){
|
|||
this.ipHash = hashUtil.hashIP(this.ipHash);
|
||||
}
|
||||
|
||||
//If the user was modified (usually only on document initialization)
|
||||
if(this.isModified('user')){
|
||||
//Delete previous requests for the given user
|
||||
const requests = await this.model().deleteMany({user: this.user._id});
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
//statics
|
||||
/**
|
||||
* Schedulable function for processing expired reset requests
|
||||
*/
|
||||
passwordResetSchema.statics.processExpiredRequests = async function(){
|
||||
//Pull all requests from the DB
|
||||
//Tested finding request by date, but mongoose kept throwing casting errors.
|
||||
//This seems to be an intermittent issue online. Maybe it will work in a future version?
|
||||
const requestDB = await this.find({});
|
||||
|
||||
//Fire em all off at once without waiting for the last one to complete since we don't fuckin' need to
|
||||
requestDB.forEach(async (request) => {
|
||||
for(let requestIndex in requestDB){
|
||||
//pull request from requestDB by index
|
||||
const request = requestDB[requestIndex];
|
||||
|
||||
//If the request hasn't been processed and it's been expired
|
||||
if(request.getDaysUntilExpiration() <= 0){
|
||||
//Delete the request
|
||||
await this.deleteOne({_id: request._id});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
//methods
|
||||
/**
|
||||
* Resets password and consumes token
|
||||
* @param {String} pass - New password to set
|
||||
* @param {String} confirmPass - Confirmation String to ensure new pass is correct
|
||||
*/
|
||||
passwordResetSchema.methods.consume = async function(pass, confirmPass){
|
||||
//Check confirmation pass
|
||||
if(pass != confirmPass){
|
||||
throw new Error("Confirmation password does not match!");
|
||||
throw loggerUtils.exceptionSmith("Confirmation password does not match!", "validation");
|
||||
}
|
||||
|
||||
//Populate the user reference
|
||||
|
|
@ -104,9 +131,13 @@ passwordResetSchema.methods.consume = async function(pass, confirmPass){
|
|||
await this.deleteOne();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates password reset URL off of the token object
|
||||
* @returns {String} Reset URL
|
||||
*/
|
||||
passwordResetSchema.methods.getResetURL = function(){
|
||||
//Check for default port based on protocol
|
||||
if((config.protocol == 'http' && config.port == 80) || (config.protocol == 'https' && config.port == 443)){
|
||||
if((config.protocol == 'http' && config.port == 80) || (config.protocol == 'https' && config.port == 443) || config.proxied){
|
||||
//Return path
|
||||
return `${config.protocol}://${config.domain}/passwordReset?token=${this.token}`;
|
||||
}else{
|
||||
|
|
@ -115,6 +146,10 @@ passwordResetSchema.methods.getResetURL = function(){
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns number of days until token expiration
|
||||
* @returns {Number} Number of days until token expiration
|
||||
*/
|
||||
passwordResetSchema.methods.getDaysUntilExpiration = function(){
|
||||
//Get request date
|
||||
const expirationDate = new Date(this.date);
|
||||
|
|
|
|||
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);
|
||||
|
|
@ -18,9 +18,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
|
|||
const {mongoose} = require('mongoose');
|
||||
|
||||
//Local Imports
|
||||
const hashUtil = require('../../utils/hashUtils');
|
||||
const {userModel} = require('./userSchema');
|
||||
const hashUtil = require('../../utils/hashUtils.js');
|
||||
const {userModel} = require('./userSchema.js');
|
||||
const loggerUtils = require('../../utils/loggerUtils.js');
|
||||
|
||||
/**
|
||||
* DB Schema for Documents representing a single user's ban
|
||||
*/
|
||||
const userBanSchema = new mongoose.Schema({
|
||||
user: {
|
||||
type: mongoose.SchemaTypes.ObjectID,
|
||||
|
|
@ -63,9 +67,12 @@ const userBanSchema = new mongoose.Schema({
|
|||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Checks ban by IP
|
||||
* @param {String} ip - IP Address check for bans
|
||||
* @returns {Mongoose.Document} Found ban Document if one exists.
|
||||
*/
|
||||
userBanSchema.statics.checkBanByIP = async function(ip){
|
||||
//Get hash of ip
|
||||
const ipHash = hashUtil.hashIP(ip);
|
||||
//Get all bans
|
||||
const banDB = await this.find({});
|
||||
//Create null variable to hold any found ban
|
||||
|
|
@ -97,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;
|
||||
|
||||
|
|
@ -126,6 +133,11 @@ userBanSchema.statics.checkBanByIP = async function(ip){
|
|||
return foundBan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for bans by user DB doc
|
||||
* @param {Mongoose.Document} userDB - User Doc to check
|
||||
* @returns {Mongoose.Document} Found ban document for given user doc
|
||||
*/
|
||||
userBanSchema.statics.checkBanByUserDoc = async function(userDB){
|
||||
const banDB = await this.find({});
|
||||
var foundBan = null;
|
||||
|
|
@ -155,11 +167,21 @@ userBanSchema.statics.checkBanByUserDoc = async function(userDB){
|
|||
return foundBan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for ban by username
|
||||
* @param {String} user - User to check for bans
|
||||
* @returns {Mongoose.Document} Found User Ban DB Document
|
||||
*/
|
||||
userBanSchema.statics.checkBan = async function(user){
|
||||
const userDB = await userModel.findOne({user: user.user});
|
||||
return this.checkBanByUserDoc(userDB);
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks through processed bans by user
|
||||
* @param {String} user - user to check against for bans
|
||||
* @returns {Mongoose.Document} Spent User Ban Document
|
||||
*/
|
||||
userBanSchema.statics.checkProcessedBans = async function(user){
|
||||
//Pull banlist and create empty variable to hold any found ban
|
||||
const banDB = await this.find({});
|
||||
|
|
@ -181,24 +203,32 @@ userBanSchema.statics.checkProcessedBans = async function(user){
|
|||
return foundBan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bans a given user by their user Document
|
||||
* @param {Mongoose.Document} userDB - DB Doc of the user to ban
|
||||
* @param {Boolean} permanent - Whether or not it's permanant
|
||||
* @param {Number} expirationDays - Days to expire
|
||||
* @param {Boolean} ipBan - Whether or not we're banning by IP
|
||||
* @returns {Mongoose.Document} A freshly created User Ban DB Document :)
|
||||
*/
|
||||
userBanSchema.statics.banByUserDoc = async function(userDB, permanent, expirationDays, ipBan = false){
|
||||
//Prevent missing users
|
||||
if(userDB == null){
|
||||
throw new Error("User not found")
|
||||
throw loggerUtils.exceptionSmith("User not found", "validation");
|
||||
}
|
||||
|
||||
//Ensure the user isn't already banned
|
||||
if(await this.checkBanByUserDoc(userDB) != null){
|
||||
throw new Error("User already banned");
|
||||
throw loggerUtils.exceptionSmith("User already banned", "validation");
|
||||
}
|
||||
|
||||
//Verify time to expire/delete depending on action
|
||||
if(expirationDays < 0){
|
||||
throw new Error("Expiration Days must be a positive integer!");
|
||||
throw loggerUtils.exceptionSmith("Expiration Days must be a positive integer!", "validation");
|
||||
}else if(expirationDays < 30 && permanent){
|
||||
throw new Error("Permanent bans must be given at least 30 days before automatic account deletion!");
|
||||
throw loggerUtils.exceptionSmith("Permanent bans must be given at least 30 days before automatic account deletion!", "validation");
|
||||
}else if(expirationDays > 185){
|
||||
throw new Error("Expiration/Deletion date cannot be longer than half a year out from the original ban date.");
|
||||
throw loggerUtils.exceptionSmith("Expiration/Deletion date cannot be longer than half a year out from the original ban date.", "validation");
|
||||
}
|
||||
|
||||
await banSessions(userDB);
|
||||
|
|
@ -257,22 +287,35 @@ userBanSchema.statics.banByUserDoc = async function(userDB, permanent, expiratio
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bans user by username
|
||||
* @param {String} user - Username of user to ban
|
||||
* @param {Boolean} permanent - Whether or not it's permanant
|
||||
* @param {Number} expirationDays - Days to expire
|
||||
* @param {Boolean} ipBan - Whether or not we're banning by IP
|
||||
* @returns {Mongoose.Document} A freshly created User Ban DB Document :)
|
||||
*/
|
||||
userBanSchema.statics.ban = async function(user, permanent, expirationDays, ipBan){
|
||||
const userDB = await userModel.findOne({user: user.user});
|
||||
return this.banByUserDoc(userDB, permanent, expirationDays, ipBan);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unbans users by user doc
|
||||
* @param {Mongoose.Document} userDB - User DB Document to unban
|
||||
* @returns {Mongoose.Document} Old, deleted ban document
|
||||
*/
|
||||
userBanSchema.statics.unbanByUserDoc = async function(userDB){
|
||||
|
||||
//Prevent missing users
|
||||
if(userDB == null){
|
||||
throw new Error("User not found")
|
||||
throw loggerUtils.exceptionSmith("User not found", "validation");
|
||||
}
|
||||
|
||||
const banDB = await this.checkBanByUserDoc(userDB);
|
||||
|
||||
if(!banDB){
|
||||
throw new Error("User already un-banned");
|
||||
throw loggerUtils.exceptionSmith("User already un-banned", "validation");
|
||||
}
|
||||
|
||||
//Use _id in-case mongoose wants to be a cunt
|
||||
|
|
@ -280,17 +323,28 @@ userBanSchema.statics.unbanByUserDoc = async function(userDB){
|
|||
return oldBan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unban deleted user
|
||||
* Can't bring back accounts, but will re-allow re-use of old usernames, and new accounts/connections from banned IP's
|
||||
* @param {String} user - Username of deleted account to unban
|
||||
* @returns {Mongoose.Document} Old, deleted ban document
|
||||
*/
|
||||
userBanSchema.statics.unbanDeleted = async function(user){
|
||||
const banDB = await this.checkProcessedBans(user);
|
||||
|
||||
if(!banDB){
|
||||
throw new Error("User already un-banned");
|
||||
throw loggerUtils.exceptionSmith("User already un-banned", "validation");
|
||||
}
|
||||
|
||||
const oldBan = await this.deleteOne({_id: banDB._id});
|
||||
return oldBan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unbans user by username
|
||||
* @param {String} user - Username of user to unban
|
||||
* @returns Old, deleted ban document
|
||||
*/
|
||||
userBanSchema.statics.unban = async function(user){
|
||||
//Find user in DB
|
||||
const userDB = await userModel.findOne({user: user.user});
|
||||
|
|
@ -305,6 +359,10 @@ userBanSchema.statics.unban = async function(user){
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates Network-Friendly Browser-Digestable list of bans for the admin panel
|
||||
* @returns {Object} Network-Friendly Browser-Digestable list of bans for the admin panel
|
||||
*/
|
||||
userBanSchema.statics.getBans = async function(){
|
||||
//Get the ban, populating users and alts
|
||||
const banDB = await this.find({}).populate('user').populate('alts');
|
||||
|
|
@ -351,11 +409,18 @@ userBanSchema.statics.getBans = async function(){
|
|||
return bans;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduable function for processing expired user bans
|
||||
*/
|
||||
userBanSchema.statics.processExpiredBans = async function(){
|
||||
//Channel ban expirations may vary so there's no way to search for expired bans
|
||||
const banDB = await this.find({});
|
||||
|
||||
//Firem all off all at once seperately without waiting for one another
|
||||
banDB.forEach(async (ban) => {
|
||||
for(let banIndex in banDB){
|
||||
//Pull ban from banlist by index
|
||||
const ban = banDB[banIndex];
|
||||
|
||||
//This ban was already processed, and it's user has been deleted. There is no more to be done...
|
||||
if(ban.user == null){
|
||||
return;
|
||||
|
|
@ -393,10 +458,14 @@ userBanSchema.statics.processExpiredBans = async function(){
|
|||
await this.deleteOne({_id: ban._id});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
//methods
|
||||
/**
|
||||
* Calculates days until ban expiration
|
||||
* @returns {Number} Days until ban expiration
|
||||
*/
|
||||
userBanSchema.methods.getDaysUntilExpiration = function(){
|
||||
//Get ban date
|
||||
const expirationDate = new Date(this.banDate);
|
||||
|
|
|
|||
|
|
@ -14,9 +14,6 @@ GNU Affero General Public License for more details.
|
|||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.*/
|
||||
|
||||
//Node Imports
|
||||
const { profile } = require('console');
|
||||
|
||||
//NPM Imports
|
||||
const {mongoose} = require('mongoose');
|
||||
|
||||
|
|
@ -25,16 +22,22 @@ const {mongoose} = require('mongoose');
|
|||
const server = require('../../server');
|
||||
//DB Models
|
||||
const statModel = require('../statSchema');
|
||||
const tokeModel = require('../tokebot/tokeSchema');
|
||||
const flairModel = require('../flairSchema');
|
||||
const permissionModel = require('../permissionSchema');
|
||||
const emoteModel = require('../emoteSchema');
|
||||
const emailChangeModel = require('./emailChangeSchema');
|
||||
const playlistSchema = require('../channel/media/playlistSchema');
|
||||
const rememberMeModel = require('./rememberMeSchema');
|
||||
//Utils
|
||||
const hashUtil = require('../../utils/hashUtils');
|
||||
const mailUtil = require('../../utils/mailUtils');
|
||||
const loggerUtils = require('../../utils/loggerUtils')
|
||||
|
||||
|
||||
/**
|
||||
* Mongoose Schema for a document representing a single canopy user
|
||||
*/
|
||||
const userSchema = new mongoose.Schema({
|
||||
id: {
|
||||
type: mongoose.SchemaTypes.Number,
|
||||
|
|
@ -59,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,
|
||||
|
|
@ -73,25 +81,27 @@ const userSchema = new mongoose.Schema({
|
|||
img: {
|
||||
type: mongoose.SchemaTypes.String,
|
||||
required: true,
|
||||
default: "/img/johnny.png"
|
||||
default: "/nonfree/johnny.png"
|
||||
},
|
||||
//These should be larger than validator values to make room for escaped characters
|
||||
bio: {
|
||||
type: mongoose.SchemaTypes.String,
|
||||
required: true,
|
||||
maxLength: 2000,
|
||||
//Calculate max length by the validator max length and the size of an escaped character
|
||||
maxLength: 1000 * 6,
|
||||
default: "Bio not set!"
|
||||
},
|
||||
pronouns:{
|
||||
type: mongoose.SchemaTypes.String,
|
||||
optional: true,
|
||||
maxLength: 50,
|
||||
//Calculate max length by the validator max length and the size of an escaped character
|
||||
maxLength: 15 * 6,
|
||||
default: ""
|
||||
},
|
||||
signature: {
|
||||
type: mongoose.SchemaTypes.String,
|
||||
required: true,
|
||||
maxLength: 300,
|
||||
//Calculate max length by the validator max length and the size of an escaped character
|
||||
maxLength: 25 * 6,
|
||||
default: "Signature not set!"
|
||||
},
|
||||
highLevel: {
|
||||
|
|
@ -147,12 +157,15 @@ const userSchema = new mongoose.Schema({
|
|||
});
|
||||
|
||||
//This is one of those places where you really DON'T want to use an arrow function over an anonymous one!
|
||||
/**
|
||||
* Pre-Save function for user document, handles password hashing, flair updates, emote refreshes, and kills sessions upon rank change
|
||||
*/
|
||||
userSchema.pre('save', async function (next){
|
||||
|
||||
//If the password was changed
|
||||
if(this.isModified("pass")){
|
||||
//Hash that sunnovabitch, no questions asked.
|
||||
this.pass = hashUtil.hashPassword(this.pass);
|
||||
this.pass = await hashUtil.hashPassword(this.pass);
|
||||
}
|
||||
|
||||
//If the flair was changed
|
||||
|
|
@ -161,7 +174,7 @@ userSchema.pre('save', async function (next){
|
|||
await this.populate('flair');
|
||||
|
||||
if(permissionModel.rankToNum(this.rank) < permissionModel.rankToNum(this.flair.rank)){
|
||||
throw new Error(`User '${this.user}' does not have a high enough rank for flair '${this.flair.displayName}'!`);
|
||||
throw loggerUtils.exceptionSmith(`User '${this.user}' does not have a high enough rank for flair '${this.flair.displayName}'!`, "unauthorized");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -173,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.");
|
||||
}
|
||||
|
|
@ -190,7 +208,9 @@ userSchema.pre('save', async function (next){
|
|||
next();
|
||||
});
|
||||
|
||||
//post-delete function (document not query)
|
||||
/**
|
||||
* Pre-Delete function for user accounts, drops connections and rips it self out from alt accounts
|
||||
*/
|
||||
userSchema.post('deleteOne', {document: true}, async function (){
|
||||
//Kill any active sessions
|
||||
await this.killAllSessions("If you're seeing this, your account has been deleted. So long, and thanks for all the fish! <3");
|
||||
|
|
@ -212,18 +232,55 @@ userSchema.post('deleteOne', {document: true}, async function (){
|
|||
});
|
||||
|
||||
//statics
|
||||
/**
|
||||
* Holds cache of usernames of profiles stored in the Legacy Profile Migration collection
|
||||
*
|
||||
* We can't directly reference migrationSchema, as it would cause a circular reference
|
||||
* To deal with this, migration schema caches it's regestered users into this array on startup.
|
||||
* Bonus pts for improved performance on registration calls
|
||||
*/
|
||||
userSchema.statics.migrationCache = {
|
||||
users: [],
|
||||
emails: []
|
||||
};
|
||||
|
||||
/**
|
||||
* Registers a new user account with given information
|
||||
* @param {Object} userObj - Object representing user to register, generated by the client
|
||||
* @param {String} ip - IP Address of connection registering the account
|
||||
*/
|
||||
userSchema.statics.register = async function(userObj, ip){
|
||||
//Pull values from user object
|
||||
const {user, pass, passConfirm, email} = userObj;
|
||||
|
||||
//Check password confirmation matches
|
||||
if(pass == passConfirm){
|
||||
//Setup user query
|
||||
let userQuery = {user: new RegExp(user, 'i')};
|
||||
|
||||
//If we have an email
|
||||
if(email != null && email != ""){
|
||||
userQuery = {$or: [
|
||||
userQuery,
|
||||
{email: new RegExp(email, 'i')}
|
||||
]};
|
||||
}
|
||||
|
||||
//Look for a user (case insensitive)
|
||||
var userDB = await this.findOne({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"){
|
||||
throw new Error("User name/email already taken!");
|
||||
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
|
||||
const id = await statModel.incrementUserCount();
|
||||
|
|
@ -236,24 +293,33 @@ 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{
|
||||
throw new Error("Confirmation password doesn't match!");
|
||||
throw loggerUtils.exceptionSmith("Confirmation password doesn't match!", "validation");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticates a username and password pair
|
||||
* @param {String} user - Username of account to login as
|
||||
* @param {String} pass - Password to authenticat account
|
||||
* @param {String} failLine - Line to paste into custom error upon login failure
|
||||
* @returns {Mongoose.Document} - User DB Document upon success
|
||||
*/
|
||||
userSchema.statics.authenticate = async function(user, pass, failLine = "Bad Username or Password."){
|
||||
//check for missing pass
|
||||
if(!user || !pass){
|
||||
throw new Error("Missing user/pass.");
|
||||
throw loggerUtils.exceptionSmith("Missing user/pass.", "validation");
|
||||
}
|
||||
|
||||
//get the user if it exists
|
||||
const userDB = await this.findOne({ user });
|
||||
const userDB = await this.findOne({ user: new RegExp(user, 'i')});
|
||||
|
||||
//if not scream and shout
|
||||
if(!userDB){
|
||||
|
|
@ -261,19 +327,25 @@ 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
|
||||
badLogin();
|
||||
}
|
||||
|
||||
//standardize bad login response so it's unknowin which is bad for security reasons.
|
||||
//standardize bad login response so it's unknown which is bad for security reasons.
|
||||
function badLogin(){
|
||||
throw new Error(failLine);
|
||||
throw loggerUtils.exceptionSmith(failLine, "unauthorized");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets profile by username
|
||||
* @param {String} user - name of user to find
|
||||
* @param {Boolean} includeEmail - Whether or not to include email in the final result
|
||||
* @returns {Object} A network-friendly browser-digestable Object representing a single user profile
|
||||
*/
|
||||
userSchema.statics.findProfile = async function(user, includeEmail = false){
|
||||
//Catch null users
|
||||
if(user == null || user.user == null){
|
||||
|
|
@ -284,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!"
|
||||
};
|
||||
|
|
@ -309,6 +382,10 @@ userSchema.statics.findProfile = async function(user, includeEmail = false){
|
|||
|
||||
}
|
||||
|
||||
/**
|
||||
* Tattoos a single toke callout to all the users within it
|
||||
* @param {Map} tokemap - Map containing list of users and the toke command they used to join the toke
|
||||
*/
|
||||
userSchema.statics.tattooToke = function(tokemap){
|
||||
//For each toke, asynchronously:
|
||||
tokemap.forEach(async (toke, user) => {
|
||||
|
|
@ -345,6 +422,11 @@ userSchema.statics.tattooToke = function(tokemap){
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquires a list of the entire userbase
|
||||
* @param {Boolean} fullList - Determines whether or not we're listing rank and email
|
||||
* @returns {Array} Array of objects containing user metadata
|
||||
*/
|
||||
userSchema.statics.getUserList = async function(fullList = false){
|
||||
var userList = [];
|
||||
//Get all of our users
|
||||
|
|
@ -379,35 +461,51 @@ userSchema.statics.getUserList = async function(fullList = false){
|
|||
return userList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process and Deletes Aged IP Records
|
||||
*/
|
||||
userSchema.statics.processAgedIPRecords = async function(){
|
||||
//Pull full userlist
|
||||
const users = await this.find({});
|
||||
|
||||
//for every user
|
||||
users.forEach((userDB) => {
|
||||
//Not a fan of iterating through the DB but there doesn't seem to be a way to search for a doc based on the properties of it's subdoc
|
||||
for(let userIndex in users){
|
||||
//Pull user record from users by index
|
||||
const userDB = users[userIndex];
|
||||
//For every recent ip within the user
|
||||
userDB.recentIPs.forEach((record, recordI) => {
|
||||
for(let recordIndex in userDB.recentIPs){
|
||||
//Pull record from recent IPs by index
|
||||
const record = userDB.recentIPs[recordIndex];
|
||||
//Check how long it's been since we've last seen the IP
|
||||
const daysSinceLastUse = ((new Date() - record.lastLog) / (1000 * 60 * 60 * 24)).toFixed(1);
|
||||
|
||||
//If it's been more than a week
|
||||
if(daysSinceLastUse >= 7){
|
||||
//Splice out the IP record
|
||||
userDB.recentIPs.splice(recordI, 1);
|
||||
userDB.recentIPs.splice(recordIndex, 1);
|
||||
//No reason to wait on this since we're done with this user
|
||||
userDB.save();
|
||||
await userDB.save();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
//methods
|
||||
userSchema.methods.checkPass = function(pass){
|
||||
return hashUtil.comparePassword(pass, this.pass)
|
||||
/**
|
||||
* Checks password against a user document
|
||||
* @param {String} pass - Password to authenticate
|
||||
* @returns {Boolean} True if authenticated
|
||||
*/
|
||||
userSchema.methods.checkPass = async function(pass){
|
||||
return await hashUtil.comparePassword(pass, this.pass)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists authenticated sessions for a given user document
|
||||
* @returns {Promise} Promise containing an array of active user sessions for a given user
|
||||
*/
|
||||
userSchema.methods.getAuthenticatedSessions = async function(){
|
||||
var returnArr = [];
|
||||
|
||||
|
|
@ -438,12 +536,18 @@ userSchema.methods.getAuthenticatedSessions = async function(){
|
|||
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a network-friendly browser-digestable profile object for a sepcific user document
|
||||
* @param {Boolean} includeEmail - Whether or not to include the email address in the complete profile object
|
||||
* @returns {Object} Network-Friendly Browser-Digestable object containing profile metadata
|
||||
*/
|
||||
userSchema.methods.getProfile = function(includeEmail = false){
|
||||
//Create profile hashtable
|
||||
const profile = {
|
||||
id: this.id,
|
||||
user: this.user,
|
||||
date: this.date,
|
||||
lastActive: this.lastActive,
|
||||
tokes: this.tokes,
|
||||
tokeCount: this.getTokeCount(),
|
||||
img: this.img,
|
||||
|
|
@ -461,6 +565,10 @@ userSchema.methods.getProfile = function(includeEmail = false){
|
|||
return profile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets list of profiles of alt accounts related to the given user document
|
||||
* @returns {Array} List of alternate profiles
|
||||
*/
|
||||
userSchema.methods.getAltProfiles = async function(){
|
||||
//Create an empty list to hold alt profiles
|
||||
const profileList = [];
|
||||
|
|
@ -478,6 +586,11 @@ userSchema.methods.getAltProfiles = async function(){
|
|||
return profileList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets flair for current user documetn
|
||||
* @param {String} flair - flair to set
|
||||
* @returns {Mongoose.Document} returns found flair document from DB
|
||||
*/
|
||||
userSchema.methods.setFlair = async function(flair){
|
||||
//Find flair by name
|
||||
const flairDB = await flairModel.findOne({name: flair});
|
||||
|
|
@ -489,6 +602,10 @@ userSchema.methods.setFlair = async function(flair){
|
|||
return flairDB;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets number of tokes for a given user document
|
||||
* @returns {Number} Number of tokes recorded for the given user document
|
||||
*/
|
||||
userSchema.methods.getTokeCount = function(){
|
||||
//Set tokeCount to 0
|
||||
var tokeCount = 0;
|
||||
|
|
@ -503,6 +620,10 @@ userSchema.methods.getTokeCount = function(){
|
|||
return tokeCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets number of emotes for a given user document
|
||||
* @returns {Array} Array of objects representing emotes for the given user document
|
||||
*/
|
||||
userSchema.methods.getEmotes = function(){
|
||||
//Create an empty array to hold our emote list
|
||||
const emoteList = [];
|
||||
|
|
@ -521,6 +642,10 @@ userSchema.methods.getEmotes = function(){
|
|||
return emoteList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an emote from the user Document
|
||||
* @param {String} name - Name of emote to delete
|
||||
*/
|
||||
userSchema.methods.deleteEmote = async function(name){
|
||||
//Get index by emote name
|
||||
const emoteIndex = this.emotes.findIndex(checkName);
|
||||
|
|
@ -537,6 +662,10 @@ userSchema.methods.deleteEmote = async function(name){
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets list of user playlists
|
||||
* @returns {Array} Array of objects represnting a playlist containing media objects
|
||||
*/
|
||||
userSchema.methods.getPlaylists = function(){
|
||||
//Create an empty array to hold our emote list
|
||||
const playlists = [];
|
||||
|
|
@ -551,6 +680,10 @@ userSchema.methods.getPlaylists = function(){
|
|||
return playlists;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterates through user playlists and calls a given callback function against them
|
||||
* @param {Function} cb - Callback function to call against user playlists
|
||||
*/
|
||||
userSchema.methods.playlistCrawl = function(cb){
|
||||
for(let listIndex in this.playlists){
|
||||
//Grab the associated playlist
|
||||
|
|
@ -561,6 +694,11 @@ userSchema.methods.playlistCrawl = function(cb){
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns playlist by name
|
||||
* @param {String} name - Playlist to get by name
|
||||
* @returns {Object} Object representing the requested playlist
|
||||
*/
|
||||
userSchema.methods.getPlaylistByName = function(name){
|
||||
//Create null value to hold our found playlist
|
||||
let foundPlaylist = null;
|
||||
|
|
@ -580,6 +718,10 @@ userSchema.methods.getPlaylistByName = function(name){
|
|||
return foundPlaylist;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a playlist from the user document by name
|
||||
* @param {String} name - Name of playlist to delete
|
||||
*/
|
||||
userSchema.methods.deletePlaylistByName = async function(name){
|
||||
//Find the playlist
|
||||
const playlist = this.getPlaylistByName(name);
|
||||
|
|
@ -591,6 +733,10 @@ userSchema.methods.deletePlaylistByName = async function(name){
|
|||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Salts, Hashes and Tattoo's IP address to user document in a privacy respecting manner
|
||||
* @param {String} ip - Plaintext IP Address to Salt, Hash and Tattoo
|
||||
*/
|
||||
userSchema.methods.tattooIPRecord = async function(ip){
|
||||
//Hash the users ip
|
||||
const ipHash = hashUtil.hashIP(ip);
|
||||
|
|
@ -611,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
|
||||
|
|
@ -620,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);
|
||||
|
||||
|
|
@ -657,12 +801,19 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
//note: if you gotta call this from a request authenticated by it's user, make sure to kill that session first!
|
||||
/**
|
||||
* Kills all sessions for a given user
|
||||
* @param {String} reason - Reason to kill user sessions
|
||||
*/
|
||||
userSchema.methods.killAllSessions = async function(reason = "A full log-out from all devices was requested for your account."){
|
||||
//Nuke all related remember me tokens
|
||||
await rememberMeModel.deleteMany({user: this._id});
|
||||
|
||||
//get authenticated sessions
|
||||
var sessions = await this.getAuthenticatedSessions();
|
||||
|
||||
|
|
@ -675,8 +826,12 @@ userSchema.methods.killAllSessions = async function(reason = "A full log-out fro
|
|||
server.channelManager.kickConnections(this.user, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes password for a given user document
|
||||
* @param {Object} passChange - passChange object handed down from Browser
|
||||
*/
|
||||
userSchema.methods.changePassword = async function(passChange){
|
||||
if(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;
|
||||
|
|
@ -688,15 +843,20 @@ userSchema.methods.changePassword = async function(passChange){
|
|||
await this.killAllSessions("Your password has been reset.");
|
||||
}else{
|
||||
//confirmation pass doesn't match
|
||||
throw new Error("Mismatched confirmation password!");
|
||||
throw loggerUtils.exceptionSmith("Mismatched confirmation password!", "validation");
|
||||
}
|
||||
}else{
|
||||
//Old password wrong
|
||||
throw new Error("Incorrect Password!");
|
||||
throw loggerUtils.exceptionSmith("Incorrect Password!", "validation");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks another user document against this one to see if they're alts
|
||||
* @param {Mongoose.Document} userDB - User document to check for alts against
|
||||
* @returns {Boolean} True if alt
|
||||
*/
|
||||
userSchema.methods.altCheck = async function(userDB){
|
||||
//Found alt flag
|
||||
let foundAlt = false;
|
||||
|
|
@ -712,20 +872,24 @@ userSchema.methods.altCheck = async function(userDB){
|
|||
return foundAlt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nukes user account from the face of the planet upon said user's request
|
||||
* @param {String} pass - Password to authenticate against before deleting
|
||||
*/
|
||||
userSchema.methods.nuke = async function(pass){
|
||||
//Check we have a confirmation password
|
||||
if(pass == "" || pass == null){
|
||||
//scream and shout
|
||||
throw new Error("No confirmation password!");
|
||||
throw loggerUtils.exceptionSmith("No confirmation password!", "validation");
|
||||
}
|
||||
|
||||
//Check that the password is correct
|
||||
if(this.checkPass(pass)){
|
||||
if(await this.checkPass(pass)){
|
||||
//delete the user
|
||||
var oldUser = await this.deleteOne();
|
||||
}else{
|
||||
//complain about a bad pass
|
||||
throw new Error("Bad pass.");
|
||||
throw loggerUtils.exceptionSmith("Bad pass.", "unauthorized");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
156
src/server.js
156
src/server.js
|
|
@ -14,35 +14,47 @@ GNU Affero General Public License for more details.
|
|||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.*/
|
||||
|
||||
//Define global crypto variable for altcha
|
||||
globalThis.crypto = require('node:crypto').webcrypto;
|
||||
|
||||
//Define NODE imports
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
|
||||
//Define NPM imports
|
||||
const express = require('express');
|
||||
const session = require('express-session');
|
||||
const {createServer } = require('http');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const { Server } = require('socket.io');
|
||||
const path = require('path');
|
||||
const mongoStore = require('connect-mongo');
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
//Define global crypto variable for altcha
|
||||
globalThis.crypto = require('node:crypto').webcrypto;
|
||||
|
||||
//Define Local Imports
|
||||
//Application
|
||||
const channelManager = require('./app/channel/channelManager');
|
||||
const pmHandler = require('./app/pm/pmHandler');
|
||||
//Util
|
||||
const configCheck = require('./utils/configCheck');
|
||||
const scheduler = require('./utils/scheduler');
|
||||
const {errorMiddleware} = require('./utils/loggerUtils');
|
||||
const sessionUtils = require('./utils/sessionUtils');
|
||||
//Validator
|
||||
const accountValidator = require('./validators/accountValidator');
|
||||
//DB Model
|
||||
const statModel = require('./schemas/statSchema');
|
||||
const flairModel = require('./schemas/flairSchema');
|
||||
const emoteModel = require('./schemas/emoteSchema');
|
||||
const tokeModel = require('./schemas/tokebot/tokeSchema');
|
||||
const tokeCommandModel = require('./schemas/tokebot/tokeCommandSchema');
|
||||
const migrationModel = require('./schemas/user/migrationSchema');
|
||||
//Controller
|
||||
const fileNotFoundController = require('./controllers/404Controller');
|
||||
//Router
|
||||
//Humie-Friendly
|
||||
const indexRouter = require('./routers/indexRouter');
|
||||
const aboutRouter = require('./routers/aboutRouter');
|
||||
const registerRouter = require('./routers/registerRouter');
|
||||
const loginRouter = require('./routers/loginRouter');
|
||||
const profileRouter = require('./routers/profileRouter');
|
||||
|
|
@ -51,10 +63,11 @@ const channelRouter = require('./routers/channelRouter');
|
|||
const newChannelRouter = require('./routers/newChannelRouter');
|
||||
const passwordResetRouter = require('./routers/passwordResetRouter');
|
||||
const emailChangeRouter = require('./routers/emailChangeController');
|
||||
const migrateRouter = require('./routers/migrateRouter');
|
||||
//Panel
|
||||
const panelRouter = require('./routers/panelRouter');
|
||||
//Popup
|
||||
const popupRouter = require('./routers/popupRouter');
|
||||
//const popupRouter = require('./routers/popupRouter');
|
||||
//Tooltip
|
||||
const tooltipRouter = require('./routers/tooltipRouter');
|
||||
//Api
|
||||
|
|
@ -62,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();
|
||||
|
||||
|
|
@ -76,15 +87,52 @@ 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"
|
||||
}
|
||||
});
|
||||
|
||||
//Define http and socket.io servers
|
||||
const httpServer = createServer(app);
|
||||
const io = new Server(httpServer, {});
|
||||
//Declare web server
|
||||
let webServer = null;
|
||||
|
||||
//If we're using HTTPS
|
||||
if(config.protocol.toLowerCase() == "https"){
|
||||
try{
|
||||
//Read key/cert files and store contents
|
||||
const httpsOptions = {
|
||||
key: fs.readFileSync(config.ssl.key),
|
||||
cert: fs.readFileSync(config.ssl.cert)
|
||||
};
|
||||
|
||||
//Start HTTPS Server
|
||||
webServer = https.createServer(httpsOptions, app);
|
||||
}catch(err){
|
||||
//If the error has a path
|
||||
if(err.path != null && err.path != ""){
|
||||
//Tell the user to fix their shit
|
||||
console.log(`Error opening key/cert file: ${err.path}`);
|
||||
//Otherwise
|
||||
}else{
|
||||
//Shit our pants
|
||||
console.log("Unknown error occured while starting HTTPS server! Bailing out!");
|
||||
console.log(err);
|
||||
}
|
||||
|
||||
//and run for the hills
|
||||
process.exit();
|
||||
}
|
||||
|
||||
//Otherwise
|
||||
}else{
|
||||
//Default to HTTP
|
||||
webServer = createServer(app)
|
||||
}
|
||||
const io = new Server(webServer, {});
|
||||
|
||||
//Connect mongoose to the database
|
||||
mongoose.set("sanitizeFilter", true).connect(dbUrl).then(() => {
|
||||
|
|
@ -95,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');
|
||||
|
|
@ -102,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);
|
||||
|
|
@ -110,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);
|
||||
|
|
@ -121,49 +187,63 @@ app.use('/c', channelRouter);
|
|||
app.use('/newChannel', newChannelRouter);
|
||||
app.use('/passwordReset', passwordResetRouter);
|
||||
app.use('/emailChange', emailChangeRouter);
|
||||
app.use('/migrate', migrateRouter);
|
||||
//Panel
|
||||
app.use('/panel', panelRouter);
|
||||
//Popup
|
||||
app.use('/popup', popupRouter);
|
||||
//tooltip
|
||||
app.use('/tooltip', tooltipRouter);
|
||||
//Bot-Ready
|
||||
app.use('/api', apiRouter);
|
||||
|
||||
//Static File Server
|
||||
//Serve bootstrap icons
|
||||
app.use('/lib/bootstrap-icons',express.static(path.join(__dirname, '../node_modules/bootstrap-icons')));
|
||||
app.use('/lib/altcha',express.static(path.join(__dirname, '../node_modules/altcha/dist_external')));
|
||||
//Server public 'www' folder
|
||||
app.use(express.static(path.join(__dirname, '../www')));
|
||||
|
||||
//Handle error checking
|
||||
app.use(errorMiddleware);
|
||||
|
||||
//Basic 404 handler
|
||||
app.use(fileNotFoundController);
|
||||
|
||||
asyncKickStart();
|
||||
|
||||
/*Asyncronous Kickstarter function
|
||||
Allows us to force server startup to wait on the DB to be ready.
|
||||
Might be better if she kicked off everything at once, and ran a while loop to check when they where all done.
|
||||
This runs once at server startup, and most startups will run fairly quickly so... Not worth it?*/
|
||||
async function asyncKickStart(){
|
||||
//Lettum fuckin' know wassup
|
||||
console.log(`${config.instanceName}(Powered by Canopy ${package.canopyDisplayVersion}) is booting up!`);
|
||||
|
||||
//Increment launch counter
|
||||
statModel.incrementLaunchCount();
|
||||
//Run legacy migration
|
||||
await migrationModel.ingestLegacyDump();
|
||||
|
||||
//Load default flairs
|
||||
flairModel.loadDefaults();
|
||||
//Build migration cache
|
||||
await migrationModel.buildMigrationCache();
|
||||
|
||||
//Load default emotes
|
||||
emoteModel.loadDefaults();
|
||||
//Calculate Toke Map
|
||||
await tokeModel.calculateTokeMap();
|
||||
|
||||
//Load default toke commands
|
||||
tokeCommandModel.loadDefaults();
|
||||
//Load default toke commands
|
||||
await tokeCommandModel.loadDefaults();
|
||||
|
||||
//Kick off scheduled-jobs
|
||||
scheduler.kickoff();
|
||||
//Load default flairs
|
||||
await flairModel.loadDefaults();
|
||||
|
||||
//Hand over general-namespace socket.io connections to the channel manager
|
||||
module.exports.channelManager = new channelManager(io)
|
||||
//Load default emotes
|
||||
await emoteModel.loadDefaults();
|
||||
|
||||
//Listen Function
|
||||
httpServer.listen(port, () => {
|
||||
console.log(`Opening port ${port}`);
|
||||
});
|
||||
//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`);
|
||||
});
|
||||
}
|
||||
|
|
@ -20,11 +20,21 @@ const config = require('../../config.json');
|
|||
//NPM imports
|
||||
const { createChallenge, verifySolution } = require('altcha-lib');
|
||||
|
||||
//Create empty array to hold cache of spent payloades to protect against replay attacks
|
||||
/**
|
||||
* Create empty array to hold cache of spent payloads to protect against replay attacks
|
||||
*/
|
||||
const spent = [];
|
||||
//Captcha lifetime in minutes
|
||||
/**
|
||||
* Captcha lifetime in minutes
|
||||
*/
|
||||
const lifetime = 2;
|
||||
|
||||
/**
|
||||
* Generates captcha challenges to send down to the browser
|
||||
* @param {Number} difficulty - Challange Difficulty (x100K internally)
|
||||
* @param {String} uniqueSecret - Secret to salt the challange hash with
|
||||
* @returns {String} Altcha Challenge hash
|
||||
*/
|
||||
module.exports.genCaptcha = async function(difficulty = 2, uniqueSecret = ''){
|
||||
//Set altcha expiration date
|
||||
const expiration = new Date();
|
||||
|
|
@ -34,14 +44,20 @@ 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
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies completed altcha challenges handed over from the user
|
||||
* @param {String} payload - Completed Altcha Payload
|
||||
* @param {String} uniqueSecret - Server-side Unique Secret to verify payload came from server-generated challenge
|
||||
* @returns {boolean} True if payload is a valid and unique altcha challenge which originated from this server
|
||||
*/
|
||||
module.exports.verify = async function(payload, uniqueSecret = ''){
|
||||
//If we already checked this payload
|
||||
//If this payload is already spent
|
||||
if(spent.indexOf(payload) != -1){
|
||||
//Fuck off and die
|
||||
return false;
|
||||
|
|
@ -57,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(''));
|
||||
}
|
||||
|
|
@ -23,10 +23,13 @@ const loggerUtil = require('./loggerUtils');
|
|||
//NPM Imports
|
||||
const validator = require('validator');//We need validators for express-less code too!
|
||||
|
||||
|
||||
/**
|
||||
* Basic security check which runs on startup.
|
||||
* Warns server admin against unsafe config options.
|
||||
*/
|
||||
module.exports.securityCheck = function(){
|
||||
//Check Protocol
|
||||
if(config.protocol == 'http'){
|
||||
if(config.protocol.toLowerCase() != 'https'){
|
||||
//If it's insecure then warn the admin
|
||||
loggerUtil.consoleWarn("Starting in HTTP mode. This server should be used for development purposes only!");
|
||||
}
|
||||
|
|
@ -37,16 +40,31 @@ 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.secrets.ipSecret) || config.secrets.ipSecret == "CHANGE_ME"){
|
||||
loggerUtil.consoleWarn("Insecure IP Hashing Secret! Change IP Hashing Secret!");
|
||||
}
|
||||
|
||||
//check DB pass
|
||||
if(!validator.isStrongPassword(config.db.pass) || config.db.pass == "CHANGE_ME" || config.db.pass == config.db.user){
|
||||
loggerUtil.consoleWarn("Insecure Database Password! Change Database password!");
|
||||
|
|
@ -57,4 +75,8 @@ module.exports.securityCheck = function(){
|
|||
loggerUtil.consoleWarn("Insecure Email Password! Change Email password!");
|
||||
}
|
||||
|
||||
//check debug mode
|
||||
if(config.debug){
|
||||
loggerUtil.consoleWarn("Debug mode enabled! Understand the risks and security implications before enabling on production servers!");
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -14,28 +14,113 @@ GNU Affero General Public License for more details.
|
|||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.*/
|
||||
|
||||
//Config
|
||||
const config = require('../../config.json');
|
||||
|
||||
//Node Imports
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
//NPM Imports
|
||||
const argon2 = require('argon2');
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
module.exports.hashPassword = function(pass){
|
||||
const salt = bcrypt.genSaltSync();
|
||||
return bcrypt.hashSync(pass, salt);
|
||||
/**
|
||||
* Sitewide function for hashing passwords
|
||||
* @param {String} pass - Password to hash
|
||||
* @returns {String} Hashed/Salted password
|
||||
*/
|
||||
module.exports.hashPassword = async function(pass){
|
||||
//Hash password with argon2id
|
||||
return await argon2.hash(pass, {secret: Buffer.from(config.secrets.passwordSecret)});
|
||||
}
|
||||
|
||||
module.exports.comparePassword = function(pass, hash){
|
||||
/**
|
||||
* Sitewide method for authenticating/comparing passwords agianst hashes
|
||||
* @param {String} pass - Plaintext Password
|
||||
* @param {String} hash - Salty Hash
|
||||
* @returns {Boolean} True if authentication success
|
||||
*/
|
||||
module.exports.comparePassword = async function(pass, hash){
|
||||
//Verify password against argon2 hash
|
||||
return await argon2.verify(hash, pass, {secret: Buffer.from(config.secrets.passwordSecret)});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sitewide method for authenticating/comparing passwords agianst hashes for legacy profiles
|
||||
* @param {String} pass - Plaintext Password
|
||||
* @param {String} hash - Salty Hash
|
||||
* @returns {Boolean} True if authentication success
|
||||
*/
|
||||
module.exports.compareLegacyPassword = function(pass, hash){
|
||||
return bcrypt.compareSync(pass, hash);
|
||||
}
|
||||
|
||||
module.exports.hashIP = function(ip){
|
||||
/**
|
||||
* Site-wide IP hashing/salting function
|
||||
*
|
||||
* Provides a basic level of privacy by only logging salted hashes of IP's
|
||||
* @param {String} ip - IP to hash
|
||||
* @param {String} salt - (optional) string to salt IP with, leave empty to default to a securely generated string encoded in base64
|
||||
* @returns {String} Hashed/Peppered/Salted IP Address
|
||||
*/
|
||||
module.exports.hashIP= function(ip, salt){
|
||||
//Create hash object
|
||||
const hashObj = crypto.createHash('md5');
|
||||
const hashObj = crypto.createHash('sha512');
|
||||
|
||||
//add IP to the hash
|
||||
hashObj.update(ip);
|
||||
//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,11 +16,22 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
|
|||
|
||||
//NPM Imports
|
||||
const validator = require('validator');//No express here, so regular validator it is!
|
||||
const {sanitizeUrl} = require("@braintree/sanitize-url");
|
||||
|
||||
//Create link cache
|
||||
/**
|
||||
* Basic RAM-Based cache of links, so we don't have to re-pull things after we get them
|
||||
*/
|
||||
module.exports.cache = new Map();
|
||||
|
||||
module.exports.markLink = async function(link){
|
||||
/**
|
||||
* Validates links and returns a marked link object that can be returned to the client to format/embed accordingly
|
||||
* @param {String} dirtyLink - URL to Validate
|
||||
* @returns {Object} Marked link object
|
||||
*/
|
||||
module.exports.markLink = async function(dirtyLink){
|
||||
const link = sanitizeUrl(dirtyLink);
|
||||
|
||||
//Check link cache for the requested link
|
||||
const cachedLink = module.exports.cache.get(link);
|
||||
|
||||
|
|
@ -36,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';
|
||||
|
||||
|
|
|
|||
|
|
@ -14,10 +14,44 @@ GNU Affero General Public License for more details.
|
|||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.*/
|
||||
|
||||
//Node
|
||||
const fs = require('node:fs/promises');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
//Config
|
||||
const config = require('../../config.json');
|
||||
|
||||
//At some point this will be a bit more advanced, right now it's just a placeholder :P
|
||||
/**
|
||||
* Creates and returns a custom exception, tagged as a 'custom' exception, using the 'custom' boolean property.
|
||||
* This is used to denote that this error was generated on purpose, with a human readable message, that can be securely sent to the client.
|
||||
* Unexpected exceptions should only be logged internally, however, as they may contain sensitive data.
|
||||
*
|
||||
* @param {String} msg - Error message to send the client
|
||||
* @param {String} type - Error type to send back to the client
|
||||
* @returns {Error} The exception to smith
|
||||
*/
|
||||
module.exports.exceptionSmith = function(msg, type){
|
||||
//Create the new error with the given message
|
||||
const exception = new Error(msg);
|
||||
|
||||
//Set the error type
|
||||
exception.type = type;
|
||||
|
||||
//Mark the error as a custom error
|
||||
exception.custom = true;
|
||||
|
||||
//Return the error
|
||||
return exception;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main error handling function
|
||||
* @param {Express.Response} res - Response being sent out to the client who caused the issue
|
||||
* @param {String} msg - Error message to send the client
|
||||
* @param {String} type - Error type to send back to the client
|
||||
* @param {Number} status - HTTP(s) Status Code to send back to the client
|
||||
* @returns {Express.Response} If we have a usable Express Response object, return it back after it's been cashed
|
||||
*/
|
||||
module.exports.errorHandler = function(res, msg, type = "Generic", status = 400){
|
||||
//Some controllers do things after sending headers, for those, we should remain silent
|
||||
if(!res.headersSent){
|
||||
|
|
@ -26,45 +60,103 @@ module.exports.errorHandler = function(res, msg, type = "Generic", status = 400)
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles local exceptions which where not directly created by user interaction
|
||||
* @param {Error} err - Exception to handle
|
||||
*/
|
||||
module.exports.localExceptionHandler = function(err){
|
||||
//If we're being verbose
|
||||
if(config.verbose){
|
||||
//If we're being verbose and this isn't just a basic bitch
|
||||
if(!err.custom && config.verbose){
|
||||
//Log the error
|
||||
console.log(err)
|
||||
module.exports.dumpError(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles exceptions which where directly the fault of user action >:(
|
||||
* @param {Express.Response} res - Express Response object to bitch at
|
||||
* @param {Error} err - Error created by the jerk in question
|
||||
*/
|
||||
module.exports.exceptionHandler = function(res, err){
|
||||
//Locally handle the exception
|
||||
module.exports.localExceptionHandler(err);
|
||||
//If this is a self-made problem
|
||||
if(err.custom){
|
||||
module.exports.errorHandler(res, err.message, err.type);
|
||||
}else{
|
||||
//Locally handle the exception
|
||||
module.exports.localExceptionHandler(err);
|
||||
|
||||
//if not yell at the browser for fucking up, and tell it what it did wrong.
|
||||
module.exports.errorHandler(res, err.message, "Caught Exception");
|
||||
//if not yell at the browser for fucking up, and tell it what it did wrong.
|
||||
module.exports.errorHandler(res, "An unexpected server crash was just prevented. You should probably report this to an admin.", "Caught Exception");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic error-handling for socket.io so we don't just silently swallow errors.
|
||||
* @param {Socket} socket - Socket error originated from
|
||||
* @param {String} msg - Error message to send the client
|
||||
* @param {String} type - Error type to send back to the client
|
||||
* @returns {Boolean} - Passthrough from socket.emit
|
||||
*/
|
||||
module.exports.socketErrorHandler = function(socket, msg, type = "Generic"){
|
||||
return socket.emit("error", {errors: [{type, msg, date: new Date()}]});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates error messages for simple errors generated by socket.io interaction
|
||||
* @param {Socket} socket - Socket error originated from
|
||||
* @param {Error} err - Error created by the jerk in question
|
||||
* @returns {Boolean} - Passthrough from socket.emit
|
||||
*/
|
||||
module.exports.socketExceptionHandler = function(socket, err){
|
||||
//Locally handle the exception
|
||||
module.exports.localExceptionHandler(err);
|
||||
//If this is a self made problem
|
||||
if(err.custom){
|
||||
//yell at the browser for fucking up, and tell it what it did wrong.
|
||||
return module.exports.socketErrorHandler(socket, err.message, err.type);
|
||||
}else{
|
||||
//Locally handle the exception
|
||||
module.exports.localExceptionHandler(err);
|
||||
|
||||
//if not yell at the browser for fucking up, and tell it what it did wrong.
|
||||
return module.exports.socketErrorHandler(socket, err.msg, "Caught Exception");
|
||||
//if not yell at the browser for fucking up
|
||||
return module.exports.socketErrorHandler(socket, "An unexpected server crash was just prevented. You should probably report this to an admin.", "Server");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates error messages and drops connection for critical errors caused by socket.io interaction
|
||||
* @param {Socket} socket - Socket error originated from
|
||||
* @param {Error} err - Error created by the jerk in question
|
||||
* @returns {Boolean} - Passthrough from socket.disconnect
|
||||
*/
|
||||
module.exports.socketCriticalExceptionHandler = function(socket, err){
|
||||
//if not yell at the browser for fucking up, and tell it what it did wrong.
|
||||
socket.emit("kick", {type: "Disconnected", reason: `Server Error: ${err.message}`});
|
||||
//If this is a self made problem
|
||||
if(err.custom){
|
||||
//yell at the browser for fucking up, and tell it what it did wrong.
|
||||
socket.emit("kick", {type: "Disconnected", reason: `Server Error: ${err.message}`});
|
||||
}else{
|
||||
//Locally handle the exception
|
||||
module.exports.localExceptionHandler(err);
|
||||
|
||||
//yell at the browser for fucking up
|
||||
socket.emit("kick", {type: "Disconnected", reason: "An unexpected server crash was just prevented. You should probably report this to an admin."});
|
||||
}
|
||||
return socket.disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints warning text to server console
|
||||
* @param {String} string - String to print to console
|
||||
*/
|
||||
module.exports.consoleWarn = function(string){
|
||||
console.warn('\x1b[31m\x1b[4m%s\x1b[0m',string);
|
||||
}
|
||||
|
||||
//Basic error-handling middleware to ensure we're not dumping stack traces
|
||||
/**
|
||||
* Basic error-handling middleware to ensure we're not dumping stack traces to the client, as that would be insecure
|
||||
* @param {Error} err - Error to handle
|
||||
* @param {Express.Request} req - Express Request
|
||||
* @param {Express.Response} res - Express Response
|
||||
* @param {Function} next - Next function in the Express middleware chain (Not that it's getting called XP)
|
||||
*/
|
||||
module.exports.errorMiddleware = function(err, req, res, next){
|
||||
//Set generic error
|
||||
var reason = "Server Error";
|
||||
|
|
@ -75,4 +167,96 @@ module.exports.errorMiddleware = function(err, req, res, next){
|
|||
}
|
||||
|
||||
module.exports.errorHandler(res, err.message, reason, err.status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dumps unexpected server crashes to dedicated log files
|
||||
* @param {Error} err - error to dump to file
|
||||
* @param {Date} date - Date of error, defaults to now
|
||||
* @param {String} subDir - subdirectory inside the log folder we want to dump to
|
||||
* @param {Boolean} muzzle - Tells the function to STFU
|
||||
*/
|
||||
module.exports.dumpError = async function(err, date = new Date(), subDir = 'crash/', muzzle = false){
|
||||
//Generate content from error
|
||||
const content = `Error Date: ${date.toLocaleString()} (UTC-${date.getTimezoneOffset()/60})\nError Type: ${err.name}\nError Msg:${err.message}\nStack Trace:\n\n${err.stack}`;
|
||||
|
||||
//Dump text to file
|
||||
module.exports.dumpLog(content, date.getTime(), subDir, muzzle);
|
||||
}
|
||||
|
||||
|
||||
module.exports.dumpSecurityLog = async function(content, date = new Date()){
|
||||
module.exports.dumpLog(content, `Incident-{${crypto.randomUUID()}}-${date.getTime()}`, 'security/', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dumps log file to log folder
|
||||
* @param {String} content - Text to dump to file
|
||||
* @param {String} name - file name to save to
|
||||
* @param {String} subDir - subdirectory inside the log folder we want to dump to
|
||||
* @param {Boolean} muzzle - Tells the function to STFU
|
||||
*/
|
||||
module.exports.dumpLog = async function(content, name, subDir = '/', muzzle = false){
|
||||
try{
|
||||
//Crash directory
|
||||
const dir = `./log/${subDir}`
|
||||
|
||||
//Double check crash folder exists
|
||||
try{
|
||||
await fs.stat(dir);
|
||||
//If we caught an error (most likely it's missing)
|
||||
}catch(err){
|
||||
if(!muzzle){
|
||||
//Shout about it
|
||||
module.exports.consoleWarn("Log folder missing, mking dir!")
|
||||
}
|
||||
|
||||
//Make it if doesn't
|
||||
await fs.mkdir(dir, {recursive: true});
|
||||
}
|
||||
|
||||
//Assemble log file path
|
||||
const path = `${dir}${name}.log`;
|
||||
|
||||
//Write content to file
|
||||
fs.writeFile(path, content);
|
||||
|
||||
if(!muzzle){
|
||||
//Whine about the error
|
||||
module.exports.consoleWarn(`Warning: Unexpected Server Crash gracefully dumped to '${path}'... SOMETHING MAY BE VERY BROKEN!!!!`);
|
||||
}
|
||||
//If somethine went really really wrong
|
||||
}catch(doubleErr){
|
||||
if(!muzzle){
|
||||
//Use humor to cope with the pain
|
||||
module.exports.consoleWarn("Yo Dawg, I herd you like errors, so I put an error in your error dump, so you can dump while you dump:");
|
||||
//Dump the original error to console
|
||||
module.exports.consoleWarn(err);
|
||||
//Dump the error we had saving that error to file to console
|
||||
module.exports.consoleWarn(doubleErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.welcomeWagon = function(count, date, tokes){
|
||||
//Inject values into ascii art
|
||||
const art = `
|
||||
\x1b[32m ! \x1b[0m
|
||||
\x1b[32m 420 \x1b[0m \x1b[32m\x1b[40m${config.instanceName}\x1b[0m\x1b[2m, Powered By:\x1b[0m
|
||||
\x1b[32m 420 \x1b[0m
|
||||
\x1b[32m WEEED \x1b[0m CCCC AAA NN N OOO PPPP Y Y
|
||||
\x1b[32m! WEEED !\x1b[0m C A A NN N O O P P Y Y
|
||||
\x1b[32mWEE EEEEE EED\x1b[0m C A A N N N O O P P Y Y
|
||||
\x1b[32m WEE EEEEE EED\x1b[0m C AAAAA N N N O O PPPP Y
|
||||
\x1b[32m WEE EEE EED\x1b[0m C A A N N N O O P Y
|
||||
\x1b[32m WEE EEE EED\x1b[0m C A A N NN O O P Y
|
||||
\x1b[32m WEEEEED\x1b[0m CCCC A A N NN OOO P Y
|
||||
\x1b[32m WEEE ! EEED\x1b[0m
|
||||
\x1b[32m !\x1b[0m \x1b[34mInitialization Complete!\x1b[0m This server has booted \x1b[4m${count}\x1b[0m time${count == 1 ? '' : 's'} and taken \x1b[4m${tokes}\x1b[0m toke${tokes == 1 ? '' : 's'}.
|
||||
\x1b[32m !\x1b[0m This server was first booted on \x1b[4m${date}\x1b[0m.`
|
||||
|
||||
//Dump art to console
|
||||
console.log(art);
|
||||
//Add some extra padding for the port printout from server.js
|
||||
process.stdout.write(' ');
|
||||
}
|
||||
|
|
@ -19,8 +19,16 @@ const config = require('../../config.json');
|
|||
|
||||
//NPM imports
|
||||
const nodeMailer = require("nodemailer");
|
||||
const validator = require('validator');
|
||||
|
||||
//local imports
|
||||
const loggerUtils = require('./loggerUtils');
|
||||
|
||||
|
||||
//Setup mail transport
|
||||
/**
|
||||
* nodemailer transport object, generated from options specific in our config file
|
||||
*/
|
||||
const transporter = nodeMailer.createTransport({
|
||||
host: config.mail.host,
|
||||
port: config.mail.port,
|
||||
|
|
@ -31,40 +39,88 @@ const transporter = nodeMailer.createTransport({
|
|||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Sends an email as tokebot to the requested user w/ the requested body and signature
|
||||
* @param {String} to - String containing the email address to send to
|
||||
* @param {String} subject - Subject line of the email to send
|
||||
* @param {String} body - Body contents, either HTML or Plaintext
|
||||
* @param {Boolean} htmlBody - Whether or not Body contents should be sent as HTML or Plaintext
|
||||
* @returns {Object} Sent mail info
|
||||
*/
|
||||
module.exports.mailem = async function(to, subject, body, htmlBody = false){
|
||||
//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;
|
||||
}
|
||||
|
||||
module.exports.sendAddressVerification = async function(requestDB, userDB, newEmail){
|
||||
/**
|
||||
* Sends address verification email
|
||||
* @param {Mongoose.Document} requestDB - DB Document Object for the current email change request token
|
||||
* @param {Mongoose.Document} userDB - DB Document Object for the user we're verifying email against
|
||||
* @param {String} newEmail - New email address to send to
|
||||
* @param {Boolean} newUser - Denotes an email going out to a new account
|
||||
* @param {Boolean} migration - Denotes an email going out to an account which was just mirated
|
||||
*/
|
||||
module.exports.sendAddressVerification = async function(requestDB, userDB, newEmail, newUser = false, migration = false,){
|
||||
let subject = `Email Change Request - ${userDB.user}`;
|
||||
let content = `<h1>email change request</h1>
|
||||
<p>a request to change the email associated with the ${config.instanceName} account '${userDB.user}' to this address has been requested.<br>
|
||||
<a href="${requestDB.getChangeURL()}">click here</a> to confirm this change.</p>
|
||||
<sup>if you received this email without request, feel free to ignore and delete it! -tokebot</sup>`;
|
||||
|
||||
if(newUser){
|
||||
subject = `New User Email Confirmation - ${userDB.user}`;
|
||||
|
||||
content = `<h1>New user email confirmation</h1>
|
||||
<p>a new ${config.instanceName} account '${userDB.user}' was created with this email address.<br>
|
||||
<a href="${requestDB.getChangeURL()}">click here</a> to confirm this change.</p>
|
||||
<sup>if you received this email without request, feel free to ignore and delete it! -tokebot</sup>`;
|
||||
}
|
||||
|
||||
if(migration){
|
||||
subject = `User Migration Email Confirmation - ${userDB.user}`;
|
||||
|
||||
content = `<h1>User migration email confirmation</h1>
|
||||
<p>The ${config.instanceName} account '${userDB.user}' was successfully migrated to our <a href="https://git.ourfore.st/rainbownapkin/canopy">fancy new codebase</a>.<br>
|
||||
<a href="${requestDB.getChangeURL()}">click here</a> to confirm this change.</p>
|
||||
<sup>if you received this email without request, reach out to an admin, as your old account might be getting jacked! -tokebot</sup>`;
|
||||
}
|
||||
|
||||
|
||||
//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
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -15,24 +15,22 @@ 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 url = require("node:url");
|
||||
const validator = require('validator');
|
||||
|
||||
//Local Imports
|
||||
const regexUtils = require('../regexUtils');
|
||||
const media = require('../../app/channel/media/media');
|
||||
const media = require('../../app/channel/media/media.js');
|
||||
const regexUtils = require('../regexUtils.js');
|
||||
const loggerUtils = require('../loggerUtils.js')
|
||||
|
||||
module.exports.fetchMetadata = async function(link, title){
|
||||
//Parse link
|
||||
const parsedLink = new url.URL(link);
|
||||
//Split link path
|
||||
const splitPath = parsedLink.pathname.split('/');
|
||||
//Get ItemID from link path
|
||||
const itemID = splitPath[2]
|
||||
//Splice the empty string, request type, and item ID out from link path
|
||||
splitPath.splice(0,3)
|
||||
//Join remaining link path back together to get requested file path within the given archive.org upload
|
||||
const requestedPath = decodeURIComponent(splitPath.join('/'));
|
||||
/**
|
||||
* Pulls metadate for a given archive.org item
|
||||
* @param {String} fullID - Full path of the requested upload
|
||||
* @param {String} title - Title to add to media object
|
||||
* @returns {Array} Generated list of media objects from given upload path
|
||||
*/
|
||||
module.exports.fetchMetadata = async function(fullID, title){
|
||||
//Split fullID by first slash
|
||||
const [itemID, requestedPath] = decodeURIComponent(fullID).split(/\/(.*)/);
|
||||
//Create empty list to hold media objects
|
||||
const mediaList = [];
|
||||
//Create empty variable to hold return data object
|
||||
|
|
@ -52,7 +50,7 @@ module.exports.fetchMetadata = async function(link, title){
|
|||
if(!response.ok){
|
||||
//Scream and shout
|
||||
const errorBody = await response.text();
|
||||
throw new Error(`Internet Archive Error '${response.status}': ${errorBody}`);
|
||||
throw loggerUtils.exceptionSmith(`Internet Archive Error '${response.status}': ${errorBody}`, "queue");
|
||||
}
|
||||
|
||||
//Collect our metadata
|
||||
|
|
@ -63,7 +61,7 @@ module.exports.fetchMetadata = async function(link, title){
|
|||
|
||||
|
||||
//If we're requesting an empty path
|
||||
if(requestedPath == ''){
|
||||
if(requestedPath == '' || requestedPath == null){
|
||||
//Return item metadata and compatible files
|
||||
data = {
|
||||
files: compatibleFiles,
|
||||
|
|
@ -105,7 +103,7 @@ module.exports.fetchMetadata = async function(link, title){
|
|||
|
||||
function compatibilityFilter(file){
|
||||
//return true for all files that match for web-safe formats
|
||||
return file.format == "h.264 IA" || file.format == "h.264" || file.format == "Ogg Video" || file.format.match("MPEG4");
|
||||
return file.format.match(/^h\.264/) || file.format == "Ogg Video" || file.format.match("MPEG4");
|
||||
}
|
||||
|
||||
function pathFilter(file){
|
||||
|
|
|
|||
|
|
@ -15,41 +15,159 @@ You should have received a copy of the GNU Affero General Public License
|
|||
along with this program. If not, see <https://www.gnu.org/licenses/>.*/
|
||||
|
||||
//NPM Imports
|
||||
//const url = require("node:url");
|
||||
const validator = require('validator');//No express here, so regular validator it is!
|
||||
const {sanitizeUrl} = require("@braintree/sanitize-url");
|
||||
|
||||
//local import
|
||||
const iaUtil = require('./internetArchiveUtils');
|
||||
const ytdlpUtil = require('./ytdlpUtils');
|
||||
|
||||
/**
|
||||
* Checks a given URL and runs the proper metadata fetching function to create a media object from any supported URL
|
||||
* @param {String} url - URL to yank media against
|
||||
* @param {String} title - Title to apply to yanked media
|
||||
* @returns {Array} Returns list of yanked media objects on success
|
||||
*/
|
||||
module.exports.yankMedia = async function(url, title){
|
||||
//Get pull type
|
||||
const pullType = await this.getMediaType(url);
|
||||
|
||||
//Check pull type
|
||||
switch(pullType){
|
||||
switch(pullType.type){
|
||||
case "ia":
|
||||
//return media object list from IA module
|
||||
return await iaUtil.fetchMetadata(url, title);
|
||||
return await iaUtil.fetchMetadata(pullType.id, title);
|
||||
case "yt":
|
||||
//return media object list from the YT-DLP module's youtube function
|
||||
return await ytdlpUtil.fetchYoutubeMetadata(pullType.id, title);
|
||||
case "ytp":
|
||||
//return media object list from YT-DLP module's youtube playlist function
|
||||
//return await ytdlpUtil.fetchYoutubePlaylistMetadata(pullType.id, title);
|
||||
//Holding off on this since YT-DLP takes 10 years to do a playlist as it needs to pull each and every video one-by-one
|
||||
//Maybe in the future a piped alternative might be in order, however this would most likely require us to host our own local instance.
|
||||
//Though it could give us added resistance against youtube/google's rolling IP bans
|
||||
return null;
|
||||
case "dm":
|
||||
//return mediao object list from the YT-DLP module's dailymotion function
|
||||
return await ytdlpUtil.fetchDailymotionMetadata(pullType.id, title);
|
||||
default:
|
||||
//return null to signify a bad url
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.getMediaType = async function(url){
|
||||
//Encode URI in-case we where handed something a little too humie friendly
|
||||
url = encodeURI(url);
|
||||
/**
|
||||
* Refreshes raw links on relevant media objects
|
||||
*
|
||||
* Useful for sources like youtube, who only provide expiring raw links
|
||||
* @param {ScheduledMedia} mediaObj - Media Object to refresh
|
||||
* @returns {ScheduledMedia} Refreshed media object
|
||||
*/
|
||||
module.exports.refreshRawLink = async function(mediaObj){
|
||||
switch(mediaObj.type){
|
||||
case 'yt':
|
||||
//Create boolean to hold expired state
|
||||
let expired = false;
|
||||
//Create boolean to hold whether or not rawLink object is empty
|
||||
let empty = true;
|
||||
|
||||
//Check if we have a valid url
|
||||
if(!validator.isURL(url)){
|
||||
//If not toss the fucker out
|
||||
return null;
|
||||
}
|
||||
|
||||
//If we have link to a resource from archive.org
|
||||
if(url.match(/^https\:\/\/archive.org\//g)){
|
||||
//return internet archive code
|
||||
return "ia";
|
||||
//For each link map in the rawLink object
|
||||
for(const key of Object.keys(mediaObj.rawLink)){
|
||||
//Ignore da wombo-combo since it's probably just the fuckin regular URL
|
||||
if(key != "combo"){
|
||||
for(const link of mediaObj.rawLink[key]){
|
||||
//Let it be known, this bitch got links
|
||||
empty = false;
|
||||
//Get expiration parameter from the link
|
||||
const expires = new URL(link[1]).searchParams.get("expire") * 1000;
|
||||
|
||||
//If this shit's already expired
|
||||
if(expires < Date.now()){
|
||||
//Set expired to true, don't directly set the bool because we don't ever want to unset this flag
|
||||
expired = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//If the raw link object is empty or expired
|
||||
if(empty || expired){
|
||||
//Re-fetch media metadata
|
||||
metadata = await ytdlpUtil.fetchYoutubeMetadata(mediaObj.id);
|
||||
|
||||
//Refresh media rawlink from metadata
|
||||
mediaObj.rawLink = metadata[0].rawLink;
|
||||
|
||||
//return media object
|
||||
return mediaObj;
|
||||
}
|
||||
}
|
||||
|
||||
//Return null to tell the calling function there is no refresh required for this media type
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* Detects media type by URL
|
||||
*
|
||||
* I'd be lying if this didn't take at least some inspiration/regex patterns from extractQueryParam() in cytube/forest's browser-side 'util.js'
|
||||
* Still this has some improvements like url pre-checks and the fact that it's handled serverside, recuing possibility of bad requests.
|
||||
* Some of the regex expressions for certain services have also been improved, such as youtube, and the fore.st-unique archive.org
|
||||
*
|
||||
* @param {String} dirtyURL - URL to determine media type of
|
||||
* @returns {Object} containing URL type and clipped ID string
|
||||
*/
|
||||
module.exports.getMediaType = async function(dirtyURL){
|
||||
//Sanatize our URL
|
||||
const url = sanitizeUrl(dirtyURL);
|
||||
|
||||
//Check if we have a valid url, encode it on the fly in case it's too humie-friendly
|
||||
if(!validator.isURL(encodeURI(url,{require_valid_protocol: true}))){
|
||||
//If not toss the fucker out
|
||||
return {
|
||||
type: null,
|
||||
id: null
|
||||
}
|
||||
}
|
||||
|
||||
//If we have link to a resource from archive.org
|
||||
if(match = url.match(/archive\.org\/(?:details|download)\/([a-zA-Z0-9\/._-\s\%]+)/)){
|
||||
//return internet archive upload id and filepath
|
||||
return {
|
||||
type: "ia",
|
||||
id: match[1]
|
||||
}
|
||||
}
|
||||
|
||||
//If we have a match to a youtube video
|
||||
if((match = url.match(/youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})/)) || (match = url.match(/youtu\.be\/([a-zA-Z0-9_-]{11})/))){
|
||||
//return youtube video id
|
||||
return {
|
||||
type: "yt",
|
||||
id: match[1]
|
||||
}
|
||||
}
|
||||
|
||||
//If we have a match to a youtube playlist
|
||||
if((match = url.match(/youtube\.com\/playlist\?list=([a-zA-Z0-9_-]{34})/)) || (match = url.match(/youtu\.be\/playlist\?list=([a-zA-Z0-9_-]{34})/))){
|
||||
//return youtube playlist id
|
||||
return {
|
||||
type: "ytp",
|
||||
id: match[1]
|
||||
}
|
||||
}
|
||||
|
||||
//If we have a match to a dailymotion video
|
||||
if(match = url.match(/dailymotion\.com\/video\/([a-zA-Z0-9]+)/)){
|
||||
return {
|
||||
type: "dm",
|
||||
id: match[1]
|
||||
}
|
||||
}
|
||||
|
||||
//If we fell through all of our media types without a match
|
||||
return{
|
||||
type: null,
|
||||
id: null
|
||||
}
|
||||
}
|
||||
169
src/utils/media/ytdlpUtils.js
Normal file
169
src/utils/media/ytdlpUtils.js
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
/*Canopy - The next generation of stoner streaming software
|
||||
Copyright (C) 2024-2025 Rainbownapkin and the TTN Community
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.*/
|
||||
|
||||
//Config
|
||||
const config = require('../../../config.json');
|
||||
|
||||
//Node Imports
|
||||
const { create: ytdlpMaker } = require('youtube-dl-exec');
|
||||
//Import ytdlp w/ custom path from config so we can force the newest build of yt-dlp from pip
|
||||
const ytdlp = ytdlpMaker(config.ytdlpPath);
|
||||
const url = require("node:url");
|
||||
const validator = require('validator');
|
||||
|
||||
//Local Imports
|
||||
const media = require('../../app/channel/media/media.js');
|
||||
const regexUtils = require('../regexUtils.js');
|
||||
const loggerUtils = require('../loggerUtils.js')
|
||||
|
||||
/**
|
||||
* Pulls metadata for a single youtube video via YT-DLP
|
||||
* @param {String} id - Youtube Video ID
|
||||
* @param {String} title - Title to add to the given media object
|
||||
* @returns {Media} Media object containing relevant metadata
|
||||
*/
|
||||
module.exports.fetchYoutubeMetadata = async function(id, title){
|
||||
try{
|
||||
//Try to pull media from youtube id
|
||||
const media = await fetchVideoMetadata(`https://youtu.be/${id}`, title, 'yt');
|
||||
|
||||
//Return found media
|
||||
return media;
|
||||
//If something went wrong
|
||||
}catch(err){
|
||||
//If our IP was banned by youtube
|
||||
if(err.message.match("Sign in to confirm you’re not a bot.")){
|
||||
//Make our own error with blackjack and hookers
|
||||
throw loggerUtils.exceptionSmith("The server's IP address has been banned by youtube. Please contact your server's administrator.", "queue");
|
||||
//Otherwise if we don't have a good way to handle it
|
||||
}else{
|
||||
//toss it back up
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls metadata for a playlist of youtube videos via YT-DLP
|
||||
* @param {String} id - Youtube Playlist ID
|
||||
* @param {String} title - Title to add to the given media objects
|
||||
* @returns {Array} Array of Media objects containing relevant metadata
|
||||
*/
|
||||
module.exports.fetchYoutubePlaylistMetadata = async function(id, title){
|
||||
try{
|
||||
//Try to pull media from youtube id
|
||||
const media = await fetchPlaylistMetadata(`https://youtu.be/playlist?list=${id}`, title, 'yt');
|
||||
|
||||
//Return found media
|
||||
return media;
|
||||
//If something went wrong
|
||||
}catch(err){
|
||||
//If our IP was banned by youtube
|
||||
if(err.message.match("Sign in to confirm you’re not a bot.")){
|
||||
//Make our own error with blackjack and hookers
|
||||
throw loggerUtils.exceptionSmith("The server's IP address has been banned by youtube. Please contact your server's administrator.", "queue");
|
||||
//Otherwise if we don't have a good way to handle it
|
||||
}else{
|
||||
//toss it back up
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* This requires HLS embeds which, in-turn, require daily motion to add us to their CORS exception list
|
||||
* Not gonna happen, so we need to use their API for this, or proxy the video
|
||||
module.exports.fetchDailymotionMetadata = async function(id, title){
|
||||
//Pull media from dailymotion link
|
||||
const media = await fetchVideoMetadata(`https://dailymotion.com/video/${id}`, title, 'dm');
|
||||
|
||||
//Return found media;
|
||||
return media;
|
||||
}*/
|
||||
|
||||
/**
|
||||
* Generic single video YTDLP function meant to be used by service-sepecific fetchers which will then be used to fetch video metadata
|
||||
* @param {String} link - Link to video in question
|
||||
* @param {String} title - Title to add to the given media objects
|
||||
* @param {String} type - Link type to attach to the resulting media object
|
||||
* @returns {Array} Array of Media objects containing relevant metadata
|
||||
*/
|
||||
async function fetchVideoMetadata(link, title, type, format = 'ba,bv'){
|
||||
//Create media list
|
||||
const mediaList = [];
|
||||
|
||||
//Pull raw metadata from YT-DLP
|
||||
const rawMetadata = await ytdlpFetch(link, format);
|
||||
|
||||
//Pull data from rawMetadata, sanatizing title to prevent XSS
|
||||
const name = validator.escape(validator.trim(rawMetadata.title));
|
||||
|
||||
//Create new raw link object (should we make a class? Probably over kill for a fucking method-less hashtable)
|
||||
const rawLinks = {
|
||||
audio: [],
|
||||
video: [],
|
||||
combo: []
|
||||
}
|
||||
|
||||
//for each item
|
||||
for(const link of rawMetadata.requested_downloads){
|
||||
//if there isn't video included
|
||||
if(link.vcodec == 'none'){
|
||||
//Add the link under the format within the audio map
|
||||
rawLinks.audio.push([link.format_note, link.url]);
|
||||
//if there isn't audio included
|
||||
}else if(link.acodec == 'none'){
|
||||
//Add the link under the format within the video map
|
||||
rawLinks.video.push([link.format_note, link.url]);
|
||||
//otherwise, it includes audio and video
|
||||
}else{
|
||||
//Add the link under the format within the combo map
|
||||
rawLinks.combo.push([link.format_note, link.url]);
|
||||
}
|
||||
}
|
||||
|
||||
const id = rawMetadata.id;
|
||||
|
||||
//if we where handed a null title
|
||||
if(title == null || title == ''){
|
||||
//Create new media object from file info substituting filename for title
|
||||
mediaList.push(new media(name, name, link, id, type, Number(rawMetadata.duration), rawLinks));
|
||||
}else{
|
||||
//Create new media object from file info
|
||||
mediaList.push(new media(title, name, link, id, type, Number(rawMetadata.duration), rawLinks));
|
||||
}
|
||||
|
||||
//Return list of media
|
||||
return mediaList;
|
||||
}
|
||||
|
||||
//YT-DLP takes forever to handle playlists, we'll handle this via piped in the future perhaps
|
||||
/*async function fetchPlaylistMetadata(link, title, type, format = 'b'){
|
||||
}*/
|
||||
|
||||
//Wrapper function for YT-DLP NPM package with pre-set cli-flags
|
||||
/**
|
||||
* Basic async YT-DLP Fetch wrapper, ensuring config
|
||||
* @param {String} link - Link to fetch using YT-DLP
|
||||
* @param {String} format - Format string to hand YT-DLP, defaults to 'b'
|
||||
* @returns {Object} Metadata dump from YT-DLP
|
||||
*/
|
||||
async function ytdlpFetch(link, format = 'ba,ogg'){
|
||||
//return promise from ytdlp
|
||||
return ytdlp(link, {
|
||||
format,
|
||||
dumpSingleJson: true,
|
||||
});
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -14,10 +14,15 @@ GNU Affero General Public License for more details.
|
|||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.*/
|
||||
|
||||
/**
|
||||
* I won't lie this line was whole-sale ganked from stack overflow like a fucking skid
|
||||
* In my defense I only did it because js-runtime-devs are taking fucking eons to implement RegExp.escape()
|
||||
* This should be replaced once that function becomes available in mainline versions of node.js:
|
||||
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/escape
|
||||
*
|
||||
* @param {String} string - Regex string to escape
|
||||
* @returns {String} The Escaped String
|
||||
*/
|
||||
module.exports.escapeRegex = function(string){
|
||||
/* I won't lie this line was whole-sale ganked from stack overflow like a fucking skid
|
||||
In my defense I only did it because js-runtime-devs are taking fucking eons to implement RegExp.escape()
|
||||
This should be replaced once that function becomes available in mainline versions of node.js:
|
||||
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/escape */
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
|
@ -22,10 +22,13 @@ const {userModel} = require('../schemas/user/userSchema');
|
|||
const userBanModel = require('../schemas/user/userBanSchema');
|
||||
const passwordResetModel = require('../schemas/user/passwordResetSchema');
|
||||
const emailChangeModel = require('../schemas/user/emailChangeSchema');
|
||||
const rememberMeModel = require('../schemas/user/rememberMeSchema');
|
||||
const channelModel = require('../schemas/channel/channelSchema');
|
||||
const sessionUtils = require('./sessionUtils');
|
||||
const { email } = require('../validators/accountValidator');
|
||||
|
||||
/**
|
||||
* Schedules all timed jobs accross the server
|
||||
*/
|
||||
module.exports.schedule = function(){
|
||||
//Process hashed IP Records that haven't been recorded in a week or more
|
||||
cron.schedule('0 0 * * *', ()=>{userModel.processAgedIPRecords()},{scheduled: true, timezone: "UTC"});
|
||||
|
|
@ -39,19 +42,26 @@ module.exports.schedule = function(){
|
|||
cron.schedule('0 0 * * *', ()=>{passwordResetModel.processExpiredRequests()},{scheduled: true, timezone: "UTC"});
|
||||
//Process expired email change requests every night at midnight
|
||||
cron.schedule('0 0 * * *', ()=>{emailChangeModel.processExpiredRequests()},{scheduled: true, timezone: "UTC"});
|
||||
//Process expired remember me tokens every night at midnight
|
||||
cron.schedule('0 0 * * *', ()=>{rememberMeModel.processExpiredTokens()},{scheduled: true, timezone: "UTC"});
|
||||
}
|
||||
|
||||
/**
|
||||
* Kicks off first run of scheduled functions before scheduling functions for regular callback
|
||||
*/
|
||||
module.exports.kickoff = function(){
|
||||
//Process Hashed IP Records that haven't been recorded in a week or more
|
||||
userModel.processAgedIPRecords();
|
||||
//Process expired global bans that may have expired since last restart
|
||||
userBanModel.processExpiredBans()
|
||||
userBanModel.processExpiredBans();
|
||||
//Process expired channel bans that may have expired since last restart
|
||||
channelModel.processExpiredBans();
|
||||
//Process expired password reset requests that may have expired since last restart
|
||||
passwordResetModel.processExpiredRequests();
|
||||
//Process expired email change requests that may have expired since last restart
|
||||
emailChangeModel.processExpiredRequests();
|
||||
//Process expired remember me tokens that may have expired since last restart
|
||||
rememberMeModel.processExpiredTokens()
|
||||
|
||||
//Schedule jobs
|
||||
module.exports.schedule();
|
||||
|
|
|
|||
|
|
@ -14,38 +14,77 @@ 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 {userModel} = require('../schemas/user/userSchema');
|
||||
const userBanModel = require('../schemas/user/userBanSchema')
|
||||
const altchaUtils = require('../utils/altchaUtils');
|
||||
//npm imports
|
||||
const {validationResult, matchedData} = require('express-validator');
|
||||
|
||||
//Create failed sign-in cache since it's easier and more preformant to implement it this way than adding extra burdon to the database
|
||||
//Server restarts are far and few between. It would take multiple during a single bruteforce attempt for this to become an issue.
|
||||
//Local Imports
|
||||
const config = require('../../config.json');
|
||||
const {userModel} = require('../schemas/user/userSchema.js');
|
||||
const userBanModel = require('../schemas/user/userBanSchema.js');
|
||||
const rememberMeModel = require('../schemas/user/rememberMeSchema.js');
|
||||
const altchaUtils = require('../utils/altchaUtils.js');
|
||||
const loggerUtils = require('../utils/loggerUtils.js');
|
||||
|
||||
/**
|
||||
* Create failed sign-in cache since it's easier and more preformant to implement it this way than adding extra burdon to the database
|
||||
* Server restarts are far and few between. It would take multiple during a single bruteforce attempt for this to become an issue.
|
||||
*/
|
||||
const failedAttempts = new Map();
|
||||
|
||||
/**
|
||||
* How many failed attempts required to throttle with altcha
|
||||
*/
|
||||
const throttleAttempts = 5;
|
||||
|
||||
/**
|
||||
* How many attempts to lock user account out for the day
|
||||
*/
|
||||
const maxAttempts = 200;
|
||||
|
||||
//this module is good for keeping wrappers for userModel and other shit in that does more session handling than database access/modification.
|
||||
module.exports.authenticateSession = async function(user, pass, req){
|
||||
/**
|
||||
* Sole and Singular Session Authentication method.
|
||||
* All logins should happen through here, all other site-wide authentication should happen by sessions authenticated by this model.
|
||||
* This is important, as reducing authentication endpoints reduces attack surface.
|
||||
*
|
||||
* Ended up not splitting this in two/three for remember-me tokens. Kind of fucked up it was actually easier this way...
|
||||
* @param {String} identifier - Identifer used to identify account, either username or token UUID
|
||||
* @param {String} secret - Secret to authenticate session with, either password or token secret
|
||||
* @param {express.Request} req - Express request object w/ session to authenticate
|
||||
* @param {Boolean} useRememberMeToken - Whether or not we're using username/pass or remember-me tokens
|
||||
* @returns Username of authticated user upon success
|
||||
*/
|
||||
module.exports.authenticateSession = async function(identifier, secret, req, useRememberMeToken = false){
|
||||
//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;
|
||||
|
||||
//Look for ban by IP
|
||||
const ipBanDB = await userBanModel.checkBanByIP(req.ip);
|
||||
const ipBanDB = await userBanModel.checkBanByIP(ip);
|
||||
|
||||
//If this ip is randy bobandy
|
||||
if(ipBanDB != null){
|
||||
//tell it to fuck off
|
||||
throw new Error(`The IP address you are trying to login from has been banned.`);
|
||||
//Make the number a little prettier despite the lack of precision since we're not doing calculations here :P
|
||||
const expiration = ipBanDB.getDaysUntilExpiration() < 1 ? 0 : ipBanDB.getDaysUntilExpiration();
|
||||
|
||||
//If the ban is permanent
|
||||
if(ipBanDB.permanent){
|
||||
//tell it to fuck off
|
||||
throw loggerUtils.exceptionSmith(`The IP address you are trying to login from has been permanently banned. Your cleartext IP has been saved to the database. Any associated accounts will be nuked in ${expiration} day(s).`, "unauthorized");
|
||||
}else{
|
||||
//tell it to fuck off
|
||||
throw loggerUtils.exceptionSmith(`The IP address you are trying to login from has been temporarily banned. Your cleartext IP has been saved to the database until the ban expires in ${expiration} day(s).`, "unauthorized");
|
||||
}
|
||||
}
|
||||
|
||||
//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 new Error("This account has been locked for at 24 hours due to a large amount of failed log-in attempts");
|
||||
throw loggerUtils.exceptionSmith("This account has been locked for at 24 hours due to a large amount of failed log-in attempts", "unauthorized");
|
||||
}
|
||||
|
||||
//If we're throttling logins
|
||||
|
|
@ -53,15 +92,24 @@ module.exports.authenticateSession = async function(user, pass, req){
|
|||
//Verification doesnt get sanatized or checked since that would most likely break the cryptography
|
||||
//Since we've already got access to the request and dont need to import anything, why bother getting it from a parameter?
|
||||
if(req.body.verification == null){
|
||||
throw new Error("Verification failed!");
|
||||
}else if(!altchaUtils.verify(req.body.verification, user)){
|
||||
throw new Error("Verification failed!");
|
||||
throw loggerUtils.exceptionSmith("Verification failed!", "unauthorized");
|
||||
}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);
|
||||
|
|
@ -71,9 +119,9 @@ module.exports.authenticateSession = async function(user, pass, req){
|
|||
//Make the number a little prettier despite the lack of precision since we're not doing calculations here :P
|
||||
const expiration = userBanDB.getDaysUntilExpiration() < 1 ? 0 : userBanDB.getDaysUntilExpiration();
|
||||
if(userBanDB.permanent){
|
||||
throw new Error(`Your account has been permanently banned, and will be nuked from the database in: ${expiration} day(s)`);
|
||||
throw loggerUtils.exceptionSmith(`Your account has been permanently banned, and will be nuked from the database in: ${expiration} day(s)`, "unauthorized");
|
||||
}else{
|
||||
throw new Error(`Your account has been temporarily banned, and will be reinstated in: ${expiration} day(s)`);
|
||||
throw loggerUtils.exceptionSmith(`Your account has been temporarily banned, and will be reinstated in: ${expiration} day(s)`, "unauthorized");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -89,49 +137,68 @@ module.exports.authenticateSession = async function(user, pass, req){
|
|||
}
|
||||
|
||||
//Tattoo hashed IP address to user account for seven days
|
||||
userDB.tattooIPRecord(req.ip);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs user out and destroys all server-side traces of a given session
|
||||
* @param {express-session.session} session
|
||||
*/
|
||||
module.exports.killSession = async function(session){
|
||||
session.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns how many failed login attempts within the past day or so since the last login has occured for a given user
|
||||
* @param {String} user - User to check map against
|
||||
* @returns {Number} of failed login attempts
|
||||
*/
|
||||
module.exports.getLoginAttempts = function(user){
|
||||
//Read the code, i'm not explaining this
|
||||
return failedAttempts.get(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nightly Function Call which iterates through the failed login attempts map, removing any which haven't been attempted in over a da yeahy
|
||||
*/
|
||||
module.exports.processExpiredAttempts = function(){
|
||||
for(user of failedAttempts.keys()){
|
||||
//Get attempt by user
|
||||
|
|
@ -147,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];
|
||||
}
|
||||
|
|
@ -15,31 +15,203 @@ 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 { check, body, checkSchema, checkExact} = require('express-validator');
|
||||
const { checkSchema } = require('express-validator');
|
||||
const {sanitizeUrl} = require("@braintree/sanitize-url");
|
||||
|
||||
//local imports
|
||||
const {isRank} = require('./permissionsValidator');
|
||||
|
||||
module.exports = {
|
||||
user: (field = 'user') => check(field).escape().trim().isAlphanumeric().isLength({min: 1, max: 22}),
|
||||
module.exports.user = function(field = 'user'){
|
||||
return checkSchema({
|
||||
[field]: {
|
||||
escape: true,
|
||||
trim: true,
|
||||
//Caution: Nerd Rant
|
||||
//isAlphanumerics only takes locale for the option flag in schemas for some reason...
|
||||
matches: {
|
||||
//See this is the shit I'm talking about, WHY IS THIS CALLED OPTIONS? IT SHOULD BE PATTERN
|
||||
//OPTIONS IS SUPPOSED TO BE AN OBJECT THAT PASSES EXTRA VALUES THATS LITERALLY HOW EVERYTHING ELSE IN THIS FUCKING LIBRARY WORKS
|
||||
//WHO FUCKING WROTE THIS SHIT!?!?!?!?!
|
||||
//HOW IS THIS ACCEPTED ON ONE OF THE MOST WIDELY USED VALIDATION LIBRARIES ON THE WEB!??!?!?!!?
|
||||
//IT'S NOT EVEN FUCKING DOCUMENTED ANYWHERE!!!!!!!!!!!!
|
||||
//WEBDEVS, GET YOUR FUCKING SHIT TOGETHER, FUCK!
|
||||
options: [/^[A-Za-z0-9-_]+$/],
|
||||
errorMessage: "Usernames can only contain numbers, letters, underscores, and dashes."
|
||||
},
|
||||
//matches: /^[A-Za-z0-9-_]+$/,
|
||||
isLength: {
|
||||
options: {
|
||||
min: 1,
|
||||
max: 22
|
||||
},
|
||||
errorMessage: "Usernames must be between 1 and 22 characters."
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//Password security requirements may change over time, therefore we should only validate against strongPassword() when creating new accounts
|
||||
//that way we don't break old ones upon change
|
||||
pass: (field = 'pass') => body(field).notEmpty().escape().trim(),
|
||||
securePass: (field) => module.exports.pass(field).isStrongPassword({minLength: 8, minLowercase: 1, minUppercase: 1, minNumbers: 1, minSymbols: 1}),
|
||||
function getPassSchema(field = 'pass'){
|
||||
//Heavily simplified from previous versions.
|
||||
//Trimming passwords is iffy, and escaping them is a down-right bad idea
|
||||
return {
|
||||
[field]: {
|
||||
notEmpty: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
email: (field = 'email') => body(field).optional().isEmail().normalizeEmail(),
|
||||
module.exports.pass = function(field = 'pass'){
|
||||
return checkSchema(getPassSchema(field));
|
||||
}
|
||||
|
||||
img: (field = 'img') => body(field).optional().isURL({require_tld: false, require_host: false}).trim(),
|
||||
module.exports.securePass = function(field = 'pass'){
|
||||
const schema = getPassSchema(field);
|
||||
|
||||
//Length check before escaping to keep symbols from throwing the count
|
||||
pronouns: (field = 'pronouns') => body(field).optional().trim().isLength({min: 0, max: 15}).escape(),
|
||||
schema[field].isStrongPassword = {
|
||||
options: {
|
||||
minLength: 8,
|
||||
minLowercase: 1,
|
||||
minUppercase: 1,
|
||||
minNumbers: 1,
|
||||
minSymbols: 1
|
||||
},
|
||||
errorMessage: "Passwords must contain 8 characters, including at least one: Upper, Lower, Number, and Special char."
|
||||
}
|
||||
|
||||
signature: (field = 'signature') => body(field).optional().trim().isLength({min: 1, max: 25}).escape(),
|
||||
return checkSchema(schema);
|
||||
}
|
||||
|
||||
bio: (field = 'bio') => body(field).optional().trim().isLength({min: 1, max: 1000}).escape(),
|
||||
module.exports.email = function(field = 'email'){
|
||||
return checkSchema({
|
||||
[field]: {
|
||||
optional: true,
|
||||
isEmail: {
|
||||
errorMessage: "Invalid E-Mail Address"
|
||||
},
|
||||
normalizeEmail: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
rank: (field = 'rank') => body(field).escape().trim().custom(isRank),
|
||||
module.exports.img = function(field = 'img'){
|
||||
return checkSchema({
|
||||
[field]: {
|
||||
optional: true,
|
||||
isURL: {
|
||||
options: {
|
||||
require_tld: false,
|
||||
require_host: false,
|
||||
require_valid_protocol: true
|
||||
},
|
||||
errorMessage: "Invalid URL."
|
||||
},
|
||||
trim: true,
|
||||
customSanitizer: {
|
||||
options: sanitizeUrl
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
securityToken: (field = 'token') => check(field).escape().trim().isHexadecimal().isLength({min:32, max:32})
|
||||
module.exports.pronouns = function(field = 'pronouns'){
|
||||
return checkSchema({
|
||||
[field]: {
|
||||
optional: true,
|
||||
trim: true,
|
||||
isLength: {
|
||||
options: {
|
||||
min: 0,
|
||||
max: 15
|
||||
},
|
||||
errorMessage: "Pronouns must be under 15 characters."
|
||||
},
|
||||
escape: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports.signature = function(field = 'signature'){
|
||||
return checkSchema({
|
||||
[field]: {
|
||||
optional: true,
|
||||
trim: true,
|
||||
isLength: {
|
||||
options: {
|
||||
min: 1,
|
||||
max: 25
|
||||
},
|
||||
errorMessage: "Signature must be between 1 and 25 characters."
|
||||
},
|
||||
escape: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports.bio = function(field = 'bio'){
|
||||
return checkSchema({
|
||||
[field]: {
|
||||
optional: true,
|
||||
trim: true,
|
||||
isLength: {
|
||||
options: {
|
||||
min: 1,
|
||||
max: 1000
|
||||
},
|
||||
errorMessage: "Bio must be between 1 and 1000 characters."
|
||||
},
|
||||
escape: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports.rank = function(field = 'rank'){
|
||||
return checkSchema({
|
||||
[field]: {
|
||||
escape: true,
|
||||
trim: true,
|
||||
custom: {
|
||||
options: isRank,
|
||||
},
|
||||
errorMessage: "Invalid rank."
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const securityTokenSchema = {
|
||||
escape: true,
|
||||
trim: true,
|
||||
isHexadecimal: true,
|
||||
isLength: {
|
||||
options: {
|
||||
min: 64,
|
||||
max: 64
|
||||
}
|
||||
},
|
||||
errorMessage: "Invalid security token."
|
||||
}
|
||||
|
||||
module.exports.securityToken = function(field = 'token'){
|
||||
return checkSchema({[field]:securityTokenSchema});
|
||||
}
|
||||
|
||||
module.exports.rememberMeID = function(field = 'rememberme.id'){
|
||||
return checkSchema({
|
||||
[field]:{
|
||||
in: ['cookies'],
|
||||
optional: true,
|
||||
isUUID: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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});
|
||||
}
|
||||
|
|
@ -15,24 +15,81 @@ 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 { check, body, checkSchema, checkExact} = require('express-validator');
|
||||
const { checkSchema, checkExact } = require('express-validator');
|
||||
|
||||
//local imports
|
||||
const accountValidator = require('./accountValidator');
|
||||
|
||||
module.exports = {
|
||||
name: (field = 'name') => check(field).escape().trim().isAlphanumeric().isLength({min: 1, max: 50}),
|
||||
|
||||
description: (field = 'description') => body(field).escape().trim().isLength({min: 1, max: 1000}),
|
||||
|
||||
thumbnail: (field = 'thumbnail') => accountValidator.img(field),
|
||||
|
||||
rank: (field = 'rank') => accountValidator.rank(field),
|
||||
|
||||
settingsMap: () => checkExact(checkSchema({
|
||||
'settingsMap.hidden': {
|
||||
optional: true,
|
||||
isBoolean: true,
|
||||
} }))
|
||||
}
|
||||
|
||||
module.exports.name = function(field = 'name'){
|
||||
return checkSchema({
|
||||
[field]: {
|
||||
escape: true,
|
||||
isAlphanumeric: {
|
||||
errorMessage: "Channel names must be alphanumeric."
|
||||
},
|
||||
isLength: {
|
||||
options: {
|
||||
min: 1,
|
||||
max: 50
|
||||
},
|
||||
errorMessage: "Channel names must be between 1 and 50 characters."
|
||||
},
|
||||
trim: true
|
||||
}
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
module.exports.description = function(field = 'description'){
|
||||
return checkSchema({
|
||||
[field]:{
|
||||
escape: true,
|
||||
trim: true,
|
||||
isLength: {
|
||||
options: {
|
||||
min: 1,
|
||||
max: 1000
|
||||
},
|
||||
errorMessage: "Description must be between 1 and 1000 characters."
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports.thumbnail = function(field = 'thumbnail'){
|
||||
return accountValidator.img(field);
|
||||
}
|
||||
|
||||
|
||||
module.exports.rank = function(field = 'rank'){
|
||||
return accountValidator.rank(field);
|
||||
}
|
||||
|
||||
module.exports.settingsMap = function(){
|
||||
return checkExact(
|
||||
checkSchema({
|
||||
'settingsMap.hidden': {
|
||||
optional: true,
|
||||
isBoolean: true,
|
||||
errorMessage: "Bad channel settings map."
|
||||
},
|
||||
'settingsMap.streamURL': {
|
||||
optional: true,
|
||||
isURL: {
|
||||
options:{
|
||||
require_valid_protocol: true
|
||||
}
|
||||
},
|
||||
errorMessage: "Invalid Stream URL"
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -15,35 +15,72 @@ 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 { check } = require('express-validator');
|
||||
const { checkSchema } = require('express-validator');
|
||||
const validator = require('validator');//We need validators for express-less code too!
|
||||
const { errorMiddleware } = require('../utils/loggerUtils');
|
||||
|
||||
module.exports = {
|
||||
name: (field = 'name') => check(field).escape().trim().isAlphanumeric().isLength({min: 1, max: 14}),
|
||||
link: (field = 'link') => check(field).trim().isURL(),
|
||||
manualName: (input) => {
|
||||
//Trim and sanatize input
|
||||
const clean = validator.trim(validator.escape(input));
|
||||
|
||||
//if cleaned input is a proper emote name
|
||||
if(validator.isLength(clean, {min: 1, max: 14}) && validator.isAlphanumeric(clean)){
|
||||
//return cleaned input
|
||||
return clean;
|
||||
}
|
||||
|
||||
module.exports.name = function(field = 'name'){
|
||||
return checkSchema({
|
||||
[field]: {
|
||||
escape: true,
|
||||
trim: true,
|
||||
isAlphanumeric: {
|
||||
errorMessage: "Emote names must be alphanumeric."
|
||||
},
|
||||
isLength: {
|
||||
options: {
|
||||
min: 1,
|
||||
max: 14
|
||||
},
|
||||
errorMessage: "Emote name must be between 1 and 14 characters."
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
//otherwise return false
|
||||
return false;
|
||||
},
|
||||
manualLink: (input) => {
|
||||
//Trim the input
|
||||
const clean = validator.trim(input)
|
||||
|
||||
//If we have a URL return the trimmed input
|
||||
if(validator.isURL(clean)){
|
||||
return clean;
|
||||
module.exports.link = function(field = 'link'){
|
||||
return checkSchema({
|
||||
[field]: {
|
||||
isURL: {
|
||||
options: {
|
||||
require_tld: false,
|
||||
require_host: false,
|
||||
require_valid_protocol: true
|
||||
},
|
||||
errorMessage: "Invalid URL."
|
||||
},
|
||||
trim: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
//otherwise return false
|
||||
return false;
|
||||
module.exports.manualName = function(input){
|
||||
//Trim and sanatize input
|
||||
const clean = validator.trim(validator.escape(input));
|
||||
|
||||
//if cleaned input is a proper emote name
|
||||
if(validator.isLength(clean, {min: 1, max: 14}) && validator.isAlphanumeric(clean)){
|
||||
//return cleaned input
|
||||
return clean;
|
||||
}
|
||||
|
||||
//otherwise return false
|
||||
return false;
|
||||
}
|
||||
|
||||
module.exports.manualLink = function(input){
|
||||
//Trim the input
|
||||
const clean = validator.trim(input)
|
||||
|
||||
//If we have a URL return the trimmed input
|
||||
if(validator.isURL(clean,{require_valid_protocol: true})){
|
||||
return clean;
|
||||
}
|
||||
|
||||
//otherwise return false
|
||||
return false;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue