/*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 .*/ //NPM Imports const {mongoose} = require('mongoose'); const {validationResult, matchedData} = require('express-validator'); //Local Imports //Server const server = require('../../server'); //DB Models const statModel = require('../statSchema'); const {userModel} = require('../user/userSchema'); const permissionModel = require('../permissionSchema'); const emoteModel = require('../emoteSchema'); //DB Schemas const channelPermissionSchema = require('./channelPermissionSchema'); const channelBanSchema = require('./channelBanSchema'); const queuedMediaSchema = require('./media/queuedMediaSchema'); const playlistSchema = require('./media/playlistSchema'); //Utils const { exceptionHandler, errorHandler } = require('../../utils/loggerUtils'); const channelSchema = new mongoose.Schema({ id: { type: mongoose.SchemaTypes.Number, required: true }, name: { type: mongoose.SchemaTypes.String, required: true, maxLength: 50, default: 0 }, description: { type: mongoose.SchemaTypes.String, required: true, maxLength: 1000, default: 0 }, thumbnail: { type: mongoose.SchemaTypes.String, required: true, default: "/img/johnny.png" }, settings: { hidden: { type: mongoose.SchemaTypes.Boolean, required: true, default: true }, }, permissions: { type: channelPermissionSchema, default: () => ({}) }, rankList: [{ user: { type: mongoose.SchemaTypes.ObjectID, required: true, ref: "user" }, rank: { type: mongoose.SchemaTypes.String, required: true, enum: permissionModel.rankEnum } }], tokeCommands: [{ type: mongoose.SchemaTypes.String, required: true }], //Not re-using the site-wide schema because post/pre save should call different functions emotes: [{ name:{ type: mongoose.SchemaTypes.String, required: true }, link:{ type: mongoose.SchemaTypes.String, required: true }, type:{ type: mongoose.SchemaTypes.String, required: true, enum: emoteModel.typeEnum, default: emoteModel.typeEnum[0] } }], media: { nowPlaying: queuedMediaSchema, scheduled: [queuedMediaSchema], //We should consider moving archived media and channel playlists to their own collections/models for preformance sake archived: [queuedMediaSchema], playlists: [playlistSchema] }, //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] }); 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: '-_.'"); } } //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){ //Get the rank list before it was modified (gross but works, find a better way if you dont like it :P) var chanDB = await module.exports.findOne({_id: this._id}); //Create empty variable for the found rank object var foundRank = null; if(chanDB != null){ //If we're removing one if(chanDB.rankList.length > this.rankList.length){ //Child/Parent is *WAY* tooo atomic family for my tastes :P var top = chanDB; var bottom = this; }else{ //otherwise reverse the loops var top = this; var bottom = chanDB; } //Populate the top doc await top.populate('rankList.user'); //For each rank in the dommy-top copy of the rank list top.rankList.forEach((topObj) => { //Create empty variable for the matched rank var matchedRank = null; //For each rank in the subby-bottom copy of the rank list bottom.rankList.forEach((bottomObj) => { //So long as both users exist (we're not working with deleted users) if(topObj.user != null && bottomObj.user != null){ //If it's the same user if(topObj.user._id.toString() == bottomObj.user._id.toString()){ //matched rank found matchedRank = bottomObj; } } }); //If matched rank is null or isn't the topObject rank if(matchedRank == null || matchedRank.rank != topObj.rank){ //Set top object to found rank foundRank = topObj; } }); //get relevant active channel const activeChan = server.channelManager.activeChannels.get(this.name); //if the channel is online if(activeChan != null){ //make sure we're not trying to kick a deleted user if(foundRank.user != null){ //Get the relevant user connection const userConn = activeChan.userList.get(foundRank.user.user); //if the user is online if(userConn != null){ //kick the user userConn.disconnect("Your channel rank has changed!"); } } } } } //if the toke commands where changed if(this.isModified("tokeCommands")){ //Get the active Channel object from the application side of the house const activeChannel = server.channelManager.activeChannels.get(this.name); //If the channel is active if(activeChannel != null){ //Reload the toke command list activeChannel.tokeCommands = this.tokeCommands; } } //if emotes where modified if(this.isModified('emotes')){ //Get the active Channel object from the application side of the house const activeChannel = server.channelManager.activeChannels.get(this.name); //If the channel is active if(activeChannel != null){ //Broadcast the emote list activeChannel.broadcastChanEmotes(this); } } next(); }); //statics 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!"); }else{ const id = await statModel.incrementChannelCount(); const rankList = [{ user: ownerObj._id, rank: "admin" }]; const newChannel = await this.create((thumbnail ? {id, name, description, thumbnail, rankList} : {id, name, description, rankList})); } } channelSchema.statics.getChannelList = async function(includeHidden = false){ const chanDB = await this.find({}); var chanGuide = []; //crawl through channels chanDB.forEach((channel) => { //For each channel, push an object with only the information we need to the channel guide if(!channel.settings.hidden || includeHidden){ chanGuide.push({ id: channel.id, name: channel.name, description: channel.description, thumbnail: channel.thumbnail }); } }); //return the channel guide return chanGuide; } //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 channelSchema.statics.reqPermCheck = function(perm, chanField = "chanName"){ return (req, res, next)=>{ try{ //Check validation result const validResult = validationResult(req); //if our chan field is set to '/c/', telling us to check the URL if(chanField == '/c/'){ //Rip the chan name out of the URL var chanName = (req.originalUrl.split('/c/')[1].replace('/settings','')); }else if(validResult.isEmpty()){ //otherwise if our input is valid, use that var chanName = matchedData(req)[chanField]; }else{ //We didn't get /c/ and we got a bad input, time for shit to hit the fan! res.status(400); return res.send({errors: validResult.array()}) } //Find the related channel document, and handle it using a then() block this.findOne({name: chanName}).then((chanDB) => { //If we didnt find a channel if(chanDB == null){ //FUCK return errorHandler(res, "You cannot check permissions against a non-existant channel!", 'Unauthorized', 401); } //Run a perm check against the current user and permission chanDB.permCheck(req.session.user, perm).then((permitted) => { if(permitted){ //if we're permitted, go on to fulfill the request next(); }else{ //If not, prevent the request from going through and tell them why return errorHandler(res, "You do not have a high enough rank to access this resource.", 'Unauthorized', 401); } }); }); }catch(err){ return exceptionHandler(res, err); } } } channelSchema.statics.processExpiredBans = async function(){ const chanDB = await this.find({}); chanDB.forEach((channel) => { channel.banList.forEach(async (ban, i) => { //ignore permanent and non-expired bans if(ban.expirationDays >= 0 && ban.getDaysUntilExpiration() <= 0){ //Get the index of the ban channel.banList.splice(i,1); return await channel.save(); } }) }); } //methods channelSchema.methods.updateSettings = async function(settingsMap){ settingsMap.forEach((value, key) => { if(this.settings[key] == null){ throw new Error("Invalid channel setting."); } this.settings[key] = value; }) await this.save(); return this.settings; } channelSchema.methods.rankCrawl = async function(userDB,cb){ //Crawl through channel rank list //TODO: replace this with rank check function shared with setRank this.rankList.forEach(async (rankObj, rankIndex) => { //check against user ID to speed things up if(rankObj.user != null && rankObj.user._id.toString() == userDB._id.toString()){ //If we found a match, call back cb(rankObj, rankIndex); } }); } channelSchema.methods.setRank = async function(userDB,rank){ //Create variable to store found ranks var foundRankIndex = null; //Crawl through ranks to find matching index this.rankCrawl(userDB,(rankObj, rankIndex)=>{foundRankIndex = rankIndex}); //If we found an existing rank object if(foundRankIndex != null){ if(rank == "user"){ this.rankList.splice(foundRankIndex,1); }else{ //otherwise, set the users rank this.rankList[foundRankIndex].rank = rank; } }else if(rank != "user"){ //if the user rank object doesn't exist, and we're not setting to user //Create rank object based on input const rankObj = { user: userDB._id, rank: rank } //Add it to rank list this.rankList.push(rankObj); } //Save our channel and return rankList await this.save(); return this.rankList; } channelSchema.methods.getRankList = async function(){ //Create an empty array to hold the user list const rankList = new Map() //Create temp rank list to replace the current one in the advant we have busted users let tempRankList = []; //Flag that lets us know we gotta save let reqSave = false; //Populate the user objects in our ranklist based off of their DB ID's await this.populate('rankList.user'); //For each rank object in the rank list for(rankObjIndex in this.rankList){ const rankObj = this.rankList[rankObjIndex]; //If the use still exists if(rankObj.user != null){ //Push current rank object to the temp rank list in the advant that it doesn't get saved tempRankList.push(rankObj); //Create a new user object from rank object data const userObj = { id: rankObj.user.id, user: rankObj.user.user, img: rankObj.user.img, rank: rankObj.rank } //Add our user object to the list rankList.set(rankObj.user.user, userObj); //Otherwise if it's an invalid rank for a deleted user }else{ //Ignore the rank object and throw the save flag to save the temporary rank list reqSave = true; } } //if we need to save the temp rank list if(reqSave){ //set rank list this.rankList = tempRankList; //save await this.save(); } //return userList return rankList; } channelSchema.methods.getChannelRankByUserDoc = async function(userDB = null){ var foundRank = null; //Check to make sure userDB exists before going forward if(userDB == null){ //If so this user is probably not signed in return "anon" } //Crawl through ranks to find matching rank this.rankCrawl(userDB,(rankObj)=>{foundRank = rankObj}); //If we found an existing rank object if(foundRank != null){ //return rank return foundRank.rank; }else{ //default to "user" for registered users, and "anon" for anonymous if(userDB.rank == "anon"){ return "anon"; }else{ return "user"; } } } channelSchema.methods.getChannelRank = async function(user){ const userDB = await userModel.findOne({user: user.user}); return await this.getChannelRankByUserDoc(userDB); } 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) } 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"); //Get channel rank as number const chanRank = permissionModel.rankToNum(await this.getChannelRankByUserDoc(userDB)); //Get channel permission rank requirement as number const permRank = permissionModel.rankToNum(this.permissions[perm]); //Get site-wide rank requirement to override as number const overrideRank = permissionModel.rankToNum((await permissionModel.getPerms()).channelOverrides[perm]); //Get channel perm check result const permCheck = (chanRank >= permRank); //Get site-wide override perm check result const overrideCheck = (rank >= overrideRank); return (permCheck || overrideCheck); } 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 perm map return { site: siteMap.site, chan: permMap }; } channelSchema.methods.checkBanByUserDoc = async function(userDB){ var foundBan = null; //this needs to be a for loop for async //this.banList.forEach((ban) => { for(banIndex in this.banList){ if(this.banList[banIndex].user != null){ if(this.banList[banIndex].user.toString() == userDB._id.toString()){ foundBan = this.banList[banIndex]; } //If this bans alts are banned if(this.banList[banIndex].banAlts){ //Populate the user of the current ban being checked await this.populate(`banList.${banIndex}.user`); //If this is an alt of the banned user if(await this.banList[banIndex].user.altCheck(userDB)){ foundBan = this.banList[banIndex]; } } } } return foundBan; } channelSchema.methods.getEmotes = function(){ //Create an empty array to hold our emote list const emoteList = []; //For each channel emote this.emotes.forEach((emote) => { //Push an object with select information from the emote to the emote list emoteList.push({ name: emote.name, link: emote.link, type: emote.type }); }); //return the emote list return emoteList; } channelSchema.methods.getChanBans = async function(){ //Create an empty list to hold our found bans var banList = []; //Populate the users in the banList await this.populate('banList.user'); //Crawl through known bans this.banList.forEach((ban) => { var banObj = { banDate: ban.banDate, expirationDays: ban.expirationDays, banAlts: ban.banAlts, } //Check if the ban was permanent (expiration set before ban date) if(ban.expirationDays > 0){ //if not calculate expiration date var expirationDate = new Date(ban.banDate); expirationDate.setDate(expirationDate.getDate() + ban.expirationDays); //Set calculated expiration date banObj.expirationDate = expirationDate; banObj.daysUntilExpiration = ban.getDaysUntilExpiration(); } //Setup user object (Do this last to keep it at bottom for human-readibility of json :P) banObj.user = { id: ban.user.id, user: ban.user.user, img: ban.user.img, date: ban.user.date } banList.push(banObj); }); return banList; } 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!"); } const foundBan = await this.checkBanByUserDoc(userDB); if(foundBan != null){ throw new Error("User already banned!"); } //Create a new ban document based on input const banDoc = { user: userDB._id, expirationDays, banAlts } const activeChan = server.channelManager.activeChannels.get(this.name); if(activeChan != null){ const userConn = activeChan.userList.get(userDB.user); if(userConn != null){ if(expirationDays < 0){ userConn.disconnect("You have been permanently banned from this channel!"); }else{ userConn.disconnect(`You have been banned from this channel for ${expirationDays} day(s)!`); } } } //Push the ban to the list this.banList.push(banDoc); await this.save(); } channelSchema.methods.ban = async function(user, expirationDays, banAlts){ const userDB = await userModel.find({user}); return await this.banByUserDoc(userDB, expirationDays, banAlts); } 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!"); } const foundBan = await this.checkBanByUserDoc(userDB); if(foundBan == null){ throw new Error("User already unbanned!"); } //You know I can't help but feel like an asshole for looking for the index of something I just pulled out of an array using forEach... //Then again this is such an un-used function that the issue of code re-use overshadows performance //I mean how often are we REALLY going to be un-banning users from channels? const banIndex = this.banList.indexOf(foundBan); this.banList.splice(banIndex,1); return await this.save(); } channelSchema.methods.unban = async function(user){ const userDB = await userModel.find({user}); return await this.unbanByUserDoc(userDB); } channelSchema.methods.nuke = async function(confirm){ if(confirm == "" || confirm == null){ throw new Error("Empty Confirmation String!"); }else if(confirm != this.name){ throw new Error("Bad Confirmation String!"); } //Annoyingly there isnt a good way to do this from 'this' var oldChan = await this.deleteOne(); if(oldChan.deletedCount == 0){ throw new Error("Server Error: Unable to delete channel! Please report this error to your server administrator, and with timestamp."); } } module.exports = mongoose.model("channel", channelSchema);