Compare commits

..

285 commits

Author SHA1 Message Date
rainbow napkin 60338eaeb1 Merge branch 'main' of https://git.ourfore.st/rainbownapkin/canopy 2025-11-12 19:25:53 -05:00
rainbow napkin 27ab1c2c71 Upgraded version from 0.4-Indev Hotfix 3 to 0.1-Alpha 2025-11-12 19:24:59 -05:00
rainbow napkin c3d435a0a3 Merge branch 'main' of https://git.ourfore.st/rainbownapkin/canopy 2025-11-10 22:14:12 -05:00
rainbow napkin ed656cb530 Added canopy version string to about page. 2025-11-10 22:07:23 -05:00
rainbow napkin 37e9658d51 Livestream media handler now dynamically resizes UX to aspect ratio on media change. 2025-11-10 21:38:36 -05:00
rainbow napkin dd4d789d9f Added 'PM' option to userlist context menu. 2025-11-10 20:46:07 -05:00
rainbow napkin ecebcf0d32 Killed harmless bug related to official YT Api that cluttered the console. 2025-11-10 20:31:07 -05:00
rainbow napkin eaca9db987 Mods from old codebase now properly migrated. Version number upgraded. 2025-11-10 20:11:34 -05:00
rainbow napkin fe957e4083 Merge branch 'main' of https://git.ourfore.st/rainbownapkin/canopy 2025-11-10 19:54:54 -05:00
rainbow napkin e7558d7d9d Update package.json 2025-11-10 19:52:59 -05:00
rainbow napkin ce7be33a67 Merge remote-tracking branch 'origin/dev' 2025-11-06 19:39:20 -05:00
rainbow napkin e94914a152 Pretty things up a bit, after fixing that nasty profile bug. 2025-11-06 19:36:52 -05:00
rainbow napkin bd8a03d72f Merge branch 'dev' 2025-11-06 19:02:19 -05:00
rainbow napkin be01417cdf Updated vague DB query which could lead to pwned accounts 2025-11-06 19:01:22 -05:00
rainbow napkin d7c55fe3da Whoops... 2025-11-06 19:00:30 -05:00
rainbow napkin 001945a284 Whoops... 2025-11-05 18:46:51 -05:00
rainbow napkin affdd98572 Fixed errors loading default items 2025-11-05 18:35:48 -05:00
rainbow napkin eb5475ec9d Updated README.md 2025-11-04 07:05:40 -05:00
rainbow napkin a8a5357b05 Added ad-block PSA, to remind everyone that they should use an ad-blocking extension while browsing the web. 2025-11-04 06:55:28 -05:00
rainbow napkin 08fe051269 Improved sanatization for server-side templating. 2025-11-04 06:09:26 -05:00
rainbow napkin 35fd81e1b2 Improved JSDoc for IP Hash Comparison function. 2025-11-03 19:11:31 -05:00
rainbow napkin ade2a4210d User IP Hashes are now salted with 24 bits from a cryptographically secure random generation function formatted into base 64 for extra privacy/security. 2025-11-03 19:07:38 -05:00
rainbow napkin 75301ec7d9 Finished optimizing automated queue transactions. 2025-11-03 00:13:17 -05:00
rainbow napkin dd36b1d923 Optimized queue.removeRange() 2025-11-01 11:34:50 -04:00
rainbow napkin c3712bfd77 Added some nullchecks to mediaHandler.js to quiet down stale handlers right after death. 2025-11-01 09:10:47 -04:00
rainbow napkin 366766d0a3 Finished up work with youtube raw link refreshing. 2025-11-01 08:51:30 -04:00
rainbow napkin 02c4d214fa Started work on re-implementation of youtube raw-link reloading. 2025-11-01 08:09:54 -04:00
rainbow napkin a70879c76c Quick cleanup. 2025-11-01 07:55:34 -04:00
rainbow napkin b57d723d62 Fixed client-side IACDN setting. 2025-11-01 07:45:23 -04:00
rainbow napkin ccb1d91a5b Seperate audio tracks are now *reasonably* synchronized to their videos. This may need more testing to perfect. 2025-11-01 07:10:40 -04:00
rainbow napkin a59b6d0e19 Started work on syncronizing seperated audio and video tracks into back into one player. 2025-10-31 20:22:17 -04:00
rainbow napkin e0832c2c1f Started working on pushing multiple raw links to user. 2025-10-29 08:35:37 -04:00
rainbow napkin 349a6b82aa Moving currently playing items to an invalid spot in the schedule no longer creates ghost items. 2025-10-28 07:03:16 -04:00
rainbow napkin a1f0824330 Fixed queue rendering issue by changing server-side behavior around queue broadcasting. 2025-10-28 06:33:52 -04:00
rainbow napkin dd66601f0d Fixed out of order queues being sent off to clients. 2025-10-27 20:31:14 -04:00
rainbow napkin 787846c7d6 Updated config example. 2025-10-27 19:29:07 -04:00
rainbow napkin 166e174397 comment 2025-10-25 09:56:24 -04:00
rainbow napkin 37990ff8c3 Traded bug with queue.end() being called as volatile from functions which handle their own DB save, in which stale item was left in cache, for a simple queue rending bug. 2025-10-25 09:55:40 -04:00
rainbow napkin f95a0ae48c Added queue debugging. 2025-10-25 09:46:28 -04:00
rainbow napkin db3ec58ad9 Simplified chatMetadata based classes. 2025-10-24 00:26:29 -04:00
rainbow napkin b8de76b448 Fixed canopyUXUtils.getInstanceName() 2025-10-23 08:14:25 -04:00
rainbow napkin 7cda9517d4 Added basic about page. 2025-10-23 07:50:49 -04:00
rainbow napkin 1bd9fcdc80 High-level rank changes and bad attempts and good Remember-Me tokens now logged. 2025-10-22 21:53:41 -04:00
rainbow napkin a34ece4374 Video title now renders escaped entities properly. 2025-10-22 21:20:07 -04:00
rainbow napkin 79df27b72c Autocomplete placeholder now replaces spaces in input value with unicode non-breaking space character. 2025-10-22 21:04:08 -04:00
rainbow napkin f9ac076e6f readme 2025-10-22 20:49:30 -04:00
rainbow napkin 875baa833f Moar readme updates. 2025-10-22 20:46:00 -04:00
rainbow napkin b620b423f6 Updated badges again... 2025-10-22 20:34:21 -04:00
rainbow napkin a68bc6a7da Update README badges (super important) 2025-10-22 20:29:32 -04:00
rainbow napkin aa32587259 Server now auto-magically nukes expired remember me tokens on startup and at midnight UTC. 2025-10-22 20:17:53 -04:00
rainbow napkin 57787f81e7 Queue icon now only shows when readSchedule is allowed. 2025-10-22 05:36:55 -04:00
rainbow napkin 6d16ac2353 Moved server-side queue transmission to it's own protected namespace. 2025-10-22 05:00:59 -04:00
rainbow napkin 5eb307bb9e Created dedicated queue broadcast namespace, to make authorized queue broadcasts as painless as possible. 2025-10-22 04:34:05 -04:00
rainbow napkin e88ef29852 Merge branch 'dev' of https://git.ourfore.st/rainbownapkin/canopy into dev 2025-10-22 03:04:20 -04:00
rainbow napkin 597a984e46 Split pmHandler to create auxServer class for easy creation of server classes auxiliary to the channel. 2025-10-22 03:04:16 -04:00
rainbow napkin 1e48d3af2c Added repo badges to readme.
Added repo badges to readme.
2025-10-21 19:07:51 -04:00
rainbow napkin d874f5e2da Cleaned up remember-me error handling. 2025-10-21 08:09:55 -04:00
rainbow napkin bc0657a702 Remember me tokens now nuked upon full account logout. 2025-10-21 07:59:15 -04:00
rainbow napkin 3fb71ffb78 Improved Documentation. 2025-10-21 07:42:20 -04:00
rainbow napkin 1d5a087d79 Server now deletes associated remember-me token on user requested log-outs. 2025-10-21 00:21:44 -04:00
rainbow napkin 61ec3ffc52 Finished up with remember me middleware. 2025-10-21 00:10:17 -04:00
rainbow napkin e00e5a608b Continued work on remember me tokens. 2025-10-20 07:49:41 -04:00
rainbow napkin 95ed2fa403 Prepped session utils for remember me tokens. 2025-10-20 05:15:34 -04:00
rainbow napkin 5caa679b92 Upgraded password hashing algo to argon2id. 2025-10-18 09:42:08 -04:00
rainbow napkin 895a8201a5 Started work on Remember Me Tokens. 2025-10-18 09:15:00 -04:00
rainbow napkin 7f6abdf8e2 Improved Email Change and Password Reset token security by increasing token size. 2025-10-18 08:36:05 -04:00
rainbow napkin 06f552a9ec Improved link validation and sanatization, in order to mitigate CVE-2025-56200 from validator.js NPM package. 2025-10-18 07:21:17 -04:00
rainbow napkin 6bab5b4723 Migration UI complete. 2025-10-16 21:22:37 -04:00
rainbow napkin eb48b92551 Started work on migration UI. Improved email handling. 2025-10-16 08:25:13 -04:00
rainbow napkin bddbd9cd36 More welcome wagon beautification. 2025-10-16 07:41:13 -04:00
rainbow napkin cb3fc9bb91 Beautified launch printout 2025-10-16 07:36:32 -04:00
rainbow napkin 6ae652b47c Updated login API to throw 301 when an un-migrated user attempts to login. 2025-10-16 06:55:36 -04:00
rainbow napkin 66ec2fabc5 Added basic profile sanatization for legacy migration data. 2025-10-16 05:48:12 -04:00
rainbow napkin 6cbb726764 Implemented Profile Migration Backend 2025-10-16 05:25:08 -04:00
rainbow napkin da9428205f Prevented users from signing up with an email/username which was already present in the migration database. 2025-10-14 02:10:34 -04:00
rainbow napkin a1b602efdb Moved toke log to it's own DB collection w/ cached statistics. Tokebot statistics page-load time decreased by up to 20-30x 2025-10-12 23:48:45 -04:00
rainbow napkin 42ad17072f Refactored userSchema.statics.findProfile()'s tokebot profile generation procedure. 2025-10-11 09:43:26 -04:00
rainbow napkin e9b9539477 Added completion time to migration procedure. 2025-10-11 09:38:44 -04:00
rainbow napkin a231c8fc4c Started server-wide legacy cytube/fore.st toke count ingestion. 2025-10-11 08:19:53 -04:00
rainbow napkin bb2a1369a3 Added profile toke count ingestion to migration schema. 2025-10-10 08:42:02 -04:00
rainbow napkin ad0dd6bdbb Added better error checking to migration schema statics. 2025-10-09 18:02:04 -04:00
rainbow napkin 9fda308306 Started work on legacy account migration. 2025-10-09 03:50:05 -04:00
rainbow napkin 4698ba4122 Added auto-scrolling to private message panel. Fixed auto-scrolling w/ laggy assets in chat and PM. 2025-10-07 03:17:16 -04:00
rainbow napkin 3d2b40b3c8 Upgraded IP-Hashing Algorithm to SHA-512 2025-10-07 01:14:08 -04:00
rainbow napkin 64348b8486 Sesh titles now glow while unread, fixed chat icon un-lighting pre-emptively. 2025-10-07 01:00:13 -04:00
rainbow napkin e85fb18ce5 Messages now play notification sounds. 2025-10-06 21:21:54 -04:00
rainbow napkin 976e157cf1 Added settings for PM-related audio pings. 2025-10-06 19:06:47 -04:00
rainbow napkin 53fc46adc3 Started seperating non-free images/audio from mian codebase. 2025-10-06 05:00:35 -04:00
rainbow napkin 64f9d713da PMHandler now tracks unread messages and lights pm icon to notify user. 2025-10-06 04:48:17 -04:00
rainbow napkin 2feea72694 Finished up work on advanced formatted private messaging. 2025-10-03 08:58:43 -04:00
rainbow napkin faf72fd7a5 Started work on client-side private message post-processing 2025-10-02 08:56:54 -04:00
rainbow napkin e1cdca2b96 Fixed bug with sesh starting 2025-10-02 05:09:45 -04:00
rainbow napkin 6f89f36fb8 Integrated server-side chatPreprocessor.js with pmHandler.js 2025-10-02 04:56:47 -04:00
rainbow napkin ad3cdd38a3 More work decoupling chatPreprocessor.js from chatHandler.js 2025-10-02 04:05:13 -04:00
rainbow napkin 0ed1c0dd89 More work decoupling chatPreprocessor from src/app/channel. 2025-10-02 03:38:40 -04:00
rainbow napkin 23ad679473 Client-side PM Pre-processing complete. 2025-10-02 02:29:29 -04:00
rainbow napkin 57db26a827 Quick Cleanup 2025-10-01 21:41:49 -04:00
rainbow napkin b26dd1094c Modified commandPreprocessor to be easily shared between chat.js and pmHandler.js 2025-10-01 20:38:16 -04:00
rainbow napkin d465863ee6 Split commandPreprocessor in preperation for formatted private messaging. 2025-10-01 19:43:46 -04:00
rainbow napkin e81a4c0973 Basic chat UI complete. 2025-10-01 04:33:24 -04:00
rainbow napkin f109314163 Added basic sesh list rendering to PM panel UX 2025-09-30 05:03:36 -04:00
rainbow napkin a681bddbf7 Cleaned up emotePanel.js 2025-09-30 04:39:39 -04:00
rainbow napkin e2020406a7 Improved Private Message Session Management 2025-09-30 04:39:17 -04:00
rainbow napkin d8e5c64c13 Starting work on client-side chat sesh handling 2025-09-30 03:25:15 -04:00
rainbow napkin e19ae74412 Started work on PM Panel layout 2025-09-25 23:32:04 -04:00
rainbow napkin 67edef9035 Base implementation of PM back-end started. 2025-09-19 03:47:19 -04:00
rainbow napkin 7da07c8717 Seperated out socket validation/authorization from channel mangement logic. 2025-09-18 04:13:19 -04:00
rainbow napkin d541dce8c4 Starting work on private messaging back-end. 2025-09-18 03:42:52 -04:00
rainbow napkin 1384b02f4d Profile pages now display user status. 2025-09-18 02:43:43 -04:00
rainbow napkin 6445950f90 Last user activity now marked on humie-friendly page-loads and last-socket disconnects, ensuring accurate 'online' status when disconnected from a channel. 2025-09-17 20:17:41 -04:00
rainbow napkin 6222535c47 channelManager now tracks all active connectedUser objects for a given user. 2025-09-17 05:11:45 -04:00
rainbow napkin 261dce7b29 Updated README.md 2025-09-13 11:53:28 -04:00
rainbow napkin dd39f76725 Client now only injects chat buffer once. 2025-09-13 11:51:10 -04:00
rainbow napkin dca788dfc0 Improved IA Compatibility. 2025-09-13 11:36:08 -04:00
rainbow napkin 3d4e0a6621 Fixed startTimeStamp not being added to earlyEnd when calculating resume time stamp after livestream end in pushback mode. 2025-09-12 03:38:54 -04:00
rainbow napkin eb8ada7fa0 Fixed initial default toke not loading into DB properly on server first boot. 2025-09-10 08:02:36 -04:00
rainbow napkin de11803cea Fixed !clear command to clear server-side chatBuffer as well as client buffers. 2025-09-10 03:43:15 -04:00
rainbow napkin 9eeed591ad Re-scheduling currently playing item now stops currnet playback, and re-schedules a clone. Allowing for DB-Friendly schedule2scrub functionality. 2025-09-09 08:19:46 -04:00
rainbow napkin 92659929b9 Replaced split ID's with DOM Dataset in channel settings page. 2025-09-08 07:31:43 -04:00
rainbow napkin e539b9d75d Added auto-generated documentation folder to .gitignore
This can be generated by users/admins in setup/production.
2025-09-07 09:45:19 -04:00
rainbow napkin 3ab6c6c715 Changed out spliced ID's for dataset in admin panel. Removed auto-generated documentation from build step. 2025-09-07 09:43:45 -04:00
rainbow napkin cbd2136ca6 Made emotes in emote pallette only enlargen on search to improve UX. 2025-09-06 21:26:14 -04:00
rainbow napkin 9650caeed0 Userlist collapse and cinema mode now state now persistant. 2025-09-06 19:08:10 -04:00
rainbow napkin 770bdc4794 L 2025-09-06 10:42:12 -04:00
rainbow napkin 132fdabb29 Added improved settings panel. 2025-09-06 10:34:06 -04:00
rainbow napkin 306f22aa93 Added validation step to IA CDN server setting 2025-09-06 09:16:40 -04:00
rainbow napkin e9c474eaf0 Added basic option to specify alternative IA CDN servers within the settings panel. 2025-09-06 09:08:07 -04:00
rainbow napkin 08d2bf8bc9 Server crashes are now dumped to log file. 2025-09-06 01:52:21 -04:00
rainbow napkin cb5f1dbf9b Password reset tokens now auto-expire when new reset requests are made. 2025-09-06 00:59:38 -04:00
rainbow napkin edbc9909a1 Fixed bug in playlist manager initialization 2025-09-06 00:48:47 -04:00
rainbow napkin f34ad4829c Added JSDoc for Class Members of src/app/channel/* 2025-09-06 00:32:37 -04:00
rainbow napkin 1aa836ba48 Finished JSDoc for www/src/channel/* 2025-09-05 08:37:16 -04:00
rainbow napkin 2e89d4e6dc Fixed missing JSDoc in playlist manager 2025-09-05 05:55:35 -04:00
rainbow napkin 0e1b48c02c Finished JSDoc for all of www/js/channel except for www/js/channel/queuePanel.js 2025-09-05 05:53:33 -04:00
rainbow napkin c0f219276f JSDoc for www/js/channel/*.js complete. Just need to hadnle ww/js/channel/panels. 2025-09-04 20:11:23 -04:00
rainbow napkin ac06f839ea Started work on JSDoc for www/js/channel 2025-09-04 05:45:33 -04:00
rainbow napkin 5ad20f6823 Created seperate commands in build script to build client and server documentation seperately. 2025-09-02 07:46:46 -04:00
rainbow napkin 514b00a714 Properly marked exported classes to fix JSDoc 2025-09-02 07:32:51 -04:00
rainbow napkin 7d31cc9e8a Added build step to auto-generate Documentation pages at the /doc endpoint from JSDoc. 2025-09-02 07:11:39 -04:00
rainbow napkin 944d91377b Updated package.json 2025-09-02 06:57:24 -04:00
rainbow napkin 76e2f56eb4 Full JSDoc for src/schemas complete. 2025-09-02 06:48:32 -04:00
rainbow napkin ec37c2f59d Finished JSDoc for src/schemas/channel/media 2025-09-01 23:14:16 -04:00
rainbow napkin b78d35d0f8 JSDoc for src/schemas/*.js complete. Just need to finish src/schemas/channel 2025-09-01 14:15:02 -04:00
rainbow napkin 1d5c1037ab JSDoc for rc/schemas/tokebot & src/schemas/user complete. 2025-09-01 04:53:04 -04:00
rainbow napkin 7b8c44158f Finished adding JSDoc for src/utils 2025-08-31 03:44:23 -04:00
rainbow napkin 2303c89bcf Javadoc for src/app complete 2025-08-29 01:59:44 -04:00
rainbow napkin ad057916d8 Continued work on JSDoc for src/app 2025-08-26 08:37:49 -04:00
rainbow napkin fd760e4dab Started work on JSDoc. src/app started, src/app/channel/media complete. 2025-08-25 08:09:43 -04:00
rainbow napkin c64b315fdf Added cold-persistance to Channel Chat Buffer via periodic DB saves based on chat activity. 2025-07-23 01:05:39 -04:00
rainbow napkin 366df357b8 Volatile chat buffer for channel-only chats implemented. 2025-06-11 08:27:56 -04:00
rainbow napkin 8821b5cff9 Started work on implementing chat buffer. 2025-06-11 08:02:50 -04:00
rainbow napkin 370a08cb03 Added section in channel settings to update description and thumbnail. 2025-06-11 06:23:10 -04:00
rainbow napkin 4001ad2f13 Merge branch 'dev' of https://gitlab.com/rainbownapkin/canopy into dev 2025-06-09 08:31:38 -04:00
rainbow napkin 688afa09e8 Started work on channel descriptions and thumby updates. 2025-06-09 08:31:34 -04:00
rainbow napkin 8ce6bc5285 Edit README.md 2025-05-21 02:47:16 +00:00
rainbow napkin faaa2ff658 Edit README.md 2025-05-21 02:44:22 +00:00
rainbow napkin d26a2e0293 Edit README.md 2025-05-21 02:43:54 +00:00
rainbow napkin 639b183f87 Edit README.md 2025-05-21 02:42:53 +00:00
rainbow napkin b5e54afe99 Pushback Schedule mode now works when ending livestreams. 2025-05-19 04:11:25 -04:00
rainbow napkin 92edd74aaa Imrpoved UX in order to prepare for pushback mode. 2025-05-18 19:12:42 -04:00
rainbow napkin 8c8b2a6f0b Livestream Database Handling for Overwrite mode complete. Schedule goes back to pre-stream state if server crashes/stops. 2025-05-18 17:47:47 -04:00
rainbow napkin 85c1258bb6 Started work on livestream end handling, and live stream server crash handling. 2025-05-17 10:03:32 -04:00
rainbow napkin 89f78ae265 Added Go-Live button to queue panel. 2025-05-16 06:32:45 -04:00
rainbow napkin b0cca2c6fc UI Updated to reflect active livestreams, queue locks while streaming. 2025-05-15 19:56:00 -04:00
rainbow napkin afa57e8080 Started work on queueEntries for active livestreams. 2025-05-15 08:36:04 -04:00
rainbow napkin a927b31919 Added streamlock to disable refreshNextTimer() while streaming. Fixed bug in HLS Livestream Media Handler. 2025-05-12 20:48:53 -04:00
rainbow napkin dd00a11b92 Started work on HLS Livestreaming. Created basic HLS Livestream Media Handler. 2025-05-12 17:54:47 -04:00
rainbow napkin c6de68b474 Started work on HLS livestreaming 2025-05-11 08:19:30 -04:00
rainbow napkin 93265b7890 Added stream URL to channel settings. 2025-05-10 22:07:20 -04:00
rainbow napkin e4bebce431 Fixed official yt embed player destruction 2025-05-10 12:28:48 -04:00
rainbow napkin 473b54356e Implemented official yt iframe embed player support. 2025-05-10 12:23:33 -04:00
rainbow napkin 1b0caa5e02 Completed buildPlayer method for youtubeEmbedHandler 2025-05-08 08:22:42 -04:00
rainbow napkin 1344756449 Started working on implementing official YT iframe-embed API 2025-05-07 08:17:38 -04:00
rainbow napkin 047c368b70 FUCK 2025-05-07 01:09:36 -04:00
rainbow napkin aadeac03df Started work on optional offial yotuube embed playback. Added basic client settings panel. 2025-05-07 01:08:17 -04:00
rainbow napkin 336c746ba7 Started work on dailymotion implementation. Looks like HLS player will be required for this. 2025-05-06 23:35:12 -04:00
rainbow napkin 2a3740dece queue refreshes raw link for items starting in less than 10 seconds, rawRefresh only refreshes expired links. 2025-05-06 22:45:26 -04:00
rainbow napkin 60cd21d938 Youtube videos now refresh metadata 10 seconds before playback starts. 2025-05-06 21:13:54 -04:00
rainbow napkin a71f1d6cc0 Imrpoved error handling for youtube fetches. 2025-05-06 18:30:15 -04:00
rainbow napkin 9d9aa5672f Youtube videos technically queable/playable 2025-05-06 07:48:24 -04:00
rainbow napkin 0ce0685fd5 Refactored media link parsing, started work on YT-DLP integration. 2025-05-06 06:32:16 -04:00
rainbow napkin 67c687a8d3 Incremented Version Number 2025-05-05 04:59:40 -04:00
rainbow napkin a62be27db7 Finished up with canopy 0.2.2-indev 2025-05-04 22:25:51 -04:00
rainbow napkin 781b6a8311 Fixed up chromium compatibility. 2025-05-04 20:21:35 -04:00
rainbow napkin e2e6d975fb Autoscroll now ignores scrolls from chat resize 2025-05-04 19:34:56 -04:00
rainbow napkin 7badaa927c Embedded links open in new tab 2025-05-04 18:48:33 -04:00
rainbow napkin 1f00bacb6f Improved CSRF handling 2025-05-04 17:52:55 -04:00
rainbow napkin 2d5afc23d7 Added persistant volume. 2025-05-04 17:17:20 -04:00
rainbow napkin b253f817f4 Media-handler refactoring 2025-05-04 17:06:26 -04:00
rainbow napkin 99fa549469 Moved sync latching logic over to mediaHandler class. 2025-05-04 16:43:57 -04:00
rainbow napkin d3aa4b8274 Added underscores and dashes to usernames 2025-05-02 03:47:46 -04:00
rainbow napkin 23ceb74883 Finished up with improvements to error handling. 2025-04-29 07:05:38 -04:00
rainbow napkin a6228a9fd9 Started improvement of validators and respective error messages 2025-04-29 00:13:19 -04:00
rainbow napkin 8305494915 Imrpoved IP Ban Messages 2025-04-28 17:44:07 -04:00
rainbow napkin 730d816c45 Fixed expiration handlers 2025-04-27 20:39:47 -04:00
rainbow napkin 853f67fe15 Improved exception handling, started work on improving error messages for IP bans 2025-05-29 09:48:48 -04:00
rainbow napkin 7d3c31f0aa !r added to tokebot 2025-04-27 10:26:35 -04:00
rainbow napkin 6639cee8ca Added !resettoke command to tokebot 2025-04-27 09:49:14 -04:00
rainbow napkin d9bab09d53 Little bit of a hacky method, but queuePanel now waits until queue scale is properly rendered before rendering queue items 2025-04-27 08:55:11 -04:00
rainbow napkin 4dff3e562b Remove guff added by IDE 2025-04-27 08:15:53 -04:00
rainbow napkin cc5c63d3b1 Added instance-unique salt to IP hashes 2025-04-27 08:08:39 -04:00
rainbow napkin 8b6aa69c51 Fixed IP-Hashing and Alt Detection behind Reverse Proxies 2025-04-27 05:46:01 -04:00
rainbow napkin 46a7e9e067 Updated server-side self-refrencing links to only use port when not reverse proxied. 2025-04-26 19:14:10 -04:00
rainbow napkin af982f8611 Add math.floor() function call to setHighLevel() 2025-04-26 18:50:19 -04:00
rainbow napkin 9bf68a499b Added case-insensitive logins 2025-04-26 16:53:51 -04:00
rainbow napkin eadfa7c126 Critical Hotfix for !announce and !serverannounce 2025-04-26 09:48:02 -04:00
rainbow napkin bddbb3a4a3 Homepage CSS hotfix 2025-04-26 07:52:52 -04:00
rainbow napkin 8baa42478a Updated README.md 2025-04-22 07:38:46 -04:00
rainbow napkin 878ee4bb2d Converted pop-ups to standard HTML. None use templating features and the router was poorly written anywho. 2025-04-22 07:03:13 -04:00
rainbow napkin ef0344942b Fixed refreshNextTimer so it clears out stale timers. 2025-04-22 01:42:07 -04:00
rainbow napkin e34dcbdec7 Fixed double-start bug in refreshNextTimer. 2025-04-21 07:20:14 -04:00
rainbow napkin dcafd8faf6 Fixed mangled chat logic and added clearer comments to prevent future mangling. 2025-04-15 07:44:02 -04:00
rainbow napkin 46e1b2c45b Fixed crash caused by connecting with deleted flair. 2025-04-15 05:59:43 -04:00
rainbow napkin e226d45dce Fixed bug with flair in chat. 2025-04-15 05:40:42 -04:00
rainbow napkin ee68dba673 Updated readme and package ver 2025-04-12 07:24:17 -04:00
rainbow napkin 4ed4b572f2 Updated client-side DOM manipulation functions to unescape char-codes before injecting them via innerText instead of raw-dogging it into innerHTML 2025-04-12 07:21:36 -04:00
rainbow napkin e46513cc1a Added auto-scrolling to chat buffer. 2025-04-12 06:58:43 -04:00
rainbow napkin 401b9e4fee Added error handling for HTTPS key/cert file loading. 2025-04-12 06:16:38 -04:00
rainbow napkin 069cb423fd Added HTTPS support 2025-04-12 06:09:34 -04:00
rainbow napkin 567eb5b574 Redirect added after channel creation. 2025-04-12 05:08:41 -04:00
rainbow napkin 17e736ab1a Fixed bug with server-side sync function 2025-04-10 20:36:02 -04:00
rainbow napkin 3c144894c6 Modified Queue Logic to handle bulk queueing better. 2025-04-10 20:33:36 -04:00
rainbow napkin b27175bb7d Fixed clickDragger breaking on zoom-in. 2025-04-10 07:02:52 -04:00
rainbow napkin de7dab5cab Improved clickDragger behavior. 2025-04-09 22:05:59 -04:00
rainbow napkin 4c54c270d2 Fixed chat width breaking when video hidden. 2025-04-09 21:09:04 -04:00
rainbow napkin 441108c9cb Fixed page-breaking bug when scaleScrolling queue while a timetip is open 2025-04-09 04:24:55 -04:00
rainbow napkin d9d39e1563 Fixed phantom timetips. 2025-04-09 04:16:28 -04:00
rainbow napkin c70da9daf1 Removed old console.log calls. 2025-04-09 02:20:36 -04:00
rainbow napkin b383e746a7 Finshed up work with per-user playlists. 2025-04-09 01:52:16 -04:00
rainbow napkin ed78bc7dfc Finished up with backend for user playlists 2025-04-07 03:49:20 -04:00
rainbow napkin aefc2dc1bd Started work on implementing user playlists 2025-04-06 00:24:05 -04:00
rainbow napkin e629c63b2c Added random and individual queuing from playlists. 2025-04-05 15:53:07 -04:00
rainbow napkin e61d9deb52 Fixed perms for schedule/channel playlists 2025-04-04 20:38:40 -04:00
rainbow napkin 8c990c14e6 Added quick visual fix to playlist management UI 2025-04-04 08:27:18 -04:00
rainbow napkin f21b66fae0 Finished up with Playlist Managment UI. 2025-04-04 08:18:20 -04:00
rainbow napkin c8c59feb7f Refactored/Beautified existing playlist managment UI code. 2025-04-04 06:10:32 -04:00
rainbow napkin c3781d6259 Continued work on playlist management UI 2025-04-03 01:43:19 -04:00
rainbow napkin 04dec153ac anotha one 2025-04-02 08:08:46 -04:00
rainbow napkin 0e3706dafa Removed extra console.log call 2025-04-02 08:07:58 -04:00
rainbow napkin fb653a477e Continued work on playlist management UI 2025-04-02 08:00:53 -04:00
rainbow napkin f4db10fbc3 Fixed issues with IA utils, continued work on playlist mgmt UI 2025-04-01 08:47:34 -04:00
rainbow napkin 3da88aea2a Continued work on playlist management UI 2025-03-31 08:35:30 -04:00
rainbow napkin 1723e8ebd0 Started work on playlist management UI 2025-03-30 14:11:49 -04:00
rainbow napkin 65b5ae9371 Added backend functionality for channel playlist deletion 2025-03-27 08:32:45 -04:00
rainbow napkin 37f7f21c5a Merge branch 'dev' of https://gitlab.com/rainbownapkin/canopy into dev 2025-03-26 23:30:35 -04:00
rainbow napkin 1e905b3658 Added defaultTitles feature to playlists 2025-03-26 23:30:32 -04:00
rainbow napkin e92c251af1 Edit README.md 2025-03-27 03:17:22 +00:00
rainbow napkin 1167b2dd76 Continued work on Playlist Queueing 2025-03-26 23:08:32 -04:00
rainbow napkin 0325b645ca Updated license file 2025-03-25 08:35:49 -04:00
rainbow napkin 70a68d9336 Continued work on channel-wide playlists. 2025-03-25 08:23:58 -04:00
rainbow napkin 72a89ae5ff Started work on channel-wide playlists. 2025-03-23 22:30:10 -04:00
rainbow napkin 59f097db39 Created Playlist Media Schema. 2025-03-21 07:32:37 -04:00
rainbow napkin cb09c139c7 Created playlist queue schema. 2025-02-15 18:33:07 -05:00
rainbow napkin 8a273d8055 Finished up with Persistent queue storage in database. 2025-02-15 11:02:58 -05:00
rainbow napkin d60182ceae Removed console.log() call and fixed bug with resuming playback after server restart. 2025-02-12 07:48:47 -05:00
rainbow napkin c8cd336c91 Started work on schedule archiving. 2025-02-12 07:41:08 -05:00
rainbow napkin a41541d07b Added persistent rescheduling of nowPlaying after server goes down. 2025-02-11 07:39:20 -05:00
rainbow napkin 179a10fb72 Added proper handling of items that begin late and end early. 2025-02-09 17:29:07 -05:00
rainbow napkin 330c4c275b Added media and queuedMedia schemas 2025-02-09 08:37:45 -05:00
rainbow napkin 44dd613ea3 Added schedule locking and day jumping on scroll lock. 2025-02-09 00:45:36 -05:00
rainbow napkin c83ca63f9a Added optional doc argument to popup class for cPanel usage. 2025-02-07 06:27:25 -05:00
rainbow napkin c04edb6691 Added schedule clearing and scroll to drag to schedule panel. 2025-02-07 06:01:20 -05:00
rainbow napkin 56ab5a16ec fuck 2025-02-03 22:21:39 -05:00
rainbow napkin 85bbe39578 Got drag-n-drop to schedule working. 2025-02-03 22:13:51 -05:00
rainbow napkin 90be85de26 Continued work on media schedule panel. 2025-02-02 18:49:20 -05:00
rainbow napkin d5a2a51be2 Continued work on media scheduler 2025-01-28 09:43:39 -05:00
rainbow napkin 9d01b4c962 Uncommented an important line :P 2025-01-25 08:38:55 -05:00
rainbow napkin 07d1a37453 Continued work on media schedule panel. 2025-01-25 08:37:27 -05:00
rainbow napkin 42c20455e5 Started work on queue panel 2025-01-21 08:15:35 -05:00
rainbow napkin 4f6b3318a0 Implemented back-end for basic time-based queueing system. 2025-01-19 15:13:31 -05:00
rainbow napkin f38eae170d Finished up with player UI-Bar functionality, including 'reload' and 'sync' controls. 2025-01-17 06:02:39 -05:00
rainbow napkin 6dc9ad7b34 Video Syncronization Prototyping Complete. 2025-01-15 06:29:12 -05:00
rainbow napkin 0b68db1265 Started work on back-end for synced video playback. 2025-01-12 17:50:54 -05:00
rainbow napkin b443840c29 Finished up with internet archive api utility. 2025-01-12 16:39:03 -05:00
rainbow napkin 4c1d3c9db5 Fixed critical bug with server-side commandPreprocessor 2025-01-11 23:26:04 -05:00
rainbow napkin f8a6e00345 Started work on media class. 2025-01-11 19:23:42 -05:00
rainbow napkin 4c31dbde1e Finished up with inline and full-body chat-filters. 2025-01-11 13:00:34 -05:00
rainbow napkin 77bc549653 Added spoiler support. 2025-01-11 01:30:25 -05:00
rainbow napkin b56c9a3365 Finished up with profile tooltips and context-menus. 2025-01-08 04:08:27 -05:00
rainbow napkin 9a8def18d7 Started work on userlist profile tooltips and context menus. 2025-01-07 01:42:54 -05:00
rainbow napkin 9df7f52e9e Finished up with chat prompt autocomplete. 2025-01-05 00:05:19 -05:00
rainbow napkin acbe0400c4 Added proper toke and username autocompletion. 2025-01-04 11:19:24 -05:00
rainbow napkin 23a71a5478 Basic auto-complete functionality added. Just need to finish up with toke command memory and permission map system. 2025-01-04 04:00:58 -05:00
rainbow napkin 8f45048ab6 Started work on chat autocomplete 2025-01-03 04:05:29 -05:00
rainbow napkin 6cb7a6223e Updated version number. 2025-01-03 01:21:21 -05:00
202 changed files with 19772 additions and 1854 deletions

8
.gitignore vendored
View file

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

View file

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

View file

@ -1,8 +1,20 @@
Canopy - 0.1-INDEV
Canopy
======
<img src="https://img.shields.io/badge/developed-while%20high-339933">
<img src="https://pride-badges.pony.workers.dev/static/v1?label=enbyware&labelColor=%23555&stripeWidth=8&stripeColors=FCF434%2CFFFFFF%2C9C59D1%2C2C2C2C">
<img src="https://pride-badges.pony.workers.dev/static/v1?label=trans%20rights&stripeWidth=6&stripeColors=5BCEFA,F5A9B8,FFFFFF,F5A9B8,5BCEFA">
<img src="https://pride-badges.pony.workers.dev/static/v1?label=Sponsored+by+the+Gay+Agenda&labelColor=%23555&stripeWidth=8&stripeColors=E40303%2CFF8C00%2CFFED00%2C008026%2C24408E%2C732982">
<a href="https://git.ourfore.st/rainbownapkin/canopy/issues" target="_blank"><img src="https://git.ourfore.st/rainbownapkin/canopy/badges/issues/open.svg"></a>
<a href="https://git.ourfore.st/rainbownapkin/canopy/issues" target="_blank"><img src="https://git.ourfore.st/rainbownapkin/canopy/badges/issues/closed.svg"></a>
<a href="https://www.gnu.org/licenses/agpl-3.0.en.html" target="_blank"><img src="https://img.shields.io/badge/License-AGPL_v3-663366.svg"></a>
0.1-Alpha
=========
Canopy - /ˈkæ.nə.pi/:
- The upper layer of foliage and branches of a forest, containing the majority of animal life.
- An honest attempt at a freedom/privacy respecting, libre, and open-source refrence implementation of what a stoner streaming service can be.
Canopy is a community chat & synced video embedding web application, intended to replace fore.st as the server software for ourfore.st.
This new codebase intends to solve the following issues with the current CyTube based software:
@ -14,16 +26,19 @@ This new codebase intends to solve the following issues with the current CyTube
- General Clunk
- Less Unique Community Identity
Canopy intends to be a simple node/express.js app. It leverages yt-dlp and the internet archive cli utility for metadata gathering. Persistant storage is handled by mongodb, as it's document based nature inherintly works well for cleanly storing large config documents for user/channel settings, and the low use of inter-collection references within the canopy software. All hardcore security functions like session handling and password hashing are handled by industry-standard open source libraries such as express-sessions and bcrypt, however it IS hobbiest software, and it should be treated as such.
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, and never will contain:
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, always use an ad-blocker!
Thirdparty media providers may or may not contain all of the above atrocities :P (though browser-side DRM extensions will never be required), always use an ad-blocker!
Our current goal is to create a cleaner, more modern, purpose-built codebase that has feature-parity with the current version of fore.st, while writing improvements where possible. Once this is accomplished, and ourfore.st has been migrated, work will continue to re-create features from TTN, while also building completely new ones as well.
Our current goal is to create a cleaner, more modern, purpose-built back-end that has feature-parity with the current version of fore.st, writing improvements where possible. Paired with this functionality, are a mix of engineering and artistic choices which attempt to re-create the power-user friendly UX of desktop sites from the early 2010's, with the 'aged like wine' looks that late oughts/early web 2.0 designs graced us with. Making sure that pageloads are low, and GPU use is non-existant along the way, to ensure everything is usable, even on low-end machines.
## License
Canopy is written by the community, and provided under the GNU Affero General Public License v3 in order to prevent Canopy from being used in proprietary software or shitcoin scams.

View file

@ -1,11 +1,25 @@
{
"instanceName": "Canopy",
"verbose": false,
"port": 8080,
"protocol": "http",
"port": 8443,
"proxied": true,
"protocol": "https",
"domain": "localhost",
"ytdlpPath": "/home/canopy/.local/pipx/venvs/yt-dlp/bin/yt-dlp",
"migrate": false,
"dropLegacyTokes": false,
"debug": false,
"secrets":{
"passwordSecret": "CHANGE_ME",
"rememberMeSecret": "CHANGE_ME",
"sessionSecret": "CHANGE_ME",
"altchaSecret": "CHANGE_ME",
"ipSecret": "CHANGE_ME"
},
"ssl":{
"cert": "./server.cert",
"key": "./server.key"
},
"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
View 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."
}

View file

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

View file

@ -1,25 +1,36 @@
{
"name": "canopy-of",
"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
View 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;

View file

@ -16,18 +16,66 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
//local imports
const connectedUser = require('./connectedUser');
const flairModel = require('../../schemas/flairSchema');
const permissionModel = require('../../schemas/permissionSchema');
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);
/**
* 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);
@ -52,18 +100,30 @@ module.exports = class{
//if everything looks good, admit the connection to the channel
socket.join(socket.chan);
await userObj.handleConnection(userDB, chanDB, socket)
//Define per-channel event listeners
this.queue.defineListeners(socket);
this.playlistHandler.defineListeners(socket);
//Hand off the connection initiation to it's user object
const activeUser = await userObj.handleConnection(userDB, chanDB, socket)
//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){
/**
* Handles server-side initialization for disconnecting from the channel
* @param {Socket} socket - Requesting Socket
*/
handleDisconnect(socket){
//temporarily store userObj
var userObj = this.userList.get(socket.user.user);
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;
@ -71,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);
}
@ -80,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 = [{
@ -99,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){
@ -113,3 +184,5 @@ module.exports = class{
this.server.io.in(this.name).emit('chanEmotes', emoteList);
}
}
module.exports = activeChannel;

View file

@ -14,35 +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 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);
/**
* 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;
}
@ -50,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);
@ -68,13 +103,48 @@ module.exports = class{
return;
}
//Define listeners
//Connection accepted past this point
//Define listeners for inter-channel classes
this.defineListeners(socket);
this.chatHandler.defineListeners(socket);
//Connect the socket to it's given channel
//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,52 +157,18 @@ module.exports = class{
}
}
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];
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
@ -146,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 = [];
@ -183,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();
@ -221,3 +322,5 @@ module.exports = class{
this.io.sockets.emit('siteEmotes', emoteList);
}
}
module.exports = channelManager;

34
src/app/channel/chat.js Normal file
View 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;

View 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;

View file

@ -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});
@ -47,11 +102,9 @@ module.exports = class{
//We can take this data raw since our schema checks it against existing flairs, and mongoose sanatizes queries
const flairDB = await userDB.setFlair(data.flair);
//GetConnects across all channels
const connections = this.server.getConnections(socket.user.user);
//For each connection
connections.forEach((conn) => {
//Crawl through users active connections
this.server.crawlConnections(socket.user.user, (conn)=>{
//Update flair
conn.updateFlair(flairDB.name);
});
}catch(err){
@ -60,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();
@ -83,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);
@ -113,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});
@ -123,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);
@ -165,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);
@ -174,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;

View file

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

View file

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

View file

@ -15,37 +15,117 @@ 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;
//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
await this.sendClientMetadata();
this.sendClientMetadata();
//Send out emotes
await this.sendSiteEmotes();
await this.sendChanEmotes(chanDB);
await this.sendPersonalEmotes(userDB);
this.sendSiteEmotes();
this.sendChanEmotes(chanDB);
this.sendPersonalEmotes(userDB);
//Send out used tokes
this.sendUsedTokes(userDB);
//Send out the currently playing item
this.channel.queue.sendMedia(socket);
//If we're proxied
if(config.proxied){
//Tattoo hashed IP address from reverse proxy to user account for seven days
await userDB.tattooIPRecord(socket.handshake.headers['x-forwarded-for']);
}else{
//Tattoo hashed IP address to user account for seven days
await userDB.tattooIPRecord(socket.handshake.address);
}
//Return active user object for use by activeChannel and channelManager objects
return this;
}
/**
* 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) => {
@ -56,27 +136,70 @@ 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){
this.socketCrawl((socket)=>{socket.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, 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()});
}
async sendClientMetadata(){
//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({});
var flairList = [];
//if we wherent handed a user document
if(userDB == null){
//Pull it based on user name
userDB = await userModel.findOne({user: this.user});
}
//if we wherent handed a channel document
if(chanDB == null){
//Pull it based on channel name
chanDB = await channelModel.findOne({name: this.channel.name});
}
//If our perm map is un-initiated
//can't set this in constructor easily since it's asyncornous
//need to wait for it to complete before sending this off, but shouldnt re-do the wait for later connections
if(this.permMap == null){
//Grab perm map
this.permMap = await chanDB.getPermMapByUserDoc(userDB);
}
//Setup our userObj
const userObj = {
id: this.id,
@ -84,7 +207,11 @@ module.exports = class{
rank: this.rank,
chanRank: this.chanRank,
highLevel: this.highLevel,
flair: this.flair
permMap: {
site: Array.from(this.permMap.site),
chan: Array.from(this.permMap.chan),
},
flair: this.flair,
}
//For each flair listed in the Database
@ -99,10 +226,19 @@ module.exports = class{
}
});
//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});
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();
@ -111,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){
@ -125,10 +265,14 @@ 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 channel document
//if we wherent handed a user document
if(userDB == null){
//Pull it based on channel name
//Pull it based on user name
userDB = await userModel.findOne({user: this.user});
}
@ -139,6 +283,27 @@ 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){
//Pull it based on user name
userDB = await userModel.findOne({user: this.user});
}
//Create array of used toks from toke map and send it out to the user
this.emit('usedTokes',{
tokes: Array.from(userDB.tokes.keys())
});
}
/**
* Set flair for a given user and broadcast update to clients
* @param {String} flair - Flair string to update user's flair to
*/
updateFlair(flair){
this.flair = flair;
@ -146,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;
@ -154,3 +323,5 @@ module.exports = class{
this.sendClientMetadata();
}
}
module.exports = connectedUser;

View file

@ -0,0 +1,77 @@
/*Canopy - The next generation of stoner streaming software
Copyright (C) 2024-2025 Rainbownapkin and the TTN Community
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.*/
/**
* Object representing a piece of media
*/
class media{
/**
* Creates a new media object from scraped information
* @param {String} title - Chosen title of media
* @param {String} fileName - Original filename/title of media provided by source
* @param {String} url - Original URL to file
* @param {String} id - Video ID from source (IE: youtube watch code/archive.org file path)
* @param {String} type - Original video source
* @param {Number} duration - Length of media in seconds
* @param {String} rawLink - URLs to raw file copies of media, not applicable to all sources, not saved to the DB
*/
constructor(title, fileName, url, id, type, duration, rawLink){
/**
* Chosen title of media
*/
this.title = title;
/**
* Original filename/title of media provided by source
*/
this.fileName = fileName
/**
* Original URL to file
*/
this.url = url;
/**
* Video ID from source (IE: youtube watch code/archive.org file path)
*/
this.id = id;
/**
* Original video source
*/
this.type = type;
/**
* Length of media in seconds
*/
this.duration = duration;
if(rawLink == null){
/**
* URL to raw file copy of media, not applicable to all sources
*/
this.rawLink = {
audio: [],
video: [],
combo: [['default',url]]
};
}else{
this.rawLink = rawLink;
}
}
}
module.exports = media;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

View file

@ -0,0 +1,158 @@
/*Canopy - The next generation of stoner streaming software
Copyright (C) 2024-2025 Rainbownapkin and the TTN Community
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.*/
//Local Imports
const media = require('./media');
/**
* Class extending media which represents a queued piece of media
* @extends media
*/
class queuedMedia extends media{
/**
* Creates a new queued media object
* @param {Number} startTime - JS Epoch representing start time
* @param {Number} startTimeStamp - Media start time stamp in seconds (relative to duration)
* @param {Number} earlyEnd - Media end timestamp in seconds (relative to duration)
* @param {String} uuid - Media object's unique identifier
*/
constructor(title, fileName, url, id, type, duration, rawLink, startTime, startTimeStamp = 0, earlyEnd, uuid){
//Call derived constructor
super(title, fileName, url, id, type, duration, rawLink);
/**
* JS Epoch (millis) representing start time
*/
this.startTime = startTime;
/**
* Media start time stamp in seconds (relative to duration)
*/
this.startTimeStamp = startTimeStamp;
/**
* Media ent timestamp in seconds (relative to duration)
*/
this.earlyEnd = earlyEnd;
/**
* Media status type
*/
this.status = 'queued';
//If we have a null uuid (can't use default argument because of 'this')
if(uuid == null){
//Generate id unique to this specific entry of this specific file within this specific channel's queue
//That way even if we have six copies of the same video queued, we can still uniquely idenitify each instance
this.genUUID();
}else{
/**
* Media object's unique identifier
*/
this.uuid = uuid;
}
}
//statics
/**
* Creates a queuedMedia object from a media object
* @param {media} media - Media object to queue
* @param {Number} startTime - Start time formatted as a JS Epoch
* @param {Number} startTimeStamp - Start time stamp in seconds
* @returns {queuedMedia} queuedMedia object created from given media object
*/
static fromMedia(media, startTime, startTimeStamp){
//Create and return queuedMedia object from given media object and arguments
return new this(
media.title,
media.fileName,
media.url,
media.id,
media.type,
media.duration,
media.rawLink,
startTime,
startTimeStamp);
}
/**
* Converts array of media objects into array of queuedMedia objects
* @param {Array} mediaList - Array of media objects to queue
* @param {Number} start - Start time formatted as JS Epoch
* @returns Array of converted queued media objects
*/
static fromMediaArray(mediaList, start){
//Queued Media List
const queuedMediaList = [];
//Start Time Offset
let startOffset = 0;
for(let media of mediaList){
//Convert mediaObj to queuedMedia and push to the back of the list
queuedMediaList.push(this.fromMedia(media, start + startOffset, 0));
//Set start offset to end of the current item
startOffset += (media.duration * 1000) + 5;
}
return queuedMediaList;
}
//methods
/**
* Generates new unique identifier for queued media
*/
genUUID(){
this.uuid = crypto.randomUUID();
}
/**
* Generates a unique clone of a given media object
* @returns unique clone of media object
*/
clone(){
return new queuedMedia(
this.title,
this.fileName,
this.url,
this.id,
this.type,
this.duration,
this.rawLink,
this.startTime,
this.startTimeStamp,
this.earlyEnd
);
}
/**
* return the end time of a given queuedMedia object
* @param {boolean} fullTime - Overrides early ends
* @returns end time of given queuedMedia object
*/
getEndTime(fullTime = false){
//If we have an early ending
if(this.earlyEnd == null || fullTime){
//Calculate our ending
return this.startTime + ((this.duration - this.startTimeStamp) * 1000);
}else{
//Return our early end
return this.startTime + (this.earlyEnd * 1000);
}
}
}
module.exports = queuedMedia;

View file

@ -16,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);
@ -104,14 +160,17 @@ module.exports = class tokebot{
this.chatHandler.relayTokeWhisper(commandObj.socket, `Please wait ${this.cooldownCounter} seconds before starting a new group toke.`);
}
//Toke command found, don't send as chat
return false;
//Toke command found, and there isn't any extra text, don't send as chat (re-create fore.st tokebot behaviour)
return (commandObj.command != `!${commandObj.argumentArray[0]}` && commandObj.command != '!r');
}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,21 @@ 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;
//we need to wait for this so we don't send used tokes pre-maturely
await userModel.tattooToke(tokers);
}
/**
* Runs every second for 60 seconds after a toke
*/
cooldown(){
//If the cooldown timer isn't over
if(this.cooldownCounter > 0){
@ -168,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
View 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
View 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
View 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
View 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;

View 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)});
}

View file

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

View file

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

View file

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

View file

@ -21,10 +21,11 @@ const config = require('../../../../config.json');
const {validationResult, matchedData} = require('express-validator');
//local imports
const migrationModel = require('../../../schemas/user/migrationSchema.js');
const rememberMeModel = require('../../../schemas/user/rememberMeSchema.js');
const sessionUtils = require('../../../utils/sessionUtils');
const hashUtils = require('../../../utils/hashUtils.js');
const {exceptionHandler, errorHandler} = require('../../../utils/loggerUtils');
const altchaUtils = require('../../../utils/altchaUtils');
const session = require('express-session');
//api account functions
module.exports.post = async function(req, res){
@ -35,10 +36,45 @@ module.exports.post = async function(req, res){
//if we don't have errors
if(validResult.isEmpty()){
//Pull sanatzied/validated data
const {user, pass} = matchedData(req);
const data = matchedData(req);
//try to authenticate the session, and return a successful code if it works
await sessionUtils.authenticateSession(user, pass, req);
//try to authenticate the session, throwing an error and breaking the current code block if user is un-authorized
const userDB = await sessionUtils.authenticateSession(data.user, data.pass, req);
//If the user already has a remember me token
if(data.rememberme != null && data.rememberme.id != null){
//Fucking nuke the bitch
await rememberMeModel.deleteOne({id: data.rememberme.id})
//Tell the client to drop the token
res.clearCookie("rememberme.id");
res.clearCookie("rememberme.token");
}
//If the user requested a rememberMe token (I'm not validation checking a fucking boolean)
if(req.body.rememberMe){
//Gen user token
//requires second DB call, but this enforces password requirement for toke generation while ensuring we only
//need one function in the userModel for authentication, even if the second woulda just been a wrapper.
//Less attack surface is less attack surface, and this isn't something thats going to be getting constantly called
const authToken = await rememberMeModel.genToken(userDB, data.pass);
//If we properly authed
if(authToken != null){
//Check config for protocol
const secure = config.protocol.toLowerCase() == "https";
//Create expiration date for cookies (180 days)
const expires = new Date(Date.now() + (1000 * 60 * 60 * 24 * 180));
//Set remember me ID and token as browser-side cookies for safe-keeping
res.cookie("rememberme.id", authToken.id, {sameSite: 'strict', httpOnly: true, secure, expires});
//This should be the servers last interaction with the plaintext token before saving the hashed copy, and dropping it out of RAM
res.cookie("rememberme.token", authToken.token, {sameSite: 'strict', httpOnly: true, secure, expires});
}
}
//Tell the browser everything is dandy
return res.sendStatus(200);
}else{
res.status(400);
@ -51,21 +87,35 @@ module.exports.post = async function(req, res){
//if we don't have errors
if(validResult.isEmpty()){
//Get login attempts for current user
const {user} = matchedData(req);
const {user, pass} = matchedData(req);
//Look for the username in the migration DB
const migrationDB = await migrationModel.findOne({user});
//If we found a migration profile
if(migrationDB != null){
//If the user has a good password
if(hashUtils.compareLegacyPassword(pass, migrationDB.pass)){
//Redirect to migrate
return res.sendStatus(301);
}
}
//Get login attempts
const attempts = sessionUtils.getLoginAttempts(user)
//if we've gone over max attempts and
if(attempts.count > sessionUtils.throttleAttempts){
//if we've gone over max attempts
if(attempts != null && attempts.count > sessionUtils.throttleAttempts){
//tell client it needs a captcha
return res.sendStatus(429);
}else{
//Scream about any un-caught errors
return exceptionHandler(res, err);
}
}else{
res.status(400);
return res.send({errors: validResult.array()})
}
//
return exceptionHandler(res, err);
}
}

View file

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

View 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);
}
}

View file

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

View file

@ -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 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
const banMsg = [
'The IP address you are trying to register an account from has been banned.',
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);

View file

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

View file

@ -14,6 +14,9 @@ GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.*/
//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);

View file

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

View 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);
}
}

View file

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

View file

@ -31,7 +31,7 @@ module.exports.get = async function(req, res){
const channel = await channelModel.findOne({name: data.chanName});
if(channel == null){
throw 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);

View 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);
}
}

View 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);
}
}

View file

@ -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);
}

View file

@ -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);
}

View 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)});
}

View file

@ -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', {});
}

View file

@ -0,0 +1,49 @@
/*Canopy - The next generation of stoner streaming software
Copyright (C) 2024-2025 Rainbownapkin and the TTN Community
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.*/
//NPM Imports
const {validationResult, matchedData} = require('express-validator');
//NPM Imports
const validator = require('validator');//No express here, so regular validator it is!
//local imports
const presenceUtils = require('../../utils/presenceUtils');
const {userModel} = require('../../schemas/user/userSchema');
const {exceptionHandler, errorHandler} = require('../../utils/loggerUtils');
//root index functions
module.exports.get = async function(req, res){
try{
const validResult = validationResult(req);
if(validResult.isEmpty()){
const data = matchedData(req);
const profile = await userModel.findProfile({user: data.user});
//Pull presence (should be quick since everyone whos been on since last startup will be backed in RAM)
const presence = await presenceUtils.getPresence(profile.user);
return res.render('partial/panels/profile', {profile, presence, unescape: validator.unescape});
}else{
res.status(400);
return res.send({errors: validResult.array()})
}
}catch(err){
return exceptionHandler(res, err);
}
}

View file

@ -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/queue', {});
}

View 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', {});
}

View file

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

View file

@ -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()})

View file

@ -0,0 +1,45 @@
/*Canopy - The next generation of stoner streaming software
Copyright (C) 2024-2025 Rainbownapkin and the TTN Community
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.*/
//NPM Imports
const {validationResult, matchedData} = require('express-validator');
//NPM Imports
const validator = require('validator');//No express here, so regular validator it is!
//local imports
const {userModel} = require('../../schemas/user/userSchema');
const {exceptionHandler, errorHandler} = require('../../utils/loggerUtils');
//root index functions
module.exports.get = async function(req, res){
try{
const validResult = validationResult(req);
if(validResult.isEmpty()){
const data = matchedData(req);
const profile = await userModel.findProfile({user: data.user});
return res.render('partial/tooltip/profile', {profile, unescape: validator.unescape});
}else{
res.status(400);
return res.send({errors: validResult.array()});
}
}catch(err){
return exceptionHandler(res, err);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -22,6 +22,12 @@ const { Router } = require('express');
const placeholderController = require("../controllers/panel/placeholderController");
const emoteController = require("../controllers/panel/emoteController");
const popoutContainerController = require("../controllers/panel/popoutContainerController");
const profileController = require("../controllers/panel/profileController");
const queueController = require("../controllers/panel/queueController");
const settingsController = require("../controllers/panel/settingsController");
const pmController = require("../controllers/panel/pmController");
//Validators
const accountValidator = require("../validators/accountValidator");
//globals
const router = Router();
@ -30,5 +36,9 @@ const router = Router();
router.get('/placeholder', placeholderController.get);
router.get('/emote', emoteController.get);
router.get('/popoutContainer', popoutContainerController.get);
router.get('/profile', accountValidator.user(), profileController.get);
router.get('/queue', queueController.get);
router.get('/settings', settingsController.get);
router.get('/pm', pmController.get);
module.exports = router;

View file

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

View file

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

View file

@ -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,36 @@ const channelPermissionSchema = new mongoose.Schema({
default: "admin",
required: true
},
readSchedule: {
type: mongoose.SchemaTypes.String,
enum: rankEnum,
default: "admin",
required: true
},
scheduleMedia: {
type: mongoose.SchemaTypes.String,
enum: rankEnum,
default: "admin",
required: true
},
clearSchedule:{
type: mongoose.SchemaTypes.String,
enum: rankEnum,
default: "admin",
required: true
},
scheduleAdmin:{
type: mongoose.SchemaTypes.String,
enum: rankEnum,
default: "admin",
required: true
},
editChannelPlaylists:{
type: mongoose.SchemaTypes.String,
enum: rankEnum,
default: "admin",
required: true
},
deleteChannel: {
type: mongoose.SchemaTypes.String,
enum: rankEnum,

View file

@ -29,9 +29,15 @@ const emoteModel = require('../emoteSchema');
//DB Schemas
const channelPermissionSchema = require('./channelPermissionSchema');
const channelBanSchema = require('./channelBanSchema');
const queuedMediaSchema = require('./media/queuedMediaSchema');
const playlistSchema = require('./media/playlistSchema');
const chatSchema = require('./chatSchema');
//Utils
const { exceptionHandler, errorHandler } = require('../../utils/loggerUtils');
/**
* DB Schema for Documents containing de-hydrated representations of Canopy Stream/Chat Channels
*/
const channelSchema = new mongoose.Schema({
id: {
type: mongoose.SchemaTypes.Number,
@ -40,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: {
@ -60,6 +68,10 @@ const channelSchema = new mongoose.Schema({
required: true,
default: true
},
streamURL: {
type: mongoose.SchemaTypes.String,
default: ''
}
},
permissions: {
type: channelPermissionSchema,
@ -98,18 +110,34 @@ const channelSchema = new mongoose.Schema({
default: emoteModel.typeEnum[0]
}
}],
media: {
nowPlaying: queuedMediaSchema,
scheduled: [queuedMediaSchema],
//We should consider moving archived media and channel playlists to their own collections/models for preformances sake
archived: [queuedMediaSchema],
playlists: [playlistSchema],
liveRemainder: {
type: mongoose.SchemaTypes.UUID,
required: false
}
},
//Thankfully we don't have to keep track of alts, ips, or deleted users so this should be a lot easier than site-wide bans :P
banList: [channelBanSchema]
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){
@ -206,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 = [{
@ -220,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 = [];
@ -246,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{
@ -293,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;
@ -323,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
@ -335,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;
@ -368,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()
@ -416,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;
@ -442,11 +535,39 @@ channelSchema.methods.getChannelRankByUserDoc = async function(userDB = null){
}
}
/**
* Gets channel rank by username
* @param {String} user - Username of user to pull channel rank of
* @returns {String} Channel rank of requested user
*/
channelSchema.methods.getChannelRank = async function(user){
const userDB = await userModel.findOne({user: user.user});
return await this.getChannelRankByUserDoc(userDB);
}
/**
* Calculates a permission check against a specific channel permission for a given user by username
* @param {String} user - Username of user to check against
* @param {String} perm - Name of channel Permission to check against
* @returns {Boolean} Whether or not the given user passes the given channel perm check
*/
channelSchema.methods.permCheck = async function (user, perm){
//Set userDB to null if we wheren't passed a real user
if(user != null){
var userDB = await userModel.findOne({user: user.user});
}else{
var userDB = null;
}
return await this.permCheckByUserDoc(userDB, perm)
}
/**
* Calculates a permission check against a specific channel permission for a given user by DB Document
* @param {Mongoose.Document} userDB - DB Document of user to check against
* @param {String} perm - Name of channel Permission to check against
* @returns {Boolean} Whether or not the given user passes the given channel perm check
*/
channelSchema.methods.permCheckByUserDoc = async function(userDB, perm){
//Get site-wide rank as number, default to anon for anonymous users
const rank = userDB ? permissionModel.rankToNum(userDB.rank) : permissionModel.rankToNum("anon");
@ -464,17 +585,37 @@ channelSchema.methods.permCheckByUserDoc = async function(userDB, perm){
return (permCheck || overrideCheck);
}
channelSchema.methods.permCheck = async function (user, perm){
//Set userDB to null if we wheren't passed a real user
if(user != null){
var userDB = await userModel.findOne({user: user.user});
}else{
var userDB = null;
/**
* Generates channel-wide permission map for a given user by user doc
* @param {Mongoose.Document} userDB - DB Document representing a single user account
* @returns {Object} Object containing two maps, one for channel perms, another for site-wide perms
*/
channelSchema.methods.getPermMapByUserDoc = async function(userDB){
//Grap site-wide permissions
const sitePerms = await permissionModel.getPerms();
const siteMap = sitePerms.getPermMapByUserDoc(userDB);
//Pull chan permissions keys
let permTree = channelPermissionSchema.tree;
let permMap = new Map();
//For each object in the temporary permissions object
for(let perm of Object.keys(permTree)){
//Check the current permission
permMap.set(perm, await this.permCheckByUserDoc(userDB, perm));
}
return await this.permCheckByUserDoc(userDB, perm)
//return perm map
return {
site: siteMap.site,
chan: permMap
};
}
/**
* 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;
@ -503,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 = [];
@ -521,6 +666,81 @@ channelSchema.methods.getEmotes = function(){
return emoteList;
}
/**
* Generates Network-Friendly Browser-Digestable list of channel playlists
* @returns {Array} Network-Friendly Browser-Digestable list of channel playlists
*/
channelSchema.methods.getPlaylists = function(){
//Create an empty array to hold our emote list
const playlists = [];
//For each channel emote
for(let playlist of this.media.playlists){
//Push an object with select information from the emote to the emote list
playlists.push(playlist.dehydrate());
}
//return the emote list
return playlists;
}
/**
* Crawls through channel playlists, running a given callback function against each one
* @param {Function} cb - Callback function to run against channel playlists
*/
channelSchema.methods.playlistCrawl = function(cb){
for(let listIndex in this.media.playlists){
//Grab the associated playlist
playlist = this.media.playlists[listIndex];
//Call the callback with the playlist and list index as arguments
cb(playlist, listIndex);
}
}
/**
* Finds channel playlist by playlist name
* @param {String} name - name of given playlist to find
* @returns {Mongoose.Document} - Sub-Document representing a single playlist
*/
channelSchema.methods.getPlaylistByName = function(name){
//Create null value to hold our found playlist
let foundPlaylist = null;
//Crawl through active playlists
this.playlistCrawl((playlist, listIndex) => {
//If we found a match based on name
if(playlist.name == name){
//Keep it
foundPlaylist = playlist;
//Pass down the list index
foundPlaylist.listIndex = listIndex;
}
});
//return the given playlist
return foundPlaylist;
}
/**
* Deletes channel playlist by playlist name
* @param {String} name - name of given playlist to Delete
*/
channelSchema.methods.deletePlaylistByName = async function(name){
//Find the playlist
let playlist = this.getPlaylistByName(name);
//splice out the given playlist
this.media.playlists.splice(playlist.listIndex, 1);
//save the channel document
await this.save();
}
/**
* Generates Network-Friendly Browser-Digestable list of Channel-Wide user bans
* @returns {Array} Network-Friendly Browser-Digestable list of Channel-Wide user bans
*/
channelSchema.methods.getChanBans = async function(){
//Create an empty list to hold our found bans
var banList = [];
@ -561,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
@ -598,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...
@ -623,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");
}
}

View 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;

View file

@ -0,0 +1,55 @@
/*Canopy - The next generation of stoner streaming software
Copyright (C) 2024-2025 Rainbownapkin and the TTN Community
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.*/
//NPM Imports
const {mongoose} = require('mongoose');
/**
* DB Schema representing a single piece of media
*/
const mediaSchema = new mongoose.Schema({
title: {
type: mongoose.SchemaTypes.String,
required: true,
},
fileName: {
type: mongoose.SchemaTypes.String,
required: true,
},
url: {
type: mongoose.SchemaTypes.String,
required: true,
},
id: {
type: mongoose.SchemaTypes.String,
required: true,
},
type: {
type: mongoose.SchemaTypes.String,
required: true,
},
duration: {
type: mongoose.SchemaTypes.Number,
required: true,
},
},
{
discriminatorKey: 'status'
}
);
module.exports = mediaSchema;

View file

@ -0,0 +1,83 @@
/*Canopy - The next generation of stoner streaming software
Copyright (C) 2024-2025 Rainbownapkin and the TTN Community
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.*/
//NPM Imports
const {mongoose} = require('mongoose');
//Local Imports
const mediaSchema = require('./mediaSchema');
const media = require('../../../app/channel/media/media');
/**
* DB Schema for documents represnting a piece of media held in a playlist
*/
const playlistMediaProperties = new mongoose.Schema({
uuid: {
type: mongoose.SchemaTypes.UUID,
required:true,
default: crypto.randomUUID()
}
},
{
discriminatorKey: 'status'
});
//Schema Middleware
/**
* Pre-save function for playlist meda, ensures unique UUID
*/
playlistMediaProperties.pre('save', async function (next){
//If the UUID was modified in anyway
if(this.isModified("uuid")){
//Throw that shit out and make a new one since it's probably either null or a leftover from some channel queue
this.uuid = crypto.randomUUID();
}
//Keep it moving
next();
});
//methods
/**
* Rehydrate to a full phat media object
* @returns {media} A full phat media object, re-hydrated from the DB
*/
playlistMediaProperties.methods.rehydrate = function(){
//Return item as a full phat, standard media object
return new media(
this.title,
this.fileName,
this.url,
this.id,
this.type,
this.duration
);
}
/**
* Dehydrate to minified flat network-friendly object
* @returns {Object} Network-Friendly Browser-Digestable object representing media from a playlist
*/
playlistMediaProperties.methods.dehydrate = function(){
return {
title: this.title,
url: this.url,
duration: this.duration,
uuid: this.uuid.toString()
};
}
module.exports = mediaSchema.discriminator('saved', playlistMediaProperties);

View file

@ -0,0 +1,133 @@
/*Canopy - The next generation of stoner streaming software
Copyright (C) 2024-2025 Rainbownapkin and the TTN Community
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.*/
//NPM Imports
const {mongoose} = require('mongoose');
//Local Imports
const playlistMediaSchema = require('./playlistMediaSchema');
/**
* DB Schema for Documents representing playlists full of media
*/
const playlistSchema = new mongoose.Schema({
name: {
type: mongoose.SchemaTypes.String,
required: true,
},
media: [playlistMediaSchema],
defaultTitles:[{
type: mongoose.SchemaTypes.String,
required: false,
default: []
}]
});
//methods
/**
* Dehydrate to minified flat network-friendly object
* @returns {Object} Network-Friendly Browser-Digestable object representing media from a playlist
*/
playlistSchema.methods.dehydrate = function(){
//Create empty array to hold media
const mediaArray = [];
//Fill media array
for(let media of this.media){
mediaArray.push(media.dehydrate());
}
//return dehydrated playlist
return {
name: this.name,
media: mediaArray,
defaultTitles: this.defaultTitles
}
}
/**
* Add media to the given playlist Document
* @param {Array} mediaList - Array of media Objects to add to playlist
*/
playlistSchema.methods.addMedia = function(mediaList){
//For every piece of media in the list
for(let media of mediaList){
//Set media status schema discriminator
media.status = 'saved';
//Add the media to the playlist
this.media.push(media);
}
}
/**
* Gets Media from a playlist by UUID
* @param {String} uuid - UUID of media to pull
* @returns {media} media with matching UUID
*/
playlistSchema.methods.findMediaByUUID = function(uuid){
//For every piece of media in the current playlist
for(let media of this.media){
//If we found our match
if(media.uuid.toString() == uuid){
//return it
return media;
}
}
}
/**
* Deletes media from a given playlist
* @param {String} uuid - UUID of media to delete
*/
playlistSchema.methods.deleteMedia = function(uuid){
//Create new array to hold list of media to be kept
const keptMedia = [];
//For every piece of media in the current playlist
for(let media of this.media){
//It isn't the media to be deleted
if(media.uuid.toString() != uuid){
//Add it to the list to be kept
keptMedia.push(media);
}
}
//Set playlist media from keptMedia
this.media = keptMedia;
}
/**
* Pick title based on default title's list and media's given title
* @param {String} title - Title to use if there are no default titles.
* @returns {String} Chosen title based on result of function
*/
playlistSchema.methods.pickDefaultTitle = function(title){
//If we don't have default titles in this playlist
if(this.defaultTitles.length <= 0){
//If we wheren't handed an original title
if(title == null || title == ''){
return 'Unnamed Media'
}else{
return title;
}
}else{
//Grab a random default title and return it
return this.defaultTitles[Math.floor(Math.random() * this.defaultTitles.length)];
}
}
module.exports = playlistSchema;

View file

@ -0,0 +1,72 @@
/*Canopy - The next generation of stoner streaming software
Copyright (C) 2024-2025 Rainbownapkin and the TTN Community
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.*/
//NPM Imports
const {mongoose} = require('mongoose');
//Local Imports
const mediaSchema = require('./mediaSchema');
const queuedMedia = require('../../../app/channel/media/queuedMedia');
/**
* DB Schema for documents representing a queued media object
*/
const queuedProperties = new mongoose.Schema({
startTime: {
type: mongoose.SchemaTypes.Number,
required: true,
},
startTimeStamp: {
type: mongoose.SchemaTypes.Number,
required: false,
},
earlyEnd: {
type: mongoose.SchemaTypes.Number,
required: false,
},
uuid: {
type: mongoose.SchemaTypes.UUID,
required: true,
}
},
{
discriminatorKey: 'status'
});
//Methods
/**
* Rehydrate to a full phat queued media object
* @returns {queuedMedia} A full phat queued media object, re-hydrated from the DB
*/
queuedProperties.methods.rehydrate = function(){
return new queuedMedia(
this.title,
this.fileName,
this.url,
this.id,
this.type,
this.duration,
//We don't save raw links that are stored seperate from the standard URL as they tend to expire.
undefined,
this.startTime,
this.startTimeStamp,
this.earlyEnd,
this.uuid.toString()
);
}
//Export schema under the 'queued' discriminator of mediaSchema
module.exports = mediaSchema.discriminator('queued', queuedProperties);

View file

@ -21,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){
//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 = [];

View file

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

View file

@ -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,31 +166,25 @@ 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();
//Set user to anon rank if no rank was found for the given user
if(user == null || user.rank == null){
user ={
rank: "anon"
};
}
//Check if this permission exists
if(perms[perm] != null){
//if so get required rank as a number
requiredRank = this.rankToNum(perms[perm])
//if so get user rank as a number
userRank = user ? this.rankToNum(user.rank) : 0;
//return whether or not the user is equal to or higher than the required rank for this permission
return (userRank >= requiredRank);
}else{
//if not scream and shout
throw new Error(`Permission check '${perm}' not found!`);
}
//Call the perm check method
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){
@ -173,32 +197,26 @@ 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()).channelOverrides;
//Set user to anon rank if no rank was found for the given user
if(user == null || user.rank == null){
user ={
rank: "anon"
};
}
//Check if this permission exists
if(perms[perm] != null){
//if so get required rank as a number
requiredRank = this.rankToNum(perms[perm])
//if so get user rank as a number
userRank = user ? this.rankToNum(user.rank) : 0;
//return whether or not the user is equal to or higher than the required rank for this permission
return (userRank >= requiredRank);
}else{
//if not scream and shout
throw new Error(`Permission check '${perm}' not found!`);
}
const perms = await this.getPerms();
//Call the perm check method
return perms.overrideCheckByUserDoc(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) => {
@ -211,4 +229,93 @@ permissionSchema.statics.reqPermCheck = function(perm){
}
}
//methods
//these are good to have even for single-doc collections since we can loop through them without finding them in the database each time
/**
* Checks permission against a single user by document
* @param {Mongoose.Document} userDB - User doc to rank check against
* @param {String} perm - Permission to check user doc against
* @returns {Boolean} True if authorized
*/
permissionSchema.methods.permCheckByUserDoc = function(userDB, perm){
//Set user to anon rank if no rank was found for the given user
if(userDB == null || userDB.rank == null){
userDB ={
rank: "anon"
};
}
//Check if this permission exists
if(this[perm] != null){
//if so get required rank as a number
requiredRank = this.model().rankToNum(this[perm])
//if so get user rank as a number
userRank = userDB ? this.model().rankToNum(userDB.rank) : 0;
//return whether or not the user is equal to or higher than the required rank for this permission
return (userRank >= requiredRank);
}else{
//if not scream and shout
throw loggerUtils.exceptionSmith(`Permission check '${perm}' not found!`, "Validation");
}
}
/**
* Checks channel override permission against a single user by document
* @param {Mongoose.Document} userDB - User doc to rank check against
* @param {String} perm - Channel Override Permission to check user doc against
* @returns {Boolean} True if authorized
*/
permissionSchema.methods.overrideCheckByUserDoc = function(userDB, perm){
//Set user to anon rank if no rank was found for the given user
if(userDB == null || userDB.rank == null){
userDB ={
rank: "anon"
};
}
//Check if this permission exists
if(this.channelOverrides[perm] != null){
//if so get required rank as a number
requiredRank = this.model().rankToNum(this.channelOverrides[perm])
//if so get user rank as a number
userRank = userDB ? this.model().rankToNum(userDB.rank) : 0;
//return whether or not the user is equal to or higher than the required rank for this permission
return (userRank >= requiredRank);
}else{
//if not scream and shout
throw loggerUtils.exceptionSmith(`Permission check '${perm}' not found!`, "validation");
}
}
/**
* Returns entire permission map marked with booleans
* @param {Mongoose.Document} userDB - User Doc to generate perm map against
* @returns {Map} Permission map containing booleans for each permission's authorization for a given user doc
*/
permissionSchema.methods.getPermMapByUserDoc = function(userDB){
//Pull permissions keys
let permTree = this.schema.tree;
let overrideTree = channelPermissionSchema.tree;
let permMap = new Map();
let overrideMap = new Map();
//For each object in the temporary permissions object
for(let perm of Object.keys(permTree)){
//Check the current permission
permMap.set(perm, this.permCheckByUserDoc(userDB, perm));
}
//For each object in the temporary permissions object
for(let perm of Object.keys(overrideTree)){
//Check the current permission
overrideMap.set(perm, this.overrideCheckByUserDoc(userDB, perm));
}
//return the auto-generated schema
return {
site: permMap,
channelOverrides: overrideMap
}
}
module.exports = mongoose.model("permissions", permissionSchema);

View file

@ -19,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,22 +46,20 @@ 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({});
@ -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);

View file

@ -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,25 +32,38 @@ 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){
//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);
}
}
}
//All is good, continue on saving.
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!");
}
}
}

View 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);

View file

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

View 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);

View file

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

View 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);

View file

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

View file

@ -14,9 +14,6 @@ GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.*/
//Node Imports
const { profile } = require('console');
//NPM Imports
const {mongoose} = require('mongoose');
@ -25,15 +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,
@ -58,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,
@ -72,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: {
@ -141,16 +152,20 @@ const userSchema = new mongoose.Schema({
alts:[{
type: mongoose.SchemaTypes.ObjectID,
ref: "user"
}]
}],
playlists: [playlistSchema]
});
//This is one of those places where you really DON'T want to use an arrow function over an anonymous one!
/**
* Pre-Save function for user document, handles password hashing, flair updates, emote refreshes, and kills sessions upon rank change
*/
userSchema.pre('save', async function (next){
//If the password was changed
if(this.isModified("pass")){
//Hash that sunnovabitch, no questions asked.
this.pass = hashUtil.hashPassword(this.pass);
this.pass = await hashUtil.hashPassword(this.pass);
}
//If the flair was changed
@ -159,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");
}
}
@ -171,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.");
}
@ -188,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");
@ -210,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();
@ -234,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){
@ -259,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){
@ -282,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!"
};
@ -307,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) => {
@ -332,10 +411,22 @@ userSchema.statics.tattooToke = function(tokemap){
//Save the user doc to the database
await userDB.save();
//Would rather do this inside of tokebot but then our boi would have to wait for DB or pass a nasty-looking callback function
//Crawl through active connections
server.channelManager.crawlConnections(userDB.user, (conn)=>{
//Update used toke list
conn.sendUsedTokes(userDB);
});
}
});
}
/**
* Acquires a list of the entire userbase
* @param {Boolean} fullList - Determines whether or not we're listing rank and email
* @returns {Array} Array of objects containing user metadata
*/
userSchema.statics.getUserList = async function(fullList = false){
var userList = [];
//Get all of our users
@ -370,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 = [];
@ -429,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,
@ -452,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 = [];
@ -469,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});
@ -480,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;
@ -494,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 = [];
@ -512,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);
@ -528,6 +662,81 @@ userSchema.methods.deleteEmote = async function(name){
}
}
/**
* Gets list of user playlists
* @returns {Array} Array of objects represnting a playlist containing media objects
*/
userSchema.methods.getPlaylists = function(){
//Create an empty array to hold our emote list
const playlists = [];
//For each channel emote
for(let playlist of this.playlists){
//Push an object with select information from the emote to the emote list
playlists.push(playlist.dehydrate());
}
//return the emote list
return playlists;
}
/**
* Iterates through user playlists and calls a given callback function against them
* @param {Function} cb - Callback function to call against user playlists
*/
userSchema.methods.playlistCrawl = function(cb){
for(let listIndex in this.playlists){
//Grab the associated playlist
playlist = this.playlists[listIndex];
//Call the callback with the playlist and list index as arguments
cb(playlist, listIndex);
}
}
/**
* Returns playlist by name
* @param {String} name - Playlist to get by name
* @returns {Object} Object representing the requested playlist
*/
userSchema.methods.getPlaylistByName = function(name){
//Create null value to hold our found playlist
let foundPlaylist = null;
//Crawl through active playlists
this.playlistCrawl((playlist, listIndex) => {
//If we found a match based on name
if(playlist.name == name){
//Keep it
foundPlaylist = playlist;
//Pass down the list index
foundPlaylist.listIndex = listIndex;
}
});
//return the given playlist
return foundPlaylist;
}
/**
* Deletes a playlist from the user document by name
* @param {String} name - Name of playlist to delete
*/
userSchema.methods.deletePlaylistByName = async function(name){
//Find the playlist
const playlist = this.getPlaylistByName(name);
//splice out the given playlist
this.playlists.splice(playlist.listIndex, 1);
//save the channel document
await this.save();
}
/**
* Salts, Hashes and Tattoo's IP address to user document in a privacy respecting manner
* @param {String} ip - Plaintext IP Address to Salt, Hash and Tattoo
*/
userSchema.methods.tattooIPRecord = async function(ip){
//Hash the users ip
const ipHash = hashUtil.hashIP(ip);
@ -548,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
@ -557,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);
@ -594,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();
@ -612,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;
@ -625,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;
@ -649,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");
}
}

View file

@ -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`);
});
}

View file

@ -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(''));
}

View file

@ -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!");
}
}

View file

@ -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();

View file

@ -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)});
}

View file

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

View file

@ -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,39 +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 and this isn't just a basic bitch
if(!err.custom && config.verbose){
//Log the error
module.exports.dumpError(err);
}
}
/**
* Handles exceptions which where directly the fault of user action >:(
* @param {Express.Response} res - Express Response object to bitch at
* @param {Error} err - Error created by the jerk in question
*/
module.exports.exceptionHandler = function(res, err){
//If we're being verbose
if(config.verbose){
//Log the error
console.log(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");
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){
//If we're being verbose
if(config.verbose){
//Log the error
console.log(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 socket.emit("error", {errors: [{type: "Caught Exception", msg: err.message, date: new Date()}]});
//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.
//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";
@ -70,3 +168,95 @@ module.exports.errorMiddleware = function(err, req, res, next){
module.exports.errorHandler(res, err.message, reason, err.status);
}
/**
* Dumps unexpected server crashes to dedicated log files
* @param {Error} err - error to dump to file
* @param {Date} date - Date of error, defaults to now
* @param {String} subDir - subdirectory inside the log folder we want to dump to
* @param {Boolean} muzzle - Tells the function to STFU
*/
module.exports.dumpError = async function(err, date = new Date(), subDir = 'crash/', muzzle = false){
//Generate content from error
const content = `Error Date: ${date.toLocaleString()} (UTC-${date.getTimezoneOffset()/60})\nError Type: ${err.name}\nError Msg:${err.message}\nStack Trace:\n\n${err.stack}`;
//Dump text to file
module.exports.dumpLog(content, date.getTime(), subDir, muzzle);
}
module.exports.dumpSecurityLog = async function(content, date = new Date()){
module.exports.dumpLog(content, `Incident-{${crypto.randomUUID()}}-${date.getTime()}`, 'security/', true);
}
/**
* Dumps log file to log folder
* @param {String} content - Text to dump to file
* @param {String} name - file name to save to
* @param {String} subDir - subdirectory inside the log folder we want to dump to
* @param {Boolean} muzzle - Tells the function to STFU
*/
module.exports.dumpLog = async function(content, name, subDir = '/', muzzle = false){
try{
//Crash directory
const dir = `./log/${subDir}`
//Double check crash folder exists
try{
await fs.stat(dir);
//If we caught an error (most likely it's missing)
}catch(err){
if(!muzzle){
//Shout about it
module.exports.consoleWarn("Log folder missing, mking dir!")
}
//Make it if doesn't
await fs.mkdir(dir, {recursive: true});
}
//Assemble log file path
const path = `${dir}${name}.log`;
//Write content to file
fs.writeFile(path, content);
if(!muzzle){
//Whine about the error
module.exports.consoleWarn(`Warning: Unexpected Server Crash gracefully dumped to '${path}'... SOMETHING MAY BE VERY BROKEN!!!!`);
}
//If somethine went really really wrong
}catch(doubleErr){
if(!muzzle){
//Use humor to cope with the pain
module.exports.consoleWarn("Yo Dawg, I herd you like errors, so I put an error in your error dump, so you can dump while you dump:");
//Dump the original error to console
module.exports.consoleWarn(err);
//Dump the error we had saving that error to file to console
module.exports.consoleWarn(doubleErr);
}
}
}
module.exports.welcomeWagon = function(count, date, tokes){
//Inject values into ascii art
const art = `
\x1b[32m ! \x1b[0m
\x1b[32m 420 \x1b[0m \x1b[32m\x1b[40m${config.instanceName}\x1b[0m\x1b[2m, Powered By:\x1b[0m
\x1b[32m 420 \x1b[0m
\x1b[32m WEEED \x1b[0m CCCC AAA NN N OOO PPPP Y Y
\x1b[32m! WEEED !\x1b[0m C A A NN N O O P P Y Y
\x1b[32mWEE EEEEE EED\x1b[0m C A A N N N O O P P Y Y
\x1b[32m WEE EEEEE EED\x1b[0m C AAAAA N N N O O PPPP Y
\x1b[32m WEE EEE EED\x1b[0m C A A N N N O O P Y
\x1b[32m WEE EEE EED\x1b[0m C A A N NN O O P Y
\x1b[32m WEEEEED\x1b[0m CCCC A A N NN OOO P Y
\x1b[32m WEEE ! EEED\x1b[0m
\x1b[32m !\x1b[0m \x1b[34mInitialization Complete!\x1b[0m This server has booted \x1b[4m${count}\x1b[0m time${count == 1 ? '' : 's'} and taken \x1b[4m${tokes}\x1b[0m toke${tokes == 1 ? '' : 's'}.
\x1b[32m !\x1b[0m This server was first booted on \x1b[4m${date}\x1b[0m.`
//Dump art to console
console.log(art);
//Add some extra padding for the port printout from server.js
process.stdout.write(' ');
}

View file

@ -19,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,7 +39,22 @@ const transporter = nodeMailer.createTransport({
}
});
/**
* Sends an email as tokebot to the requested user w/ the requested body and signature
* @param {String} to - String containing the email address to send to
* @param {String} subject - Subject line of the email to send
* @param {String} body - Body contents, either HTML or Plaintext
* @param {Boolean} htmlBody - Whether or not Body contents should be sent as HTML or Plaintext
* @returns {Object} Sent mail info
*/
module.exports.mailem = async function(to, subject, body, htmlBody = false){
try{
//If we have a bad email address
if(!validator.isEmail(to)){
//fuck off
return;
}
//Create mail object
const mailObj = {
from: `"Tokebot🤖💨"<${config.mail.address}>`,
@ -54,17 +77,50 @@ module.exports.mailem = async function(to, subject, body, htmlBody = false){
//return the mail info
return sentMail;
}catch(err){
loggerUtils.dumpError(err, new Date(), 'crash/mail/');
}
}
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
);

View file

@ -0,0 +1,113 @@
/*Canopy - The next generation of stoner streaming software
Copyright (C) 2024-2025 Rainbownapkin and the TTN Community
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.*/
//Node Imports
const validator = require('validator');
//Local Imports
const media = require('../../app/channel/media/media.js');
const regexUtils = require('../regexUtils.js');
const loggerUtils = require('../loggerUtils.js')
/**
* Pulls metadate for a given archive.org item
* @param {String} fullID - Full path of the requested upload
* @param {String} title - Title to add to media object
* @returns {Array} Generated list of media objects from given upload path
*/
module.exports.fetchMetadata = async function(fullID, title){
//Split fullID by first slash
const [itemID, requestedPath] = decodeURIComponent(fullID).split(/\/(.*)/);
//Create empty list to hold media objects
const mediaList = [];
//Create empty variable to hold return data object
let data;
//Create metadata link from itemID
const metadataLink = `https://archive.org/metadata/${itemID}`;
//Fetch item metadata from the internet archive
const response = await fetch(metadataLink,
{
method: "GET"
}
);
//If we hit a snag
if(!response.ok){
//Scream and shout
const errorBody = await response.text();
throw loggerUtils.exceptionSmith(`Internet Archive Error '${response.status}': ${errorBody}`, "queue");
}
//Collect our metadata
const rawMetadata = await response.json();
//Filter out any in-compatible files
const compatibleFiles = rawMetadata.files.filter(compatibilityFilter);
//If we're requesting an empty path
if(requestedPath == '' || requestedPath == null){
//Return item metadata and compatible files
data = {
files: compatibleFiles,
metadata: rawMetadata.metadata
}
//Other wise
}else{
//Return item metadata and matching compatible files
data = {
//Filter files out that don't match requested path and return remaining list
files: compatibleFiles.filter(pathFilter),
metadata: rawMetadata.metadata
}
}
//for every compatible and relevant file returned from IA
for(let file of data.files){
//Split file path by directories
const path = file.name.split('/');
//pull filename from path and escape in-case someone put something nasty in there
const name = validator.escape(validator.trim(path[path.length - 1]));
//Construct link from pulled info
const link = `https://archive.org/download/${data.metadata.identifier}/${file.name}`;
//if we where handed a null title
if(title == null || title == ''){
//Create new media object from file info substituting filename for title
mediaList.push(new media(name, name, link, link, 'ia', Number(file.length)));
}else{
//Create new media object from file info
mediaList.push(new media(title, name, link, link, 'ia', Number(file.length)));
}
}
//return media object list
return mediaList;
function compatibilityFilter(file){
//return true for all files that match for web-safe formats
return file.format.match(/^h\.264/) || file.format == "Ogg Video" || file.format.match("MPEG4");
}
function pathFilter(file){
//return true for all file names which match the given requested file path
return file.name.match(`^${regexUtils.escapeRegex(requestedPath)}`);
}
}

173
src/utils/media/yanker.js Normal file
View file

@ -0,0 +1,173 @@
/*Canopy - The next generation of stoner streaming software
Copyright (C) 2024-2025 Rainbownapkin and the TTN Community
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.*/
//NPM Imports
//const url = require("node:url");
const validator = require('validator');//No express here, so regular validator it is!
const {sanitizeUrl} = require("@braintree/sanitize-url");
//local import
const iaUtil = require('./internetArchiveUtils');
const ytdlpUtil = require('./ytdlpUtils');
/**
* Checks a given URL and runs the proper metadata fetching function to create a media object from any supported URL
* @param {String} url - URL to yank media against
* @param {String} title - Title to apply to yanked media
* @returns {Array} Returns list of yanked media objects on success
*/
module.exports.yankMedia = async function(url, title){
//Get pull type
const pullType = await this.getMediaType(url);
//Check pull type
switch(pullType.type){
case "ia":
//return media object list from IA module
return await iaUtil.fetchMetadata(pullType.id, title);
case "yt":
//return media object list from the YT-DLP module's youtube function
return await ytdlpUtil.fetchYoutubeMetadata(pullType.id, title);
case "ytp":
//return media object list from YT-DLP module's youtube playlist function
//return await ytdlpUtil.fetchYoutubePlaylistMetadata(pullType.id, title);
//Holding off on this since YT-DLP takes 10 years to do a playlist as it needs to pull each and every video one-by-one
//Maybe in the future a piped alternative might be in order, however this would most likely require us to host our own local instance.
//Though it could give us added resistance against youtube/google's rolling IP bans
return null;
case "dm":
//return mediao object list from the YT-DLP module's dailymotion function
return await ytdlpUtil.fetchDailymotionMetadata(pullType.id, title);
default:
//return null to signify a bad url
return null;
}
}
/**
* Refreshes raw links on relevant media objects
*
* Useful for sources like youtube, who only provide expiring raw links
* @param {ScheduledMedia} mediaObj - Media Object to refresh
* @returns {ScheduledMedia} Refreshed media object
*/
module.exports.refreshRawLink = async function(mediaObj){
switch(mediaObj.type){
case 'yt':
//Create boolean to hold expired state
let expired = false;
//Create boolean to hold whether or not rawLink object is empty
let empty = true;
//For each link map in the rawLink object
for(const key of Object.keys(mediaObj.rawLink)){
//Ignore da wombo-combo since it's probably just the fuckin regular URL
if(key != "combo"){
for(const link of mediaObj.rawLink[key]){
//Let it be known, this bitch got links
empty = false;
//Get expiration parameter from the link
const expires = new URL(link[1]).searchParams.get("expire") * 1000;
//If this shit's already expired
if(expires < Date.now()){
//Set expired to true, don't directly set the bool because we don't ever want to unset this flag
expired = true;
}
}
}
}
//If the raw link object is empty or expired
if(empty || expired){
//Re-fetch media metadata
metadata = await ytdlpUtil.fetchYoutubeMetadata(mediaObj.id);
//Refresh media rawlink from metadata
mediaObj.rawLink = metadata[0].rawLink;
//return media object
return mediaObj;
}
}
//Return null to tell the calling function there is no refresh required for this media type
return null;
}
/**
* Detects media type by URL
*
* I'd be lying if this didn't take at least some inspiration/regex patterns from extractQueryParam() in cytube/forest's browser-side 'util.js'
* Still this has some improvements like url pre-checks and the fact that it's handled serverside, recuing possibility of bad requests.
* Some of the regex expressions for certain services have also been improved, such as youtube, and the fore.st-unique archive.org
*
* @param {String} dirtyURL - URL to determine media type of
* @returns {Object} containing URL type and clipped ID string
*/
module.exports.getMediaType = async function(dirtyURL){
//Sanatize our URL
const url = sanitizeUrl(dirtyURL);
//Check if we have a valid url, encode it on the fly in case it's too humie-friendly
if(!validator.isURL(encodeURI(url,{require_valid_protocol: true}))){
//If not toss the fucker out
return {
type: null,
id: null
}
}
//If we have link to a resource from archive.org
if(match = url.match(/archive\.org\/(?:details|download)\/([a-zA-Z0-9\/._-\s\%]+)/)){
//return internet archive upload id and filepath
return {
type: "ia",
id: match[1]
}
}
//If we have a match to a youtube video
if((match = url.match(/youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})/)) || (match = url.match(/youtu\.be\/([a-zA-Z0-9_-]{11})/))){
//return youtube video id
return {
type: "yt",
id: match[1]
}
}
//If we have a match to a youtube playlist
if((match = url.match(/youtube\.com\/playlist\?list=([a-zA-Z0-9_-]{34})/)) || (match = url.match(/youtu\.be\/playlist\?list=([a-zA-Z0-9_-]{34})/))){
//return youtube playlist id
return {
type: "ytp",
id: match[1]
}
}
//If we have a match to a dailymotion video
if(match = url.match(/dailymotion\.com\/video\/([a-zA-Z0-9]+)/)){
return {
type: "dm",
id: match[1]
}
}
//If we fell through all of our media types without a match
return{
type: null,
id: null
}
}

View file

@ -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 youre not a bot.")){
//Make our own error with blackjack and hookers
throw loggerUtils.exceptionSmith("The server's IP address has been banned by youtube. Please contact your server's administrator.", "queue");
//Otherwise if we don't have a good way to handle it
}else{
//toss it back up
throw err;
}
}
}
/**
* Pulls metadata for a playlist of youtube videos via YT-DLP
* @param {String} id - Youtube Playlist ID
* @param {String} title - Title to add to the given media objects
* @returns {Array} Array of Media objects containing relevant metadata
*/
module.exports.fetchYoutubePlaylistMetadata = async function(id, title){
try{
//Try to pull media from youtube id
const media = await fetchPlaylistMetadata(`https://youtu.be/playlist?list=${id}`, title, 'yt');
//Return found media
return media;
//If something went wrong
}catch(err){
//If our IP was banned by youtube
if(err.message.match("Sign in to confirm youre not a bot.")){
//Make our own error with blackjack and hookers
throw loggerUtils.exceptionSmith("The server's IP address has been banned by youtube. Please contact your server's administrator.", "queue");
//Otherwise if we don't have a good way to handle it
}else{
//toss it back up
throw err;
}
}
}
/* This requires HLS embeds which, in-turn, require daily motion to add us to their CORS exception list
* Not gonna happen, so we need to use their API for this, or proxy the video
module.exports.fetchDailymotionMetadata = async function(id, title){
//Pull media from dailymotion link
const media = await fetchVideoMetadata(`https://dailymotion.com/video/${id}`, title, 'dm');
//Return found media;
return media;
}*/
/**
* Generic single video YTDLP function meant to be used by service-sepecific fetchers which will then be used to fetch video metadata
* @param {String} link - Link to video in question
* @param {String} title - Title to add to the given media objects
* @param {String} type - Link type to attach to the resulting media object
* @returns {Array} Array of Media objects containing relevant metadata
*/
async function fetchVideoMetadata(link, title, type, format = 'ba,bv'){
//Create media list
const mediaList = [];
//Pull raw metadata from YT-DLP
const rawMetadata = await ytdlpFetch(link, format);
//Pull data from rawMetadata, sanatizing title to prevent XSS
const name = validator.escape(validator.trim(rawMetadata.title));
//Create new raw link object (should we make a class? Probably over kill for a fucking method-less hashtable)
const rawLinks = {
audio: [],
video: [],
combo: []
}
//for each item
for(const link of rawMetadata.requested_downloads){
//if there isn't video included
if(link.vcodec == 'none'){
//Add the link under the format within the audio map
rawLinks.audio.push([link.format_note, link.url]);
//if there isn't audio included
}else if(link.acodec == 'none'){
//Add the link under the format within the video map
rawLinks.video.push([link.format_note, link.url]);
//otherwise, it includes audio and video
}else{
//Add the link under the format within the combo map
rawLinks.combo.push([link.format_note, link.url]);
}
}
const id = rawMetadata.id;
//if we where handed a null title
if(title == null || title == ''){
//Create new media object from file info substituting filename for title
mediaList.push(new media(name, name, link, id, type, Number(rawMetadata.duration), rawLinks));
}else{
//Create new media object from file info
mediaList.push(new media(title, name, link, id, type, Number(rawMetadata.duration), rawLinks));
}
//Return list of media
return mediaList;
}
//YT-DLP takes forever to handle playlists, we'll handle this via piped in the future perhaps
/*async function fetchPlaylistMetadata(link, title, type, format = 'b'){
}*/
//Wrapper function for YT-DLP NPM package with pre-set cli-flags
/**
* Basic async YT-DLP Fetch wrapper, ensuring config
* @param {String} link - Link to fetch using YT-DLP
* @param {String} format - Format string to hand YT-DLP, defaults to 'b'
* @returns {Object} Metadata dump from YT-DLP
*/
async function ytdlpFetch(link, format = 'ba,ogg'){
//return promise from ytdlp
return ytdlp(link, {
format,
dumpSingleJson: true,
});
}

134
src/utils/presenceUtils.js Normal file
View 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();
}
}
}

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

View file

@ -22,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();

View file

@ -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){
//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 new Error(`The IP address you are trying to login from has been banned.`);
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,16 +137,22 @@ 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(!useRememberMeToken){
//If we got to here then the log-in was successful. We should clear-out any failed attempts.
failedAttempts.delete(user);
failedAttempts.delete(identifier);
}
//return user
return userDB.user;
return userDB;
}catch(err){
//Failed attempts at good tokens are handled by the token schema by dropping the users effected tokens and screaming bloody murder
//Failed attempts with bad tokens don't need to be handled as it's not like attacking a bad UUID is going to get you anywhere anywho
//This also makes it way easier to re-use parts of this function
if(!useRememberMeToken){
//Look for previous failed attempts
var attempt = failedAttempts.get(user);
var attempt = failedAttempts.get(identifier);
//If this is the first attempt
if(attempt == null){
@ -116,22 +170,35 @@ module.exports.authenticateSession = async function(user, pass, req){
}
//Commit the failed attempt to the failed sign-in cache
failedAttempts.set(user, attempt);
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
View 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];
}

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