canopy/src/schemas/channel/channelSchema.js

676 lines
23 KiB
JavaScript

/*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 {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);