diff --git a/lib/channel-new.js b/lib/channel-new.js deleted file mode 100644 index bac6e95d..00000000 --- a/lib/channel-new.js +++ /dev/null @@ -1,2963 +0,0 @@ -var util = require("./utilities"); -var db = require("./database"); -var Playlist = require("./playlist"); -var Poll = require("./poll").Poll; -var Filter = require("./filter").Filter; -var Logger = require("./logger"); -var AsyncQueue = require("./asyncqueue"); -var MakeEmitter = require("./emitter"); -var InfoGetter = require("./get-info"); -var ChatCommand = require("./chatcommand"); -var XSS = require("./xss"); - -var fs = require("fs"); -var path = require("path"); -var url = require("url"); - -var DEFAULT_FILTERS = [ - new Filter("monospace", "`(.+?)`", "g", "$1"), - new Filter("bold", "\\*(.+?)\\*", "g", "$1"), - new Filter("italic", "_(.+?)_", "g", "$1"), - new Filter("strike", "~~(.+?)~~", "g", "$1"), - new Filter("inline spoiler", "\\[sp\\](.*?)\\[\\/sp\\]", "ig", "$1") -]; - -function Channel(name) { - MakeEmitter(this); - var self = this; // Alias `this` to prevent scoping issues - Logger.syslog.log("Loading channel " + name); - - // Defaults - self.ready = false; - self.name = name; - self.uniqueName = name.toLowerCase(); // To prevent casing issues - self.registered = false; // set to true if the channel exists in the database - self.users = []; - self.mutedUsers = new util.Set(); - self.playlist = new Playlist(self); - self.plmeta = { count: 0, time: "00:00:00" }; - self.plqueue = new AsyncQueue(); // For synchronizing playlist actions - self.drinks = 0; - self.leader = null; - self.chatbuffer = []; - self.playlistLock = true; - self.poll = null; - self.voteskip = null; - self.permissions = { - playlistadd: 1.5, // Add video to the playlist - playlistnext: 1.5, - playlistmove: 1.5, // Move a video on the playlist - playlistdelete: 2, // Delete a video from the playlist - playlistjump: 1.5, // Start a different video on the playlist - playlistaddlist: 1.5, // Add a list of videos to the playlist - oplaylistadd: -1, // Same as above, but for open (unlocked) playlist - oplaylistnext: 1.5, - oplaylistmove: 1.5, - oplaylistdelete: 2, - oplaylistjump: 1.5, - oplaylistaddlist: 1.5, - playlistaddcustom: 3, // Add custom embed to the playlist - playlistaddlive: 1.5, // Add a livestream to the playlist - exceedmaxlength: 2, // Add a video longer than the maximum length set - addnontemp: 2, // Add a permanent video to the playlist - settemp: 2, // Toggle temporary status of a playlist item - playlistgeturl: 1.5, // TODO is this even used? - playlistshuffle: 2, // Shuffle the playlist - playlistclear: 2, // Clear the playlist - pollctl: 1.5, // Open/close polls - pollvote: -1, // Vote in polls - viewhiddenpoll: 1.5, // View results of hidden polls - voteskip: -1, // Vote to skip the current video - mute: 1.5, // Mute other users - kick: 1.5, // Kick other users - ban: 2, // Ban other users - motdedit: 3, // Edit the MOTD - filteredit: 3, // Control chat filters - filterimport: 3, // Import chat filter list - playlistlock: 2, // Lock/unlock the playlist - leaderctl: 2, // Give/take leader - drink: 1.5, // Use the /d command - chat: 0 // Send chat messages - }; - self.opts = { - allow_voteskip: true, // Allow users to voteskip - voteskip_ratio: 0.5, // Ratio of skip votes:non-afk users needed to skip the video - afk_timeout: 600, // Number of seconds before a user is automatically marked afk - pagetitle: self.name, // Title of the browser tab - maxlength: 0, // Maximum length (in seconds) of a video queued - externalcss: "", // Link to external stylesheet - externaljs: "", // Link to external script - chat_antiflood: false, // Throttle chat messages - chat_antiflood_params: { - burst: 4, // Number of messages to allow with no throttling - sustained: 1, // Throttle rate (messages/second) - cooldown: 4 // Number of seconds with no messages before burst is reset - }, - show_public: false, // List the channel on the index page - enable_link_regex: true, // Use the built-in link filter - password: false // Channel password (false -> no password required for entry) - }; - self.motd = { - motd: "", // Raw MOTD text - html: "" // Filtered MOTD text (XSS removed; \n replaced by
) - }; - self.filters = DEFAULT_FILTERS; - self.logger = new Logger.Logger(path.join(__dirname, "../chanlogs", - self.uniqueName + ".log")); - self.css = ""; // Up to 20KB of inline CSS - self.js = ""; // Up to 20KB of inline Javascript - - self.error = false; // Set to true if something bad happens => don't save state - - self.on("ready", function () { - self.ready = true; - }); - - // Load from database - db.channels.load(self, function (err) { - if (err && err !== "Channel is not registered") { - return; - } else { - // Load state from JSON blob - self.tryLoadState(); - } - }); -}; - -Channel.prototype.isMuted = function (name) { - return this.mutedUsers.contains(name.toLowerCase()) || - this.mutedUsers.contains("[shadow]" + name.toLowerCase()); -}; - -Channel.prototype.isShadowMuted = function (name) { - return this.mutedUsers.contains("[shadow]" + name.toLowerCase()); -}; - -Channel.prototype.mutedUsers = function () { - var self = this; - return self.users.filter(function (u) { - return self.mutedUsers.contains(u.name); - }); -}; - -Channel.prototype.shadowMutedUsers = function () { - var self = this; - return self.users.filter(function (u) { - return self.mutedUsers.contains("[shadow]" + u.name); - }); -}; - -Channel.prototype.channelModerators = function () { - return this.users.filter(function (u) { - return u.rank >= 2; - }); -}; - -Channel.prototype.channelAdmins = function () { - return this.users.filter(function (u) { - return u.rank >= 3; - }); -}; - -Channel.prototype.tryLoadState = function () { - var self = this; - if (self.name === "") { - return; - } - - // Don't load state if the channel isn't registered - if (!self.registered) { - self.emit("ready"); - return; - } - - var file = path.join(__dirname, "../chandump", self.uniqueName); - fs.stat(file, function (err, stats) { - if (!err) { - var mb = stats.size / 1048576; - mb = Math.floor(mb * 100) / 100; - if (mb > 1) { - Logger.errlog.log("Large chandump detected: " + self.uniqueName + - " (" + mb + " MiB)"); - self.setMOTD("Your channel file has exceeded the maximum size of 1MB " + - "and cannot be loaded. Please ask an administrator for " + - "assistance in restoring it."); - self.error = true; - self.emit("ready"); - return; - } - } - - self.loadState(); - }); -}; - -/** - * Load the channel state from disk. - * - * SHOULD ONLY BE CALLED FROM tryLoadState - */ -Channel.prototype.loadState = function () { - var self = this; - if (self.error) { - return; - } - - fs.readFile(path.join(__dirname, "../chandump", self.uniqueName), - function (err, data) { - if (err) { - // File didn't exist => start fresh - if (err.code === "ENOENT") { - self.emit("ready"); - self.saveState(); - } else { - Logger.errlog.log("Failed to open channel dump " + self.uniqueName); - Logger.errlog.log(err); - self.setMOTD("Channel state load failed. Contact an administrator."); - self.error = true; - self.emit("ready"); - } - return; - } - - try { - self.logger.log("*** Loading channel state"); - data = JSON.parse(data); - - // Load the playlist - if ("playlist" in data) { - self.playlist.load(data.playlist, function () { - self.sendPlaylist(self.users); - self.updatePlaylistMeta(); - self.sendPlaylistMeta(self.users); - self.playlist.startPlayback(data.playlist.time); - }); - } - - // Playlist lock - self.setLock(data.playlistLock || false); - - // Configurables - if ("opts" in data) { - for (var key in data.opts) { - self.opts[key] = data.opts[key]; - } - } - - // Permissions - if ("permissions" in data) { - for (var key in data.permissions) { - self.permissions[key] = data.permissions[key]; - } - } - - // Chat filters - if ("filters" in data) { - for (var i = 0; i < data.filters.length; i++) { - var f = data.filters[i]; - var filt = new Filter(f.name, f.source, f.flags, f.replace); - filt.active = f.active; - filt.filterlinks = f.filterlinks; - self.updateFilter(filt, false); - } - } - - // MOTD - if ("motd" in data) { - self.motd = { - motd: data.motd.motd, - html: data.motd.html - }; - } - - // Chat history - if ("chatbuffer" in data) { - data.chatbuffer.forEach(function (msg) { - self.chatbuffer.push(msg); - }); - } - - // Inline CSS/JS - self.css = data.css || ""; - self.js = data.js || ""; - self.emit("ready"); - - } catch (e) { - self.error = true; - Logger.errlog.log("Channel dump load failed (" + self.uniqueName + "): " + e); - self.setMOTD("Channel state load failed. Contact an administrator."); - self.emit("ready"); - } - }); -}; - -Channel.prototype.saveState = function () { - var self = this; - - if (self.error) { - return; - } - - if (!self.registered || self.uniqueName === "") { - return; - } - - var filters = self.filters.map(function (f) { - return f.pack(); - }); - - var data = { - playlist: self.playlist.dump(), - opts: self.opts, - permissions: self.permissions, - filters: filters, - motd: self.motd, - playlistLock: self.playlistLock, - chatbuffer: self.chatbuffer, - css: self.css, - js: self.js - }; - - var text = JSON.stringify(data); - fs.writeFileSync(path.join(__dirname, "../chandump", self.uniqueName), text); -}; - -/** - * Checks whether a user has the given permission node - */ -Channel.prototype.hasPermission = function (user, key) { - // Special case: you can have separate permissions for when playlist is unlocked - if (key.indexOf("playlist") === 0 && !this.playlistLock) { - var key2 = "o" + key; - var v = this.permissions[key2]; - if (typeof v === "number" && user.rank >= v) { - return true; - } - } - - var v = this.permissions[key]; - if (typeof v !== "number") { - return false; - } - - return user.rank >= v; -}; - -/** - * Defer a callback to complete when the channel is ready to accept users. - * Called immediately if the ready flag is already set - */ -Channel.prototype.whenReady = function (fn) { - if (this.ready) { - setImmediate(fn); - } else { - this.on("ready", fn); - } -}; - -/** - * Looks up a user's rank in the channel. Computed as max(global_rank, channel rank) - */ -Channel.prototype.getRank = function (name, callback) { - var self = this; - db.users.getGlobalRank(name, function (err, global) { - if (self.dead) { - return; - } - - if (err) { - callback(err, null); - return; - } - - if (!self.registered) { - callback(null, global); - return; - } - - db.channels.getRank(self.name, name, function (err, rank) { - if (self.dead) { - return; - } - - if (err) { - callback(err, null); - return; - } - - callback(null, Math.max(rank, global)); - }); - }); -}; - -/** - * Looks up the highest rank of any alias of an IP address - */ -Channel.prototype.getIPRank = function (ip, callback) { - var self = this; - db.getAliases(ip, function (err, names) { - if (self.dead) { - return; - } - - db.users.getGlobalRanks(names, function (err, res) { - if (self.dead) { - return; - } - - if (err) { - callback(err, null); - return; - } - - var rank = res.reduce(function (a, b) { - return Math.max(a, b); - }, 0); - - if (!self.registered) { - callback(null, rank); - return; - } - - db.channels.getRanks(self.name, names, - function (err, res) { - if (self.dead) { - return; - } - - if (err) { - callback(err, null); - return; - } - - var rank = res.reduce(function (a, b) { - return Math.max(a, b); - }, rank); - - callback(null, rank); - }); - }); - }); -}; - -/** - * Called when a user attempts to join a channel. - * Handles password check - */ -Channel.prototype.preJoin = function (user, password) { - var self = this; - self.whenReady(function () { - user.whenLoggedIn(function () { - self.getRank(user.name, function (err, rank) { - if (err) { - user.rank = user.global_rank; - } else { - user.rank = Math.max(rank, user.global_rank); - } - - user.socket.emit("rank", user.rank); - user.emit("channelRank", user.rank); - }); - }); - - if (self.opts.password !== false && user.rank < 2) { - if (password !== self.opts.password) { - var checkPassword = function (pw) { - if (self.dead) { - return; - } - - if (pw !== self.opts.password) { - user.socket.emit("needPassword", true); - return; - } - - user.socket.listeners("channelPassword").splice( - user.socket.listeners("channelPassword").indexOf(checkPassword) - ); - - user.socket.emit("cancelNeedPassword"); - self.join(user); - }; - - - user.socket.on("channelPassword", checkPassword); - user.socket.emit("needPassword", typeof password !== "undefined"); - user.once("channelRank", function (r) { - if (!user.inChannel() && !self.dead && r >= 2) { - user.socket.emit("cancelNeedPassword"); - self.join(user); - } - }); - return; - } - } - - self.join(user); - }); -}; - -/** - * Called when a user joins a channel - */ -Channel.prototype.join = function (user) { - var self = this; - - var afterLogin = function () { - if (self.dead) { - return; - } - - var lname = user.name.toLowerCase(); - for (var i = 0; i < self.users.length; i++) { - if (self.users[i].name.toLowerCase() === lname && self.users[i] !== user) { - self.users[i].kick("Duplicate login"); - } - } - - self.sendUserJoin(self.users, user); - self.sendUserlist([user]); - }; - - var afterIPBan = function () { - user.autoAFK(); - user.socket.join(self.uniqueName); - user.channel = self; - - self.users.push(user); - self.sendVoteskipUpdate(self.users); - self.sendUsercount(self.users); - - user.whenLoggedIn(function () { - db.channels.isNameBanned(self.name, user.name, function (err, banned) { - if (!err && banned) { - user.kick("You're banned!"); - } else { - afterLogin(); - } - }); - }); - - self.sendPlaylist([user]); - self.sendMediaUpdate([user]); - self.sendPlaylistLock([user]); - self.sendUserlist([user]); - self.sendRecentChat([user]); - self.sendCSSJS([user]); - self.sendPoll([user]); - self.sendOpts([user]); - self.sendPermissions([user]); - self.sendMotd([user]); - self.sendDrinkCount([user]); - - self.logger.log("+++ " + user.ip + " joined"); - Logger.syslog.log(user.ip + " joined channel " + self.name); - }; - - db.channels.isIPBanned(self.name, user.ip, function (err, banned) { - if (!err && banned) { - user.kick("You're banned!"); - return; - } else { - afterIPBan(); - } - }); -}; - -/** - * Called when a user leaves the channel. - * Cleans up and sends appropriate updates to other users - */ -Channel.prototype.part = function (user) { - var self = this; - user.channel = null; - - // Clear poll vote - if (self.poll) { - self.poll.unvote(user.ip); - self.sendPollUpdate(self.users); - } - - // Clear voteskip vote - if (self.voteskip) { - self.voteskip.unvote(user.ip); - self.sendVoteskipUpdate(self.users); - } - - // Return video lead to server if necessary - if (self.leader === user) { - self.changeLeader(""); - } - - // Remove from users array - var idx = self.users.indexOf(user); - if (idx >= 0 && idx < self.users.length) { - self.users.splice(idx, 1); - } - - // A change in usercount might cause a voteskip result to change - self.checkVoteskipPass(); - self.sendUsercount(self.users); - - if (user.loggedIn) { - self.sendUserLeave(self.users, user); - } - - self.logger.log("--- " + user.ip + " (" + user.name + ") left"); - if (self.users.length === 0) { - self.emit("empty"); - return; - } -}; - -/** - * Send the MOTD to the given users - */ -Channel.prototype.sendMOTD = function (users) { - var motd = this.motd; - users.forEach(function (u) { - u.socket.emit("setMotd", motd); - }); -}; - -/** - * Sends a message to channel moderators - */ -Channel.prototype.sendModMessage = function (msg, minrank) { - if (isNaN(minrank)) { - minrank = 2; - } - - var notice = { - username: "[server]", - msg: msg, - meta: { - addClass: "server-whisper" , - addClassToNameAndTimestamp: true - }, - time: Date.now() - }; - - this.users.forEach(function(u) { - if (u.rank > minrank) { - u.socket.emit("chatMsg", notice); - } - }); -}; - -/** - * Stores a video in the channel's library - */ -Channel.prototype.cacheMedia = function (media) { - // Don't cache Google Drive videos because of their time limit - if (media.type === "gd") { - return false; - } - - if (this.registered) { - db.channels.addToLibrary(this.name, media); - } -}; - -/** - * Attempts to ban a user by name - */ -Channel.prototype.handleNameBan = function (actor, name, reason) { - var self = this; - if (!self.hasPermission(actor, "ban")) { - return false; - } - - if (!self.registered) { - actor.socket.emit("errorMsg", { - msg: "Banning is only supported in registered channels" - }); - return; - } - - name = name.toLowerCase(); - if (name == actor.name.toLowerCase()) { - actor.socket.emit("costanza", { - msg: "Trying to ban yourself?" - }); - return; - } - - // Look up the name's rank so people can't ban others with higher rank than themselves - self.getRank(name, function (err, rank) { - if (self.dead) { - return; - } - - if (err) { - actor.socket.emit("errorMsg", { - msg: "Internal error: " + err - }); - return; - } - - if (rank >= actor.rank) { - actor.socket.emit("errorMsg", { - msg: "You don't have permission to ban " + name - }); - return; - } - - if (typeof reason !== "string") { - reason = ""; - } - - reason = reason.substring(0, 255); - - // If in the channel already, kick the banned user - for (var i = 0; i < self.users.length; i++) { - if (self.users[i].name.toLowerCase() == name) { - self.users[i].kick("You're banned!"); - break; - } - } - self.logger.log("*** " + actor.name + " namebanned " + name); - self.sendModMessage(actor.name + " banned " + name, self.permissions.ban); - - db.channels.isNameBanned(self.name, name, function (err, banned) { - if (!err && banned) { - actor.socket.emit("errorMsg", { - msg: name + " is already banned" - }); - return; - } - - if (self.dead) { - return; - } - - // channel, ip, name, reason, actor - db.channels.ban(self.name, "*", name, reason, actor.name); - // TODO send banlist? - }); - }); -}; - -/** - * Removes a ban by ID - */ -Channel.prototype.handleUnban = function (actor, data) { - var self = this; - if (!this.hasPermission(actor, "ban")) { - return; - } - - if (typeof data.id !== "number") { - data.id = parseInt(data.id); - if (isNaN(data.id)) { - return; - } - } - - data.actor = actor.name; - - if (!self.registered) { - return; - } - - db.channels.unbanId(self.name, data.id, function (err, res) { - if (err) { - actor.socket.emit("errorMsg", { - msg: err - }); - return; - } - - self.sendUnban(self.users, data); - }); -}; - -/** - * Sends an unban packet - */ -Channel.prototype.sendUnban = function (users, data) { - var self = this; - users.forEach(function (u) { - if (self.hasPermission(u, "ban")) { - u.socket.emit("banlistRemove", data); - } - }); - self.logger.log("*** " + data.actor + " unbanned " + data.name); - self.sendModMessage(data.actor + " unbanned " + data.name, self.permissions.ban); -}; - -/** - * Bans all IP addresses associated with a username - */ -Channel.prototype.handleBanAllIP = function (actor, name, reason, range) { - var self = this; - if (!self.hasPermission(actor, "ban")) { - return; - } - - if (typeof name !== "string") { - return; - } - - if (!self.registered) { - actor.socket.emit("errorMsg", { - msg: "Banning is not supported for unregistered rooms" - }); - return; - } - - name = name.toLowerCase(); - if (name === actor.name.toLowerCase()) { - actor.socket.emit("costanza", { - msg: "Trying to ban yourself?" - }); - return; - } - - db.getIPs(name, function (err, ips) { - if (self.dead) { - return; - } - - if (err) { - actor.socket.emit("errorMsg", { - msg: "Internal error: " + err - }); - return; - } - - ips.forEach(function (ip) { - self.tryBanIP(actor, ip, name, range); - }); - }); -}; - -/** - * Bans an individual IP - */ -Channel.prototype.banIP = function (actor, ip, name, reason, range) { - if (range) { - ip = ip.replace(/(\d+)\.(\d+)\.(\d+)\.(\d+)/, "$1.$2.$3"); - } - - if (typeof reason !== "string") { - reason = ""; - } - - reason = reason.substring(0, 255); - - self.getIPRank(ip, function (err, rank) { - if (self.dead) { - return; - } - - if (err) { - actor.socket.emit("errorMsg", { - msg: "Internal error: " + err - }); - return; - } - - if (rank >= actor.rank) { - actor.socket.emit("errorMsg", { - msg: "You don't have permission to ban IP: " + util.maskIP(ip) - }); - return; - } - - self.logger.log("*** " + actor.name + " banned " + ip + " (" + name + ")"); - self.sendModMessage(actor.name + " banned " + ip + " (" + name + ")", self.permissions.ban); - // If in the channel already, kick the banned user - for (var i = 0; i < self.users.length; i++) { - if (self.users[i].ip === ip) { - self.users[i].kick("You're banned!"); - break; - } - } - - if (!self.registered) { - return; - } - - db.channels.isIPBanned(self.name, ip, function (err, banned) { - if (!err && banned) { - var disp = actor.global_rank >= 255 ? ip : util.maskIP(ip); - actor.socket.emit("errorMsg", { - msg: disp + " is alraedy banned" - }); - return; - } - - if (self.dead) { - return; - } - - // channel, ip, name, reason, ban actor - db.channels.ban(self.name, ip, name, reason, actor.name, function (err) { - if (err) { - actor.socket.emit("errorMsg", { - msg: "Ban failed: " + err - }); - } - }); - }); - }); -}; - - -/** - * Sends the banlist - */ -Channel.prototype.sendBanlist = function (users) { - var self = this; - - var bans = []; - var unmaskedbans = []; - db.channels.listBans(self.name, function (err, banlist) { - if (err) { - return; - } - - for (var i = 0; i < banlist.length; i++) { - bans.push({ - id: banlist[i].id, - ip: banlist[i].ip === "*" ? "*" : util.maskIP(banlist[i].ip), - name: banlist[i].name, - reason: banlist[i].reason, - bannedby: banlist[i].bannedby - }); - unmaskedbans.push({ - id: banlist[i].id, - ip: banlist[i].ip, - name: banlist[i].name, - reason: banlist[i].reason, - bannedby: banlist[i].bannedby - }); - } - - users.forEach(function (u) { - if (!self.hasPermission(u, "ban")) { - return; - } - - if (u.rank >= 255) { - u.socket.emit("banlist", unmaskedbans); - } else { - u.socket.emit("banlist", bans); - } - }); - }); -}; - -/** - * Sends the channel ranks list - */ -Channel.prototype.sendChannelRanks = function (users) { - var self = this; - db.channels.allRanks(self.name, function (err, ranks) { - if (err) { - return; - } - - users.forEach(function (u) { - if (u.rank >= 3) { - u.socket.emit("channelRanks", ranks); - } - }); - }); -}; - -/** - * Sends the chat filter list - */ -Channel.prototype.sendChatFilters = function (users) { - var self = this; - - var pkt = self.filters.map(function (f) { - return f.pack(); - }); - - users.forEach(function (u) { - if (!self.hasPermission(u, "filteredit")) { - return; - } - - u.socket.emit("chatFilters", pkt); - }); -}; - -/** - * Sends the channel permissions - */ -Channel.prototype.sendPermissions = function (users) { - var perms = this.permissions; - users.forEach(function (u) { - u.socket.emit("setPermissions", perms); - }); -}; - -/** - * Sends the playlist - */ -Channel.prototype.sendPlaylist = function (users) { - var self = this; - - var pl = self.playlist.items.toArray(); - var current = null; - if (self.playlist.current) { - current = self.playlist.current.uid; - } - - users.forEach(function (u) { - u.socket.emit("playlist", pl); - u.socket.emit("setPlaylistMeta", self.plmeta); - if (current !== null) { - u.socket.emit("setCurrent", current); - } - }); -}; - -/** - * Updates the playlist count/time - */ -Channel.prototype.updatePlaylistMeta = function () { - var total = 0; - var iter = this.playlist.items.first; - while (iter !== null) { - if (iter.media !== null) { - total += iter.media.seconds; - } - iter = iter.next; - } - - var timestr = util.formatTime(total); - this.plmeta = { - count: this.playlist.items.length, - time: timestr - }; -}; - -/** - * Send the playlist count/time - */ -Channel.prototype.sendPlaylistMeta = function (users) { - var self = this; - users.forEach(function (u) { - u.socket.emit("setPlaylistMeta", self.plmeta); - }); -}; - -/** - * Sends the playlist lock - */ -Channel.prototype.sendPlaylistLock = function (users) { - var lock = this.playlistLock; - users.forEach(function (u) { - u.socket.emit("setPlaylistLocked", lock); - }); -}; - -/** - * Sends a changeMedia packet - */ -Channel.prototype.sendMediaUpdate = function (users) { - var update = this.playlist.getFullUpdate(); - if (update) { - users.forEach(function (u) { - u.socket.emit("changeMedia", update); - }); - } -}; - -/** - * Sends the drink count - */ -Channel.prototype.sendDrinkCount = function (users) { - var drinks = this.drinks; - users.forEach(function (u) { - u.socket.emit("drinkCount", drinks); - }); -}; - -/** - * Send the userlist - */ -Channel.prototype.sendUserlist = function (toUsers) { - var self = this; - var base = []; - var mod = []; - var sadmin = []; - - for (var i = 0; i < self.users.length; i++) { - var u = self.users[i]; - if (u.name === "") { - continue; - } - - var data = self.packUserData(self.users[i]); - base.push(data.base); - mod.push(data.mod); - sadmin.push(data.sadmin); - } - - toUsers.forEach(function (u) { - if (u.global_rank >= 255) { - u.socket.emit("userlist", sadmin); - } else if (u.rank >= 2) { - u.socket.emit("userlist", mod); - } else { - u.socket.emit("userlist", base); - } - - if (self.leader != null) { - u.socket.emit("setLeader", self.leader.name); - } - }); -}; - -/** - * Send the user count - */ -Channel.prototype.sendUsercount = function (users) { - var self = this; - users.forEach(function (u) { - u.socket.emit("usercount", self.users.length); - }); -}; - -/** - * Send the chat buffer - */ -Channel.prototype.sendRecentChat = function (users) { - var self = this; - users.forEach(function (u) { - for (var i = 0; i < self.chatbuffer.length; i++) { - u.socket.emit("chatMsg", self.chatbuffer[i]); - } - }); -}; - -/** - * Sends a user profile - */ -Channel.prototype.sendUserProfile = function (users, user) { - var packet = { - name: user.name, - profile: user.profile - }; - - users.forEach(function (u) { - u.socket.emit("setUserProfile", packet); - }); -}; - -/** - * Packs userdata for addUser or userlist - */ -Channel.prototype.packUserData = function (user) { - var base = { - name: user.name, - rank: user.rank, - profile: user.profile, - meta: { - afk: user.meta.afk, - muted: user.meta.muted && !user.meta.smuted - } - }; - - var mod = { - name: user.name, - rank: user.rank, - profile: user.profile, - meta: { - afk: user.meta.afk, - muted: user.meta.muted, - smuted: user.meta.smuted, - aliases: user.meta.aliases, - ip: util.maskIP(user.ip) - } - }; - - var sadmin = { - name: user.name, - rank: user.rank, - profile: user.profile, - meta: { - afk: user.meta.afk, - muted: user.meta.muted, - smuted: user.meta.smuted, - aliases: user.meta.aliases, - ip: user.ip - } - }; - - return { - base: base, - mod: mod, - sadmin: sadmin - }; -}; - -/** - * Sends a user.meta update, optionally filtering by minimum rank - */ -Channel.prototype.sendUserMeta = function (users, user, minrank) { - var self = this; - var userdata = self.packUserData(user); - self.users.filter(function (u) { - return typeof minrank !== "number" || u.rank > minrank - }).forEach(function (u) { - if (u.rank >= 255) { - u.socket.emit("setUserMeta", { - name: user.name, - meta: userdata.sadmin.meta - }); - } else if (u.rank >= 2) { - u.socket.emit("setUserMeta", { - name: user.name, - meta: userdata.mod.meta - }); - } else { - u.socket.emit("setUserMeta", { - name: user.name, - meta: userdata.base.meta - }); - } - }); -}; - -/** - * Send a user join notification - */ -Channel.prototype.sendUserJoin = function (users, user) { - var self = this; - db.getAliases(user.ip, function (err, aliases) { - if (self.dead) { - return; - } - - if (err || aliases.length === 0) { - aliases = [user.name]; - } - - user.meta.aliases = aliases; - - if (self.isShadowMuted(user.name)) { - user.meta.muted = true; - user.meta.shadowmuted = true; - } else if (self.isMuted(user.name)) { - user.meta.muted = true; - user.meta.shadowmuted = false; - } - - var data = self.packUserData(user); - - users.forEach(function (u) { - if (u.global_rank >= 255) { - u.socket.emit("addUser", data.sadmin); - } else if (u.rank >= 2) { - u.socket.emit("addUser", data.mod); - } else { - u.socket.emit("addUser", data.base); - } - }); - - self.sendModMessage(user.name + " joined (aliases: " + aliases.join(",") + ")", 2); - }); -}; - -/** - * Sends a notification that a user left - */ -Channel.prototype.sendUserLeave = function (users, user) { - var data = { - name: user.name - }; - - users.forEach(function (u) { - u.socket.emit("userLeave", data); - }); -}; - -/** - * Sends the current poll - */ -Channel.prototype.sendPoll = function (users) { - var self = this; - if (!self.poll) { - return; - } - - var obscured = self.poll.packUpdate(false); - var unobscured = self.poll.packUpdate(true); - - users.forEach(function (u) { - if (self.hasPermission(u, "viewpollresults")) { - u.socket.emit("newPoll", unobscured); - } else { - u.socket.emit("newPoll", obscured); - } - }); -}; - -/** - * Sends a poll notification - */ -Channel.prototype.sendPollUpdate = function (users) { - var self = this; - var unhidden = self.poll.packUpdate(true); - var hidden = self.poll.packUpdate(false); - - users.forEach(function (u) { - if (self.hasPermission(u, "viewhiddenpoll")) { - u.socket.emit("updatePoll", unhidden); - } else { - u.socket.emit("updatePoll", hidden); - } - }); -}; - -/** - * Sends a "poll closed" notification - */ -Channel.prototype.sendPollClose = function (users) { - users.forEach(function (u) { - u.socket.emit("closePoll"); - }); -}; - -/** - * Broadcasts the channel options - */ -Channel.prototype.sendOpts = function (users) { - var opts = this.opts; - users.forEach(function (u) { - u.socket.emit("channelOpts", opts); - }); -}; - -/** - * Calculates the number of eligible users to voteskip - */ -Channel.prototype.calcVoteskipMax = function () { - var self = this; - return self.users.map(function (u) { - if (!self.hasPermission(u, "voteskip")) { - return 0; - } - - return u.meta.afk ? 0 : 1; - }).reduce(function (a, b) { - return a + b; - }, 0); -}; - -/** - * Creates a voteskip update packet - */ -Channel.prototype.getVoteskipPacket = function () { - var have = this.voteskip ? this.voteskip.counts[0] : 0; - var max = this.calcVoteskipMax(); - var need = this.voteskip ? Math.ceil(max * this.opts.voteskip_ratio) : 0; - return { - count: have, - need: need - }; -}; - -/** - * Sends a voteskip update packet - */ -Channel.prototype.sendVoteskipUpdate = function (users) { - var update = this.getVoteskipPacket(); - users.forEach(function (u) { - if (u.rank >= 1.5) { - u.socket.emit("voteskip", update); - } - }); -}; - -/** - * Sends the inline CSS and JS - */ -Channel.prototype.sendCSSJS = function (users) { - var data = { - css: this.css, - js: this.js - }; - - users.forEach(function (u) { - u.socket.emit("channelCSSJS", data); - }); -}; - -/** - * Sends the MOTD - */ -Channel.prototype.sendMotd = function (users) { - var motd = this.motd; - users.forEach(function (u) { - u.socket.emit("setMotd", motd); - }); -}; - -/** - * Sends the drink count - */ -Channel.prototype.sendDrinks = function (users) { - var drinks = this.drinks; - users.forEach(function (u) { - u.socket.emit("drinkCount", drinks); - }); -}; - -/** - * Resets video-related variables - */ -Channel.prototype.resetVideo = function () { - this.voteskip = false; - this.sendVoteskipUpdate(this.users); - this.drinks = 0; - this.sendDrinks(this.users); -}; - -/** - * Handles a queue message from a client - */ -Channel.prototype.handleQueue = function (user, data) { - // Verify the user has permission to add - if (!this.hasPermission(user, "playlistadd")) { - return; - } - - // Verify data types - if (typeof data.id !== "string" && data.id !== false) { - return; - } - var id = data.id || false; - - if (typeof data.type !== "string") { - return; - } - var type = data.type; - var link = util.formatLink(id, type); - - // Verify user has the permission to add at the position given - if (data.pos === "next" && !this.hasPermission(user, "playlistnext")) { - return; - } - var pos = data.pos || "end"; - - // Verify user has permission to add a YouTube playlist, if relevant - if (data.type === "yp" && !this.hasPermission(user, "playlistaddlist")) { - user.socket.emit("queueFail", { - msg: "You don't have permission to add playlists", - link: link - }); - return; - } - - // Verify the user has permission to add livestreams, if relevant - if (util.isLive(type) && !this.hasPermission(user, "playlistaddlive")) { - user.socket.emit("queueFail", { - msg: "You don't have permission to add livestreams", - link: link - }); - return; - } - - // Verify the user has permission to add a Custom Embed, if relevant - if (data.type === "cu" && !this.hasPermission(user, "playlistaddcustom")) { - user.socket.emit("queueFail", { - msg: "You don't have permission to add custom embeds", - link: null - }); - return; - } - - /** - * Always reset any user-provided title if it's not a custom embed. - * Additionally reset if it is a custom embed but a title is not provided - */ - if (typeof data.title !== "string" || data.type !== "cu") { - data.title = false; - } - var title = data.title || false; - - var queueby = user != null ? user.name : ""; - var temp = data.temp || !this.hasPermission(user, "addnontemp"); - - // Throttle video adds - var limit = { - burst: 3, - sustained: 1 - }; - - if (user.rank >= 2 || this.leader === user) { - limit = { - burst: 10, - sustained: 2 - }; - } - - if (user.queueLimiter.throttle(limit)) { - user.socket.emit("queueFail", { - msg: "You are adding videos too quickly", - link: null - }); - return; - } - - // Actually add the video - this.addMedia({ - id: id, - title: title, - pos: pos, - queueby: queueby, - temp: temp, - type: type, - maxlength: this.hasPermission(user, "exceedmaxlength") ? 0 : this.opts.maxlength - }, function (err, media) { - if (err) { - user.socket.emit("queueFail", { - msg: err, - link: link - }); - return; - } - - if (media.restricted) { - user.socket.emit("queueWarn", { - msg: "This video is blocked in the following countries: " + - media.restricted, - link: link - }); - return; - } - }); -}; - -/** - * Add a video to the playlist - */ -Channel.prototype.addMedia = function (data, callback) { - var self = this; - - if (data.type === "cu" && typeof data.title === "string") { - var t = data.title; - if (t.length > 100) { - t = t.substring(0, 97) + "..."; - } - data.title = t; - } - - if (data.pos === "end") { - data.pos = "append"; - } - - var afterLookup = function (lock, shouldCache, media) { - if (data.maxlength && media.seconds > data.maxlength) { - callback("Maximum length exceeded: " + data.maxlength + " seconds", null); - lock.release(); - return; - } - - media.pos = data.pos; - media.queueby = data.queueby; - media.temp = data.temp; - if (data.title && media.type === "cu") { - media.title = data.title; - } - - var res = self.playlist.addMedia(media); - if (res.error) { - callback(res.error, null); - lock.release(); - return; - } - - self.logger.log("### " + data.queueby + " queued " + media.title); - - var item = res.item; - var packet = { - item: item.pack(), - after: item.prev ? item.prev.uid : "prepend" - }; - self.users.forEach(function (u) { - u.socket.emit("queue", packet); - }); - - self.updatePlaylistMeta(); - self.sendPlaylistMeta(self.users); - - if (shouldCache) { - self.cacheMedia(media); - } - - lock.release(); - callback(null, media); - }; - - // Cached video data - if (data.type !== "cu" && typeof data.title === "string") { - self.plqueue.queue(function (lock) { - var m = new Media(data.id, data.title, data.seconds, data.type); - afterLookup(lock, false, m); - }); - return; - } - - // YouTube playlists - if (data.type === "yp") { - self.plqueue.queue(function (lock) { - InfoGetter.getMedia(data.id, data.type, function (e, vids) { - if (e) { - callback(e, null); - lock.release(); - return; - } - - // If queueing next, reverse queue order so the videos end up - // in the correct order - if (data.pos === "next") { - vids.reverse(); - // Special case to ensure correct playlist order - if (self.playlist.length === 0) { - vids.unshift(vids.pop()); - } - } - - // We only want to release the lock after the entire playlist - // is processed. Set up a dummy so the same code will work. - var dummy = { - release: function () { } - }; - - for (var i = 0; i < vids.length; i++) { - afterLookup(dummy, true, vids[i]); - } - - lock.release(); - }); - }); - return; - } - - // Cases where there is no cached data in the database - if (!self.registered || util.isLive(data.type)) { - self.plqueue.queue(function (lock) { - InfoGetter.getMedia(data.id, data.type, function (e, media) { - if (e) { - callback(e, null); - lock.release(); - return; - } - - afterLookup(lock, false, media); - }); - }); - return; - } - - // Finally, the "normal" case - self.plqueue.queue(function (lock) { - if (self.dead) { - return; - } - - var lookupNewMedia = function () { - InfoGetter.getMedia(data.id, data.type, function (e, media) { - if (self.dead) { - return; - } - - if (e) { - callback(e, null); - lock.release(); - return; - } - - afterLookup(lock, true, media); - }); - }; - - db.channels.getLibraryItem(self.name, data.id, function (err, item) { - if (self.dead) { - return; - } - - if (err && err !== "Item not in library") { - user.socket.emit("queueFail", { - msg: "Internal error: " + err, - link: util.formatLink(data.id, data.type) - }); - lock.release(); - return; - } - - if (item !== null) { - afterLookup(lock, true, item); - } else { - lookupNewMedia(); - } - }); - }); -}; - -/** - * Handles a user queueing a user playlist - */ -Channel.prototype.handleQueuePlaylist = function (user, data) { - var self = this; - if (!self.hasPermission(user, "playlistaddlist")) { - return; - } - - if (typeof data.name !== "string") { - return; - } - var name = data.name; - - if (data.pos === "next" && !self.hasPermission(user, "playlistaddnext")) { - return; - } - var pos = data.pos || "end"; - - var temp = data.temp || !self.hasPermission(user, "addnontemp"); - - db.getUserPlaylist(user.name, name, function (err, pl) { - if (self.dead) { - return; - } - - if (err) { - user.socket.emit("errorMsg", { - msg: "Playlist load failed: " + err - }); - return; - } - - try { - // Ensure correct order when queueing next - if (pos === "next") { - pl.reverse(); - if (pl.length > 0 && self.playlist.items.length === 0) { - pl.unshift(pl.pop()); - } - } - - for (var i = 0; i < pl.length; i++) { - pl[i].pos = pos; - pl[i].temp = temp; - self.addMedia(pl[i], function (err, media) { - if (err) { - user.socket.emit("queueFail", { - msg: err, - link: util.formatLink(pl[i].id, pl[i].type) - }); - } - }); - } - } catch (e) { - Logger.errlog.log("Loading user playlist failed!"); - Logger.errlog.log("PL: " + user.name + "-" + name); - Logger.errlog.log(e.stack); - user.socket.emit("queueFail", { - msg: "Internal error occurred when loading playlist. The administrator has been notified.", - link: null - }); - } - }); -}; - -/** - * Handles a user message to delete a playlist item - */ -Channel.prototype.handleDelete = function (user, data) { - var self = this; - - if (!self.hasPermission(user, "playlistdelete")) { - return; - } - - if (typeof data !== "number") { - return; - } - - var plitem = self.playlist.items.find(data); - - self.deleteMedia(data, function (err) { - if (!err && plitem && plitem.media) { - self.logger.log("### " + user.name + " deleted " + plitem.media.title); - } - }); -}; - -/** - * Deletes a playlist item - */ -Channel.prototype.deleteMedia = function (uid, callback) { - var self = this; - self.plqueue.queue(function (lock) { - if (self.dead) { - return; - } - - if (self.playlist.remove(uid)) { - self.sendAll("delete", { - uid: uid - }); - self.updatePlaylistMeta(); - self.sendPlaylistMeta(self.users); - callback(null); - } else { - callback("Delete failed"); - } - - lock.release(); - }); -}; - -/** - * Sets the temporary status of a playlist item - */ -Channel.prototype.setTemp = function (uid, temp) { - var item = this.playlist.items.find(uid); - if (item == null) { - return; - } - - item.temp = temp; - this.sendAll("setTemp", { - uid: uid, - temp: temp - }); - - // TODO might change the way this works - if (!temp) { - this.cacheMedia(item.media); - } -}; - -/** - * Handles a user message to set a playlist item as temporary/not - */ -Channel.prototype.handleSetTemp = function (user, data) { - if (!this.hasPermission(user, "settemp")) { - return; - } - - if (typeof data.uid !== "number" || typeof data.temp !== "boolean") { - return; - } - - this.setTemp(data.uid, data.temp); - // TODO log? -}; - -/** - * Moves a playlist item in the playlist - */ -Channel.prototype.move = function (from, after, callback) { - callback = typeof callback === "function" ? callback : function () { }; - var self = this; - - if (from === after) { - callback("Cannot move playlist item after itself!", null); - return; - } - - self.plqueue.queue(function (lock) { - if (self.dead) { - return; - } - - if (self.playlist.move(from, after)) { - self.sendAll("moveVideo", { - from: from, - after: after - }); - callback(null, true); - } else { - callback(true, null); - } - - lock.release(); - }); -}; - -/** - * Handles a user message to move a playlist item - */ -Channel.prototype.handleMove = function (user, data) { - var self = this; - - if (!self.hasPermission(user, "playlistmove")) { - return; - } - - if (typeof data.from !== "number" || (typeof data.after !== "number" && typeof data.after !== "string")) { - return; - } - - self.move(data.from, data.after, function (err) { - if (!err) { - var fromit = self.playlist.items.find(data.from); - var afterit = self.playlist.items.find(data.after); - var aftertitle = (afterit && afterit.media) ? afterit.media.title : ""; - if (fromit) { - self.logger.log("### " + user.name + " moved " + fromit.media.title + - (aftertitle ? " after " + aftertitle : "")); - } - } - }); -}; - -/** - * Handles a user message to remove a video from the library - */ -Channel.prototype.handleUncache = function (user, data) { - var self = this; - if (!self.registered) { - return; - } - - if (user.rank < 2) { - return; - } - - if (typeof data.id !== "string") { - return; - } - - db.channels.deleteFromLibrary(self.name, data.id, function (err, res) { - if (self.dead) { - return; - } - - if (err) { - return; - } - - self.logger.log("*** " + user.name + " deleted " + data.id + " from library"); - }); -}; - -/** - * Handles a user message to skip to the next video in the playlist - */ -Channel.prototype.handlePlayNext = function (user) { - if (!this.hasPermission(user, "playlistjump")) { - return; - } - - this.logger.log("### " + user.name + " skipped the video"); - this.playlist.next(); -}; - -/** - * Handles a user message to jump to a video in the playlist - */ -Channel.prototype.handleJumpTo = function (user, data) { - if (!this.hasPermission(user, "playlistjump")) { - return; - } - - if (typeof data !== "string" && typeof data !== "number") { - return; - } - - this.logger.log("### " + user.name + " skipped the video"); - this.playlist.jump(data); -}; - -/** - * Clears the playlist - */ -Channel.prototype.clear = function () { - this.playlist.clear(); - this.plqueue.reset(); - this.updatePlaylistMeta(); - this.sendPlaylist(this.users); -}; - -/** - * Handles a user message to clear the playlist - */ -Channel.prototype.handleClear = function (user) { - if (!this.hasPermission(user, "playlistclear")) { - return; - } - - this.logger.log("### " + user.name + " cleared the playlist"); - this.clear(); -}; - -/** - * Shuffles the playlist - */ -Channel.prototype.shuffle = function () { - var pl = this.playlist.items.toArray(false); - this.playlist.clear(); - this.plqueue.reset(); - while (pl.length > 0) { - var i = Math.floor(Math.random() * pl.length); - var item = this.playlist.makeItem(pl[i].media); - item.temp = pl[i].temp; - item.queueby = pl[i].queueby; - this.playlist.items.append(item); - pl.splice(i, 1); - } - - this.playlist.current = this.playlist.items.first; - this.sendPlaylist(this.users); - this.playlist.startPlayback(); -}; - -/** - * Handles a user message to shuffle the playlist - */ -Channel.prototype.handleShuffle = function (user) { - if (!this.hasPermission(user, "playlistshuffle")) { - return; - } - - this.logger.log("### " + user.name + " shuffle the playlist"); - this.shuffle(); -}; - -/** - * Handles a video update from a leader - */ -Channel.prototype.handleUpdate = function (user, data) { - if (this.leader !== user) { - user.kick("Received mediaUpdate from non-leader"); - return; - } - - if (typeof data.id !== "string" || typeof data.currentTime !== "number") { - return; - } - - if (this.playlist.current === null) { - return; - } - - var media = this.playlist.current.media; - - if (util.isLive(media.type) && media.type !== "jw") { - return; - } - - if (media.id !== data.id || isNaN(data.currentTime)) { - return; - } - - media.currentTime = data.currentTime; - media.paused = Boolean(data.paused); - this.sendAll("mediaUpdate", media.timeupdate()); -}; - -/** - * Handles a user message to open a poll - */ -Channel.prototype.handleOpenPoll = function (user, data) { - if (!this.hasPermission(user, "pollctl")) { - return; - } - - if (typeof data.title !== "string" || !(data.opts instanceof Array)) { - return; - } - var title = data.title.substring(0, 255); - var opts = []; - - for (var i = 0; i < data.opts.length; i++) { - opts[i] = (""+data.opts[i]).substring(0, 255); - } - - var obscured = (data.obscured === true); - var poll = new Poll(user.name, title, opts, obscured); - this.poll = poll; - this.sendPoll(this.users, true); - this.logger.log("*** " + user.name + " Opened Poll: '" + poll.title + "'"); -}; - -/** - * Handles a user message to close the active poll - */ -Channel.prototype.handleClosePoll = function (user) { - if (!this.hasPermission(user, "pollctl")) { - return; - } - - if (this.poll) { - if (this.poll.obscured) { - this.poll.obscured = false; - this.sendPollUpdate(this.users); - } - - this.logger.log("*** " + user.name + " closed the active poll"); - this.poll = false; - this.sendAll("closePoll"); - } -}; - -/** - * Handles a user message to vote in a poll - */ -Channel.prototype.handlePollVote = function (user, data) { - if (!this.hasPermission(user, "pollvote")) { - return; - } - - if (typeof data.option !== "number") { - return; - } - - if (this.poll) { - this.poll.vote(user.ip, data.option); - this.sendPollUpdate(this.users); - } -}; - -/** - * Handles a user message to voteskip the current video - */ -Channel.prototype.handleVoteskip = function (user) { - if (!this.opts.allow_voteskip) { - return; - } - - if (!this.hasPermission(user, "voteskip")) { - return; - } - - user.setAFK(false); - user.autoAFK(); - if (!this.voteskip) { - this.voteskip = new Poll("voteskip", "voteskip", ["yes"]); - } - this.voteskip.vote(user.ip, 0); - this.logger.log("### " + (user.name ? user.name : "anonymous") + " voteskipped"); - this.checkVoteskipPass(); -}; - -/** - * Checks if the voteskip requirement is met - */ -Channel.prototype.checkVoteskipPass = function () { - if (!this.opts.allow_voteskip) { - return false; - } - - if (!this.voteskip) { - return false; - } - - if (this.playlist.length === 0) { - return false; - } - - var max = this.calcVoteskipMax(); - var need = Math.ceil(max * this.opts.voteskip_ratio); - if (this.voteskip.counts[0] >= need) { - this.logger.log("### Voteskip passed, skipping to next video"); - this.playlist.next(); - } - - this.sendVoteskipUpdate(this.users); - return true; -}; - -/** - * Sets the locked state of the playlist - */ -Channel.prototype.setLock = function (locked) { - this.playlistLock = locked; - this.sendPlaylistLock(this.users); -}; - -/** - * Handles a user message to change the locked state of the playlist - */ -Channel.prototype.handleSetLock = function (user, data) { - if (!this.hasPermission(user, "playlistlock")) { - return; - } - - - data.locked = Boolean(data.locked); - this.logger.log("*** " + user.name + " set playlist lock to " + data.locked); - this.setLock(data.locked); -}; - -/** - * Handles a user message to toggle the locked state of the playlist - */ -Channel.prototype.handleToggleLock = function (user) { - this.handleSetLock(user, { locked: !this.playlistLock }); -}; - -/** - * Imports a list of chat filters, replacing the current list - */ -Channel.prototype.importFilters = function (filters) { - this.filters = filters; - this.sendChatFilters(this.users); -}; - -/** - * Handles a user message to import a list of chat filters - */ -Channel.prototype.handleImportFilters = function (user, data) { - if (!this.hasPermission(user, "filterimport")) { - return; - } - - if (!(data instanceof Array)) { - return; - } - - this.filters = data.map(this.validateChatFilter.bind(this)) - .filter(function (f) { return f !== false; }); - - this.sendChatFilters(this.users); -}; - -/** - * Validates data for a chat filter - */ -Channel.prototype.validateChatFilter = function (f) { - if (typeof f.source !== "string" || typeof f.flags !== "string" || - typeof f.replace !== "string") { - return false; - } - - if (typeof f.name !== "string") { - f.name = f.source; - } - - f.replace = f.replace.substring(0, 1000); - f.replace = XSS.sanitizeHTML(f.replace); - f.flags = f.flags.substring(0, 4); - - try { - new RegExp(f.source, f.flags); - } catch (e) { - return false; - } - - var filter = new Filter(f.name, f.source, f.flags, f.replace); - filter.active = Boolean(f.active); - filter.filterlinks = Boolean(f.filterlinks); - return filter; -}; - -/** - * Updates a chat filter, or adds a new one if the filter does not exist - */ -Channel.prototype.updateFilter = function (filter) { - var self = this; - - if (!filter.name) { - filter.name = filter.source; - } - - var found = false; - for (var i = 0; i < self.filters.length; i++) { - if (self.filters[i].name === filter.name) { - found = true; - self.filters[i] = filter; - break; - } - } - - if (!found) { - self.filters.push(filter); - } - - self.users.forEach(function (u) { - if (self.hasPermission(u, "filteredit")) { - u.socket.emit("updateChatFilter", filter); - } - }); -}; - -/** - * Handles a user message to update a filter - */ -Channel.prototype.handleUpdateFilter = function (user, f) { - if (!this.hasPermission(user, "filteredit")) { - user.kick("Attempted updateFilter with insufficient permission"); - return; - } - - filter = this.validateChatFilter(f); - if (!filter) { - return; - } - - this.logger.log("%%% " + user.name + " updated filter: " + f.name); - this.updateFilter(filter); -}; - -/** - * Removes a chat filter - */ -Channel.prototype.removeFilter = function (filter) { - var self = this; - - for (var i = 0; i < self.filters.length; i++) { - if (self.filters[i].name === filter.name) { - self.filters.splice(i, 1); - self.users.forEach(function (u) { - if (self.hasPermission(u, "filteredit")) { - u.socket.emit("deleteChatFilter", filter); - } - }); - break; - } - } -}; - -/** - * Handles a user message to delete a chat filter - */ -Channel.prototype.handleRemoveFilter = function (user, f) { - if (!this.hasPermission(user, "filteredit")) { - user.kick("Attempted removeFilter with insufficient permission"); - return; - } - - if (typeof f.name !== "string") { - return; - } - - this.logger.log("%%% " + user.name + " removed filter: " + f.name); - this.removeFilter(f); -}; - -/** - * Changes the order of chat filters - */ -Channel.prototype.moveFilter = function (from, to) { - if (from < 0 || to < 0 || from >= this.filters.length || to >= this.filters.length) { - return; - } - - var f = this.filters[from]; - to = to > from ? to + 1 : to; - from = to > from ? from : from + 1; - - this.filters.splice(to, 0, f); - this.filters.splice(from, 1); - // TODO broadcast -}; - -/** - * Handles a user message to change the chat filter order - */ -Channel.prototype.handleMoveFilter = function (user, data) { - if (!this.hasPermission(user, "filteredit")) { - user.kick("Attempted moveFilter with insufficient permission"); - return; - } - - if (typeof data.to !== "number" || typeof data.from !== "number") { - return; - } - - this.moveFilter(data.from, data.to); -}; - -/** - * Handles a user message to change the channel permissions - */ -Channel.prototype.handleSetPermissions = function (user, perms) { - if (user.rank < 3) { - user.kick("Attempted setPermissions as a non-admin"); - return; - } - - for (var key in perms) { - if (key in this.permissions) { - this.permissions[key] = perms[key]; - } - } - - this.logger.log("%%% " + user.name + " updated permissions"); - this.sendAll("setPermissions", this.permissions); -}; - -/** - * Handles a user message to change the channel settings - */ -Channel.prototype.handleUpdateOptions = function (user, data) { - if (user.rank < 2) { - user.kick("Attempted setOptions as a non-moderator"); - return; - } - - if ("allow_voteskip" in data) { - this.opts.voteskip = Boolean(data.allow_voteskip); - } - - if ("voteskip_ratio" in data) { - var ratio = parseFloat(data.voteskip_ratio); - if (isNaN(ratio) || ratio < 0) { - ratio = 0; - } - this.opts.voteskip_ratio = ratio; - } - - if ("afk_timeout" in data) { - var tm = parseInt(data.afk_timeout); - if (isNaN(tm) || tm < 0) { - tm = 0; - } - - var same = tm === this.opts.afk_timeout; - this.opts.afk_timeout = tm; - if (!same) { - this.users.forEach(function (u) { - u.autoAFK(); - }); - } - } - - if ("pagetitle" in data && user.rank >= 3) { - this.opts.pagetitle = (""+data.pagetitle).substring(0, 100); - } - - if ("maxlength" in data) { - var ml = parseInt(data.maxlength); - if (isNaN(ml) || ml < 0) { - ml = 0; - } - this.opts.maxlength = ml; - } - - if ("externalcss" in data && user.rank >= 3) { - this.opts.externalcss = (""+data.externalcss).substring(0, 255); - } - - if ("externaljs" in data && user.rank >= 3) { - this.opts.externaljs = (""+data.externaljs).substring(0, 255); - } - - if ("chat_antiflood" in data) { - this.opts.chat_antiflood = Boolean(data.chat_antiflood); - } - - if ("chat_antiflood_params" in data) { - if (typeof data.chat_antiflood_params !== "object") { - data.chat_antiflood_params = { - burst: 4, - sustained: 1 - }; - } - - var b = parseInt(data.chat_antiflood_params.burst); - if (isNaN(b) || b < 0) { - b = 1; - } - - var s = parseInt(data.chat_antiflood_params.sustained); - if (isNaN(s) || s <= 0) { - s = 1; - } - - var c = b / s; - this.opts.chat_antiflood_params = { - burst: b, - sustained: s, - cooldown: c - }; - } - - if ("show_public" in data && user.rank >= 3) { - this.opts.show_public = Boolean(data.show_public); - } - - if ("enable_link_regex" in data) { - this.opts.enable_link_regex = Boolean(data.enable_link_regex); - } - - if ("password" in data && user.rank >= 3) { - var pw = data.password + ""; - pw = pw === "" ? false : pw.substring(0, 100); - this.opts.password = pw; - } - - this.logger.log("%%% " + user.name + " updated channel options"); - this.sendOpts(this.users); -}; - -/** - * Handles a user message to set the inline channel CSS - */ -Channel.prototype.handleSetCSS = function (user, data) { - if (user.rank < 3) { - user.kick("Attempted setChannelCSS as non-admin"); - return; - } - - if (typeof data.css !== "string") { - return; - } - var css = data.css.substring(0, 20000); - - this.css = css; - this.sendCSSJS(this.users); - - this.logger.log("%%% " + user.name + " updated the channel CSS"); -}; - -/** - * Handles a user message to set the inline channel CSS - */ -Channel.prototype.handleSetJS = function (user, data) { - if (user.rank < 3) { - user.kick("Attempted setChannelJS as non-admin"); - return; - } - - if (typeof data.js !== "string") { - return; - } - var js = data.js.substring(0, 20000); - - this.js = js; - this.sendCSSJS(this.users); - - this.logger.log("%%% " + user.name + " updated the channel JS"); -}; - -/** - * Sets the MOTD - */ -Channel.prototype.setMotd = function (motd) { - motd = XSS.sanitizeHTML(motd); - var html = motd.replace(/\n/g, "
"); - this.motd = { - motd: motd, - html: html - }; - this.sendMotd(this.users); -}; - -/** - * Handles a user message to update the MOTD - */ -Channel.prototype.handleSetMotd = function (user, data) { - if (!this.hasPermission(user, "motdedit")) { - user.kick("Attempted setMotd with insufficient permission"); - return; - } - - if (typeof data.motd !== "string") { - return; - } - var motd = data.motd.substring(0, 20000); - - this.setMotd(motd); - this.logger.log("%%% " + user.name + " updated the MOTD"); -}; - -/** - * Handles a user chat message - */ -Channel.prototype.handleChat = function (user, data) { - if (!this.hasPermission(user, "chat")) { - return; - } - - if (typeof data.meta !== "object") { - data.meta = {}; - } - - if (!user.name) { - return; - } - - if (typeof data.msg !== "string") { - return; - } - var msg = data.msg.substring(0, 240); - - var muted = this.isMuted(user.name); - var smuted = this.isShadowMuted(user.name); - - var meta = {}; - if (user.rank >= 2) { - if ("modflair" in data.meta && data.meta.modflair === user.rank) { - meta.modflair = data.meta.modflair; - } - } - - if (user.rank < 2 && this.opts.chat_antiflood && - user.chatLimiter.throttle(this.opts.chat_antiflood_params)) { - user.socket.emit("chatCooldown", 1000 / this.opts.chat_antiflood_params.sustained); - } - - if (smuted) { - msg = XSS.sanitizeText(msg); - msg = this.filterMessage(msg); - var msgobj = { - username: user.name, - msg: msg, - meta: meta, - time: Date.now() - }; - this.shadowMutedUsers().forEach(function (u) { - u.socket.emit("chatMsg", msgobj); - }); - return; - } - - if (msg.indexOf("/") === 0) { - if (!ChatCommand.handle(this, user, msg, meta)) { - this.sendMessage(user, msg, meta); - } - } else { - if (msg.indexOf(">") === 0) { - meta.addClass = "greentext"; - } - this.sendMessage(user, msg, meta); - } -}; - -/** - * Filters a chat message - */ -Channel.prototype.filterMessage = function (msg) { - const link = /(\w+:\/\/(?:[^:\/\[\]\s]+|\[[0-9a-f:]+\])(?::\d+)?(?:\/[^\/\s]*)*)/ig; - var parts = msg.split(link); - - for (var j = 0; j < parts.length; j++) { - // Case 1: The substring is a URL - if (this.opts.enable_link_regex && parts[j].match(link)) { - var original = parts[j]; - // Apply chat filters that are active and filter links - for (var i = 0; i < this.filters.length; i++) { - if (!this.filters[i].filterlinks || !this.filters[i].active) { - continue; - } - parts[j] = this.filters[i].filter(parts[j]); - } - - // Unchanged, apply link filter - if (parts[j] === original) { - parts[j] = url.format(url.parse(parts[j])); - parts[j] = parts[j].replace(link, "$1"); - } - - continue; - } else { - // Substring is not a URL - for (var i = 0; i < this.filters.length; i++) { - if (!this.filters[i].active) { - continue; - } - - parts[j] = this.filters[i].filter(parts[j]); - } - } - } - - // Recombine the message - return parts.join(""); -}; - -/** - * Sends a chat message - */ -Channel.prototype.sendMessage = function (user, msg, meta) { - msg = XSS.sanitizeText(msg); - msg = this.filterMessage(msg); - var msgobj = { - username: user.name, - msg: msg, - meta: meta, - time: Date.now() - }; - - this.sendAll("chatMsg", msgobj); - this.chatbuffer.push(msgobj); - if (this.chatbuffer.length > 15) { - this.chatbuffer.shift(); - } - - this.logger.log("<" + user.name + (meta.addClass ? "." + meta.addClass : "") + "> " + - XSS.decodeText(msg)); -}; - -/** - * Handles a user message to change another user's rank - */ -Channel.prototype.handleSetRank = function (user, data) { - var self = this; - if (user.rank < 2) { - user.kick("Attempted setChannelRank as a non-moderator"); - return; - } - - if (typeof data.user !== "string" || typeof data.rank !== "number") { - return; - } - var name = data.user.substring(0, 20); - var rank = data.rank; - - if (isNaN(rank) || rank < 1 || rank >= user.rank) { - return; - } - - var receiver; - var lowerName = name.toLowerCase(); - for (var i = 0; i < self.users.length; i++) { - if (self.users[i].name.toLowerCase() === lowerName) { - receiver = self.users[i]; - break; - } - } - - var updateDB = function () { - self.getRank(name, function (err, oldrank) { - if (self.dead) { - return; - } - - if (err) { - user.socket.emit("channelRankFail", { - msg: "Updating user rank failed: " + err - }); - return; - } - - if (oldrank >= user.rank) { - user.socket.emit("channelRankFail", { - msg: "Updating user rank failed: " + name + " has equal or higher " + - "rank than you" - }); - return; - } - - db.channels.setRank(self.name, name, rank, function (err, res) { - if (self.dead) { - return; - } - - if (err) { - user.socket.emit("channelRankFail", { - msg: "Updating user rank failed: " + err - }); - return; - } - - self.logger.log("*** " + user.name + " set " + name + "'s rank to " + rank); - self.sendAll("setUserRank", { - name: name, - rank: rank - }); - }); - }); - }; - - if (receiver) { - if (Math.max(receiver.rank, receiver.global_rank) > user.rank) { - return; - } - - if (receiver.loggedIn) { - updateDB(); - } - } else if (self.registered) { - updateDB(); - } -}; - -/** - * Assigns a leader for video playback - */ -Channel.prototype.changeLeader = function (name) { - if (this.leader != null) { - var old = this.leader; - this.leader = null; - if (old.rank === 1.5) { - old.rank = old.oldrank; - old.socket.emit("rank", old.rank); - this.sendAll("setUserRank", { - name: old.name, - rank: old.rank - }); - } - } - - if (!name) { - this.sendAll("setLeader", ""); - this.logger.log("*** Resuming autolead"); - this.playlist.lead(true); - return; - } - - for (var i = 0; i < this.users.length; i++) { - if (this.users[i].name === name) { - this.sendAll("setLeader", name); - this.logger.log("*** Assigned leader: " + name); - this.playlist.lead(false); - this.leader = this.users[i]; - if (this.users[i].rank < 1.5) { - this.users[i].oldrank = this.users[i].rank; - this.users[i].rank = 1.5; - this.users[i].socket.emit("rank", 1.5); - this.sendAll("setUserRank", { - name: name, - rank: this.users[i].rank - }); - } - break; - } - } -}; - -/** - * Handles a user message to assign a new leader - */ -Channel.prototype.handleChangeLeader = function (user, data) { - if (!this.hasPermission(user, "leaderctl")) { - user.kick("Attempted assignLeader with insufficient permission"); - return; - } - - if (typeof data.name !== "string") { - return; - } - - this.changeLeader(data.name); - this.logger.log("### " + user.name + " assigned leader to " + data.name); -}; - -/** - * Searches channel library - */ -Channel.prototype.search = function (query, callback) { - var self = this; - if (!self.registered) { - callback([]); - return; - } - - if (typeof query !== "string") { - query = ""; - } - - query = query.substring(0, 100); - - db.channels.searchLibrary(self.name, query, function (err, res) { - if (err) { - res = []; - } - - res.sort(function(a, b) { - var x = a.title.toLowerCase(); - var y = b.title.toLowerCase(); - - return (x == y) ? 0 : (x < y ? -1 : 1); - }); - - res.forEach(function (r) { - r.duration = util.formatTime(r.seconds); - }); - - callback(res); - }); -}; - -/** - * Sends the result of readLog() to a user if the user has sufficient permission - */ -Channel.prototype.handleReadLog = function (user) { - var self = this; - - if (user.rank < 3) { - user.kick("Attempted readChanLog with insufficient permission"); - return; - } - - if (!self.registered) { - user.socket.emit("readChanLog", { - success: false, - data: "Channel log is only available to registered channels." - }); - return; - } - - var filterIp = user.global_rank < 255; - self.readLog(filterIp, function (err, data) { - if (err) { - user.socket.emit("readChanLog", { - success: false, - data: "Reading channel log failed." - }); - } else { - user.socket.emit("readChanLog", { - success: true, - data: data - }); - } - }); -}; - -/** - * Reads the last 100KiB of the channel's log file, masking IP addresses if desired - */ -Channel.prototype.readLog = function (filterIp, callback) { - var maxLen = 102400; // Limit to last 100KiB - var file = this.logger.filename; - - fs.stat(file, function (err, data) { - if (err) { - callback(err, null); - return; - } - - var start = Math.max(data.size - maxLen, 0); - var end = data.size - 1; - - var rs = fs.createReadStream(file, { - start: start, - end: end - }); - - var buffer = ""; - rs.on("data", function (data) { - buffer += data; - }); - - rs.on("end", function () { - if (filterIp) { - buffer = buffer.replace( - /\d+\.\d+\.(\d+\.\d+)/g, - "x.x.$1" - ).replace( - /\d+\.\d+\.(\d+)/g, - "x.x.$.*" - ); - } - - callback(null, buffer); - }); - }); -}; - -/** - * Broadcasts a message to the entire channel - */ -Channel.prototype.sendAll = function (msg, data) { - this.users.forEach(function (u) { - u.socket.emit(msg, data); - }); -}; - -module.exports = Channel; diff --git a/lib/channel.js b/lib/channel.js index 16d2e0de..77788b2c 100644 --- a/lib/channel.js +++ b/lib/channel.js @@ -1,533 +1,418 @@ - -/* -The MIT License (MIT) -Copyright (c) 2013 Calvin Montgomery - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -*/ +var util = require("./utilities"); +var db = require("./database"); +var Playlist = require("./playlist"); +var Poll = require("./poll").Poll; +var Filter = require("./filter").Filter; +var Logger = require("./logger"); +var AsyncQueue = require("./asyncqueue"); +var MakeEmitter = require("./emitter"); +var InfoGetter = require("./get-info"); +var ChatCommand = require("./chatcommand"); +var XSS = require("./xss"); var fs = require("fs"); var path = require("path"); var url = require("url"); -var Server = require("./server"); -var Poll = require("./poll.js").Poll; -var Media = require("./media.js").Media; -var Logger = require("./logger.js"); -var ChatCommand = require("./chatcommand.js"); -var Filter = require("./filter.js").Filter; -var Playlist = require("./playlist"); -var sanitize = require("validator").sanitize; -var $util = require("./utilities"); -var AsyncQueue = require("./asyncqueue"); -var ActionLog = require("./actionlog"); -var InfoGetter = require("./get-info"); -var db = require("./database"); -var Channel = function(name) { - var self = this; - Logger.syslog.log("Opening channel " + name); - self.shouldDump = false; +var DEFAULT_FILTERS = [ + new Filter("monospace", "`(.+?)`", "g", "$1"), + new Filter("bold", "\\*(.+?)\\*", "g", "$1"), + new Filter("italic", "_(.+?)_", "g", "$1"), + new Filter("strike", "~~(.+?)~~", "g", "$1"), + new Filter("inline spoiler", "\\[sp\\](.*?)\\[\\/sp\\]", "ig", "$1") +]; + +function Channel(name) { + MakeEmitter(this); + var self = this; // Alias `this` to prevent scoping issues + Logger.syslog.log("Loading channel " + name); + + // Defaults self.ready = false; - self.pendingJoins = []; - self.dbloaded = false; - self.server = Server.getServer(); - self.name = name; - self.canonical_name = name.toLowerCase(); - // Initialize defaults - self.registered = false; + self.uniqueName = name.toLowerCase(); // To prevent casing issues + self.registered = false; // set to true if the channel exists in the database self.users = []; - self.mutedUsers = new $util.Set(); + self.mutedUsers = new util.Set(); self.playlist = new Playlist(self); - self.plqueue = new AsyncQueue(); - self.position = -1; + self.plmeta = { count: 0, time: "00:00:00" }; + self.plqueue = new AsyncQueue(); // For synchronizing playlist actions self.drinks = 0; self.leader = null; self.chatbuffer = []; - self.openqueue = false; - self.poll = false; - self.voteskip = false; + self.playlistLock = true; + self.poll = null; + self.voteskip = null; self.permissions = { - oplaylistadd: -1, + playlistadd: 1.5, // Add video to the playlist + playlistnext: 1.5, + playlistmove: 1.5, // Move a video on the playlist + playlistdelete: 2, // Delete a video from the playlist + playlistjump: 1.5, // Start a different video on the playlist + playlistaddlist: 1.5, // Add a list of videos to the playlist + oplaylistadd: -1, // Same as above, but for open (unlocked) playlist oplaylistnext: 1.5, oplaylistmove: 1.5, oplaylistdelete: 2, oplaylistjump: 1.5, oplaylistaddlist: 1.5, - playlistadd: 1.5, - playlistnext: 1.5, - playlistmove: 1.5, - playlistdelete: 2, - playlistjump: 1.5, - playlistaddlist: 1.5, - playlistaddcustom: 3, - playlistaddlive: 1.5, - exceedmaxlength: 2, - addnontemp: 2, - settemp: 2, - playlistgeturl: 1.5, - playlistshuffle: 2, - playlistclear: 2, - pollctl: 1.5, - pollvote: -1, - viewhiddenpoll: 1.5, - voteskip: -1, - mute: 1.5, - kick: 1.5, - ban: 2, - motdedit: 3, - filteredit: 3, - drink: 1.5, - chat: 0 + playlistaddcustom: 3, // Add custom embed to the playlist + playlistaddlive: 1.5, // Add a livestream to the playlist + exceedmaxlength: 2, // Add a video longer than the maximum length set + addnontemp: 2, // Add a permanent video to the playlist + settemp: 2, // Toggle temporary status of a playlist item + playlistgeturl: 1.5, // TODO is this even used? + playlistshuffle: 2, // Shuffle the playlist + playlistclear: 2, // Clear the playlist + pollctl: 1.5, // Open/close polls + pollvote: -1, // Vote in polls + viewhiddenpoll: 1.5, // View results of hidden polls + voteskip: -1, // Vote to skip the current video + mute: 1.5, // Mute other users + kick: 1.5, // Kick other users + ban: 2, // Ban other users + motdedit: 3, // Edit the MOTD + filteredit: 3, // Control chat filters + filterimport: 3, // Import chat filter list + playlistlock: 2, // Lock/unlock the playlist + leaderctl: 2, // Give/take leader + drink: 1.5, // Use the /d command + chat: 0 // Send chat messages }; self.opts = { - allow_voteskip: true, - voteskip_ratio: 0.5, - afk_timeout: 600, - pagetitle: self.name, - maxlength: 0, - externalcss: "", - externaljs: "", - chat_antiflood: false, + allow_voteskip: true, // Allow users to voteskip + voteskip_ratio: 0.5, // Ratio of skip votes:non-afk users needed to skip the video + afk_timeout: 600, // Number of seconds before a user is automatically marked afk + pagetitle: self.name, // Title of the browser tab + maxlength: 0, // Maximum length (in seconds) of a video queued + externalcss: "", // Link to external stylesheet + externaljs: "", // Link to external script + chat_antiflood: false, // Throttle chat messages chat_antiflood_params: { - burst: 4, - sustained: 1, - cooldown: 4 + burst: 4, // Number of messages to allow with no throttling + sustained: 1, // Throttle rate (messages/second) + cooldown: 4 // Number of seconds with no messages before burst is reset }, - show_public: false, - enable_link_regex: true, - password: false + show_public: false, // List the channel on the index page + enable_link_regex: true, // Use the built-in link filter + password: false // Channel password (false -> no password required for entry) }; - self.filters = [ - new Filter("monospace", "`([^`]+)`", "g", "$1"), - new Filter("bold", "(^|\\s)\\*([^\\*]+)\\*", "g", "$1$2"), - new Filter("italic", "(^| )_([^_]+)_", "g", "$1$2"), - new Filter("strikethrough", "~~([^~]+)~~", "g", "$1"), - new Filter("inline spoiler", "\\[spoiler\\](.*)\\[\\/spoiler\\]", "ig", "$1"), - ]; self.motd = { - motd: "", - html: "" + motd: "", // Raw MOTD text + html: "" // Filtered MOTD text (XSS removed; \n replaced by
) }; - self.ipbans = {}; - self.namebans = {}; - self.ip_alias = {}; - self.name_alias = {}; - self.login_hist = []; + self.filters = DEFAULT_FILTERS; self.logger = new Logger.Logger(path.join(__dirname, "../chanlogs", - self.canonical_name + ".log")); - self.i = 0; - self.time = new Date().getTime(); - self.plmeta = { - count: 0, - time: "00:00" - }; + self.uniqueName + ".log")); + self.css = ""; // Up to 20KB of inline CSS + self.js = ""; // Up to 20KB of inline Javascript - self.css = ""; - self.js = ""; + self.error = false; // Set to true if something bad happens => don't save state - self.ipkey = ""; - for(var i = 0; i < 15; i++) { - self.ipkey += "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"[parseInt(Math.random() * 65)] - } - - db.channels.load(self, function (err) { - if (err && err !== "Channel is not registered") - return; - else - self.dbloaded = true; - - self.tryLoadDump(); + self.on("ready", function () { + self.ready = true; }); -} -/* REGION Permissions */ -Channel.prototype.hasPermission = function(user, key) { - if(key.indexOf("playlist") == 0 && this.openqueue) { - var key2 = "o" + key; - var v = this.permissions[key2]; - if(typeof v == "number" && user.rank >= v) { - return true; + // Load from database + db.channels.load(self, function (err) { + if (err && err !== "Channel is not registered") { + return; + } else { + // Load state from JSON blob + self.tryLoadState(); } - } - var v = this.permissions[key]; - if(typeof v != "number") { - return false; - } - return user.rank >= v; -} + }); +}; -/* REGION Channel data */ +Channel.prototype.isMuted = function (name) { + return this.mutedUsers.contains(name.toLowerCase()) || + this.mutedUsers.contains("[shadow]" + name.toLowerCase()); +}; -Channel.prototype.tryLoadDump = function () { +Channel.prototype.isShadowMuted = function (name) { + return this.mutedUsers.contains("[shadow]" + name.toLowerCase()); +}; + +Channel.prototype.mutedUsers = function () { + var self = this; + return self.users.filter(function (u) { + return self.mutedUsers.contains(u.name); + }); +}; + +Channel.prototype.shadowMutedUsers = function () { + var self = this; + return self.users.filter(function (u) { + return self.mutedUsers.contains("[shadow]" + u.name); + }); +}; + +Channel.prototype.channelModerators = function () { + return this.users.filter(function (u) { + return u.rank >= 2; + }); +}; + +Channel.prototype.channelAdmins = function () { + return this.users.filter(function (u) { + return u.rank >= 3; + }); +}; + +Channel.prototype.tryLoadState = function () { var self = this; - // Not sure how this would ever happen, but it was there before if (self.name === "") { return; } + // Don't load state if the channel isn't registered if (!self.registered) { - self.ready = true; - self.handlePendingJoins(); + self.emit("ready"); return; } - fs.stat(path.join(__dirname, "../chandump", self.name), - function (err, stats) { - if (self.dead) - return; - + var file = path.join(__dirname, "../chandump", self.uniqueName); + fs.stat(file, function (err, stats) { if (!err) { var mb = stats.size / 1048576; - mb = parseInt(mb * 100) / 100; + mb = Math.floor(mb * 100) / 100; if (mb > 1) { - Logger.errlog.log("Large chandump detected: " + self.name + - " (" + mb + " MB)"); - self.updateMotd("Your channel file has exceeded the " + - "maximum size of 1MB and cannot be " + - "loaded. Please ask an administrator " + - "for assistance in restoring it."); + Logger.errlog.log("Large chandump detected: " + self.uniqueName + + " (" + mb + " MiB)"); + self.setMOTD("Your channel file has exceeded the maximum size of 1MB " + + "and cannot be loaded. Please ask an administrator for " + + "assistance in restoring it."); + self.error = true; + self.emit("ready"); return; } } - self.loadDump(); + self.loadState(); }); }; -Channel.prototype.loadDump = function() { + +/** + * Load the channel state from disk. + * + * SHOULD ONLY BE CALLED FROM tryLoadState + */ +Channel.prototype.loadState = function () { var self = this; - if (self.dead || !self.name) { + if (self.error) { return; } - fs.readFile(path.join(__dirname, "../chandump", self.name), - function(err, data) { - if (self.dead) - return; + fs.readFile(path.join(__dirname, "../chandump", self.uniqueName), + function (err, data) { if (err) { - if (err.code == "ENOENT") { - self.shouldDump = true; - self.ready = true; - self.handlePendingJoins(); - self.saveDump(); + // File didn't exist => start fresh + if (err.code === "ENOENT") { + self.emit("ready"); + self.saveState(); } else { - Logger.errlog.log("Failed to open channel dump " + self.name); + Logger.errlog.log("Failed to open channel dump " + self.uniqueName); Logger.errlog.log(err); - self.ready = true; - self.handlePendingJoins(); + self.setMOTD("Channel state load failed. Contact an administrator."); + self.error = true; + self.emit("ready"); } return; } try { - self.logger.log("*** Loading channel dump from disk"); + self.logger.log("*** Loading channel state"); data = JSON.parse(data); - /* Load the playlist */ + // Load the playlist if ("playlist" in data) { - // TODO change callback to return value - self.playlist.load(data.playlist, function() { - if (self.dead) - return; - self.sendAll("playlist", self.playlist.items.toArray()); - self.broadcastPlaylistMeta(); + self.playlist.load(data.playlist, function () { + self.sendPlaylist(self.users); + self.updatePlaylistMeta(); + self.sendPlaylistMeta(self.users); self.playlist.startPlayback(data.playlist.time); }); } - /* Load playlist lock */ - self.setLock(!(data.openqueue || false)); + // Playlist lock + self.setLock(data.playlistLock || false); - /* Load configurable options */ - if ("customcss" in data.opts) { - data.opts.css = data.opts.customcss; - } - if ("customjs" in data.opts) { - data.opts.js = data.opts.customjs; - } - for (var key in data.opts) { - self.opts[key] = data.opts[key]; - } - - /* Load permissions */ - for (var key in data.permissions) { - self.permissions[key] = data.permissions[key]; - } - - /* Load chat filters */ - // TODO change the way default filters work? - for (var i = 0; i < data.filters.length; i++) { - var f = data.filters[i]; - // Old filter system used to use arrays - if (f instanceof Array) { - continue; + // Configurables + if ("opts" in data) { + for (var key in data.opts) { + self.opts[key] = data.opts[key]; } - var filt = new Filter(f.name, f.source, f.flags, f.replace); - filt.active = f.active; - filt.filterlinks = f.filterlinks; - self.updateFilter(filt, false); } - /* Load MOTD */ - if (data.motd) { - self.motd = data.motd; + // Permissions + if ("permissions" in data) { + for (var key in data.permissions) { + self.permissions[key] = data.permissions[key]; + } } - /* Load chat history */ - self.chatbuffer = data.chatbuffer || []; + // Chat filters + if ("filters" in data) { + for (var i = 0; i < data.filters.length; i++) { + var f = data.filters[i]; + var filt = new Filter(f.name, f.source, f.flags, f.replace); + filt.active = f.active; + filt.filterlinks = f.filterlinks; + self.updateFilter(filt, false); + } + } - /* Load CSS, JS */ + // MOTD + if ("motd" in data) { + self.motd = { + motd: data.motd.motd, + html: data.motd.html + }; + } + + // Chat history + if ("chatbuffer" in data) { + data.chatbuffer.forEach(function (msg) { + self.chatbuffer.push(msg); + }); + } + + // Inline CSS/JS self.css = data.css || ""; self.js = data.js || ""; - self.ready = true; - self.shouldDump = true; - self.handlePendingJoins(); - } catch(e) { - Logger.errlog.log("Channel dump load failed: "); - Logger.errlog.log(e.stack); + self.emit("ready"); + + } catch (e) { + self.error = true; + Logger.errlog.log("Channel dump load failed (" + self.uniqueName + "): " + e); + self.setMOTD("Channel state load failed. Contact an administrator."); + self.emit("ready"); } }); -} +}; -Channel.prototype.saveDump = function() { - if (this.dead) +Channel.prototype.saveState = function () { + var self = this; + + if (self.error) { return; - if (!this.shouldDump || this.name === "") - return; - var filts = new Array(this.filters.length); - for (var i = 0; i < this.filters.length; i++) { - filts[i] = this.filters[i].pack(); } - var dump = { - position: this.position, - currentTime: this.media ? this.media.currentTime : 0, - playlist: this.playlist.dump(), - opts: this.opts, - permissions: this.permissions, - filters: filts, - motd: this.motd, - openqueue: this.openqueue, - chatbuffer: this.chatbuffer, - css: this.css, - js: this.js + + if (!self.registered || self.uniqueName === "") { + return; + } + + var filters = self.filters.map(function (f) { + return f.pack(); + }); + + var data = { + playlist: self.playlist.dump(), + opts: self.opts, + permissions: self.permissions, + filters: filters, + motd: self.motd, + playlistLock: self.playlistLock, + chatbuffer: self.chatbuffer, + css: self.css, + js: self.js }; - var text = JSON.stringify(dump); - fs.writeFileSync(path.join(__dirname, "../chandump", this.name), text); -} -Channel.prototype.readLog = function (filterIp, callback) { - var maxLen = 100000; // Most recent 100KB - var file = this.logger.filename; - fs.stat(file, function (err, data) { - if(err) { - callback(err, null); - return; + var text = JSON.stringify(data); + fs.writeFileSync(path.join(__dirname, "../chandump", self.uniqueName), text); +}; + +/** + * Checks whether a user has the given permission node + */ +Channel.prototype.hasPermission = function (user, key) { + // Special case: you can have separate permissions for when playlist is unlocked + if (key.indexOf("playlist") === 0 && !this.playlistLock) { + var key2 = "o" + key; + var v = this.permissions[key2]; + if (typeof v === "number" && user.rank >= v) { + return true; } - - var start = data.size - maxLen; - if(start < 0) { - start = 0; - } - var end = data.size - 1; - - var rs = fs.createReadStream(file, { - start: start, - end: end - }); - - var buffer = ""; - rs.on("data", function (data) { - buffer += data; - }); - - rs.on("end", function () { - if(filterIp) { - buffer = buffer.replace( - /\d+\.\d+\.(\d+\.\d+)/g, - "x.x.$1" - ).replace( - /\d+\.\d+\.(\d+)/g, - "x.x.$1.*" - ); - } - - callback(false, buffer); - }); - }); -} - -Channel.prototype.tryReadLog = function (user) { - if(user.rank < 3) { - user.kick("Attempted readChanLog with insufficient permission"); - return; } - var filterIp = true; - if(user.global_rank >= 255) - filterIp = false; - - this.readLog(filterIp, function (err, data) { - if(err) { - user.socket.emit("readChanLog", { - success: false - }); - } else { - user.socket.emit("readChanLog", { - success: true, - data: data - }); - } - }); -} - -Channel.prototype.tryRegister = function (user) { - var self = this; - if(self.registered) { - ActionLog.record(user.ip, user.name, "channel-register-failure", - [self.name, "Channel already registered"]); - user.socket.emit("registerChannel", { - success: false, - error: "This channel is already registered" - }); - } - else if(!user.loggedIn) { - ActionLog.record(user.ip, user.name, "channel-register-failure", - [self.name, "Not logged in"]); - user.socket.emit("registerChannel", { - success: false, - error: "You must log in to register a channel" - }); - - } - else if(user.rank < 10) { - ActionLog.record(user.ip, user.name, "channel-register-failure", - [self.name, "Insufficient permissions"]); - user.socket.emit("registerChannel", { - success: false, - error: "You don't have permission to register this channel" - }); - } - else { - db.channels.register(self.name, user.name, - function (err, res) { - if(err) { - user.socket.emit("registerChannel", { - success: false, - error: "Unable to register channel: " + err - }); - return; - } - - ActionLog.record(user.ip, user.name, - "channel-register-success", self.name); - if (self.dead) - return; - self.registered = true; - self.shouldDump = true; - self.saveDump(); - self.saveRank(user); - user.socket.emit("registerChannel", { - success: true - }); - self.logger.log("*** " + user.name + " registered the channel"); - }); - } -} - -Channel.prototype.unregister = function (user) { - var self = this; - - if(!self.registered) { - user.socket.emit("unregisterChannel", { - success: false, - error: "This channel is already unregistered" - }); - return; + var v = this.permissions[key]; + if (typeof v !== "number") { + return false; } - if(user.rank < 10) { - user.socket.emit("unregisterChannel", { - success: false, - error: "You must be the channel owner to unregister it" - }); - return; + return user.rank >= v; +}; + +/** + * Defer a callback to complete when the channel is ready to accept users. + * Called immediately if the ready flag is already set + */ +Channel.prototype.whenReady = function (fn) { + if (this.ready) { + setImmediate(fn); + } else { + this.on("ready", fn); } - db.channels.drop(self.name, function (err, res) { - if(err) { - user.socket.emit("unregisterChannel", { - success: false, - error: "Unregistration failed: " + err - }); - return; - } - - user.socket.emit("unregisterChannel", { success: true }); - if (!self.dead) - self.registered = false; - }); -} +}; +/** + * Looks up a user's rank in the channel. Computed as max(global_rank, channel rank) + */ Channel.prototype.getRank = function (name, callback) { var self = this; db.users.getGlobalRank(name, function (err, global) { - if (self.dead) + if (self.dead) { return; + } - if(err) { + if (err) { callback(err, null); return; } - if(!self.registered) { + if (!self.registered) { callback(null, global); return; } - db.channels.getRank(self.name, name, - function (err, rank) { - if(err) { + db.channels.getRank(self.name, name, function (err, rank) { + if (self.dead) { + return; + } + + if (err) { callback(err, null); return; } - callback(null, rank > global ? rank : global); + callback(null, Math.max(rank, global)); }); }); -} - -Channel.prototype.saveRank = function (user, callback) { - if(!this.registered) - return; - if(!user.saverank) - return; - db.channels.setRank(this.name, user.name, user.rank, callback); -} - -Channel.prototype.saveInitialRank = function (user, callback) { - if(!this.registered) - return; - db.channels.newRank(this.name, user.name, user.rank, callback); }; +/** + * Looks up the highest rank of any alias of an IP address + */ Channel.prototype.getIPRank = function (ip, callback) { var self = this; db.getAliases(ip, function (err, names) { - if (self.dead) + if (self.dead) { return; + } + db.users.getGlobalRanks(names, function (err, res) { - if(err) { + if (self.dead) { + return; + } + + if (err) { callback(err, null); return; } - var rank = 0; - for(var i in res) { - rank = (res[i] > rank) ? res[i] : rank; - } + var rank = res.reduce(function (a, b) { + return Math.max(a, b); + }, 0); if (!self.registered) { callback(null, rank); @@ -536,879 +421,1079 @@ Channel.prototype.getIPRank = function (ip, callback) { db.channels.getRanks(self.name, names, function (err, res) { - if (self.dead) + if (self.dead) { return; + } - if(err) { + if (err) { callback(err, null); return; } - for(var i in res) { - rank = (res[i] > rank) ? res[i] : rank; - } + var rank = res.reduce(function (a, b) { + return Math.max(a, b); + }, rank); callback(null, rank); }); - }); }); -} +}; -Channel.prototype.cacheMedia = function(media) { +/** + * Called when a user attempts to join a channel. + * Handles password check + */ +Channel.prototype.preJoin = function (user, password) { var self = this; - if (media.type === "gd") - return false; - // Prevent the copy in the playlist from messing with this one - media = media.dup(); - if(media.temp) { + self.whenReady(function () { + user.whenLoggedIn(function () { + self.getRank(user.name, function (err, rank) { + if (err) { + user.rank = user.global_rank; + } else { + user.rank = Math.max(rank, user.global_rank); + } + + user.socket.emit("rank", user.rank); + user.emit("channelRank", user.rank); + }); + }); + + if (self.opts.password !== false && user.rank < 2) { + if (password !== self.opts.password) { + var checkPassword = function (pw) { + if (self.dead) { + return; + } + + if (pw !== self.opts.password) { + user.socket.emit("needPassword", true); + return; + } + + user.socket.listeners("channelPassword").splice( + user.socket.listeners("channelPassword").indexOf(checkPassword) + ); + + user.socket.emit("cancelNeedPassword"); + self.join(user); + }; + + + user.socket.on("channelPassword", checkPassword); + user.socket.emit("needPassword", typeof password !== "undefined"); + user.once("channelRank", function (r) { + if (!user.inChannel() && !self.dead && r >= 2) { + user.socket.emit("cancelNeedPassword"); + self.join(user); + } + }); + return; + } + } + + self.join(user); + }); +}; + +/** + * Called when a user joins a channel + */ +Channel.prototype.join = function (user) { + var self = this; + + var afterLogin = function () { + if (self.dead) { + return; + } + + var lname = user.name.toLowerCase(); + for (var i = 0; i < self.users.length; i++) { + if (self.users[i].name.toLowerCase() === lname && self.users[i] !== user) { + self.users[i].kick("Duplicate login"); + } + } + + self.sendUserJoin(self.users, user); + self.sendUserlist([user]); + }; + + var afterIPBan = function () { + user.autoAFK(); + user.socket.join(self.uniqueName); + user.channel = self; + + self.users.push(user); + self.sendVoteskipUpdate(self.users); + self.sendUsercount(self.users); + + user.whenLoggedIn(function () { + db.channels.isNameBanned(self.name, user.name, function (err, banned) { + if (!err && banned) { + user.kick("You're banned!"); + } else { + afterLogin(); + } + }); + }); + + self.sendPlaylist([user]); + self.sendMediaUpdate([user]); + self.sendPlaylistLock([user]); + self.sendUserlist([user]); + self.sendRecentChat([user]); + self.sendCSSJS([user]); + self.sendPoll([user]); + self.sendOpts([user]); + self.sendPermissions([user]); + self.sendMotd([user]); + self.sendDrinkCount([user]); + + self.logger.log("+++ " + user.ip + " joined"); + Logger.syslog.log(user.ip + " joined channel " + self.name); + }; + + db.channels.isIPBanned(self.name, user.ip, function (err, banned) { + if (!err && banned) { + user.kick("You're banned!"); + return; + } else { + afterIPBan(); + } + }); +}; + +/** + * Called when a user leaves the channel. + * Cleans up and sends appropriate updates to other users + */ +Channel.prototype.part = function (user) { + var self = this; + user.channel = null; + + // Clear poll vote + if (self.poll) { + self.poll.unvote(user.ip); + self.sendPollUpdate(self.users); + } + + // Clear voteskip vote + if (self.voteskip) { + self.voteskip.unvote(user.ip); + self.sendVoteskipUpdate(self.users); + } + + // Return video lead to server if necessary + if (self.leader === user) { + self.changeLeader(""); + } + + // Remove from users array + var idx = self.users.indexOf(user); + if (idx >= 0 && idx < self.users.length) { + self.users.splice(idx, 1); + } + + // A change in usercount might cause a voteskip result to change + self.checkVoteskipPass(); + self.sendUsercount(self.users); + + if (user.loggedIn) { + self.sendUserLeave(self.users, user); + } + + self.logger.log("--- " + user.ip + " (" + user.name + ") left"); + if (self.users.length === 0) { + self.emit("empty"); return; } - if(self.registered) { - db.channels.addToLibrary(self.name, media); - } -} +}; -Channel.prototype.tryNameBan = function(actor, name) { - var self = this; - if(!self.hasPermission(actor, "ban")) { +/** + * Send the MOTD to the given users + */ +Channel.prototype.sendMOTD = function (users) { + var motd = this.motd; + users.forEach(function (u) { + u.socket.emit("setMotd", motd); + }); +}; + +/** + * Sends a message to channel moderators + */ +Channel.prototype.sendModMessage = function (msg, minrank) { + if (isNaN(minrank)) { + minrank = 2; + } + + var notice = { + username: "[server]", + msg: msg, + meta: { + addClass: "server-whisper" , + addClassToNameAndTimestamp: true + }, + time: Date.now() + }; + + this.users.forEach(function(u) { + if (u.rank > minrank) { + u.socket.emit("chatMsg", notice); + } + }); +}; + +/** + * Stores a video in the channel's library + */ +Channel.prototype.cacheMedia = function (media) { + // Don't cache Google Drive videos because of their time limit + if (media.type === "gd") { return false; } + if (this.registered) { + db.channels.addToLibrary(this.name, media); + } +}; + +/** + * Attempts to ban a user by name + */ +Channel.prototype.handleNameBan = function (actor, name, reason) { + var self = this; + if (!self.hasPermission(actor, "ban")) { + return false; + } + + if (!self.registered) { + actor.socket.emit("errorMsg", { + msg: "Banning is only supported in registered channels" + }); + return; + } + name = name.toLowerCase(); - if(name == actor.name.toLowerCase()) { + if (name == actor.name.toLowerCase()) { actor.socket.emit("costanza", { msg: "Trying to ban yourself?" }); return; } + // Look up the name's rank so people can't ban others with higher rank than themselves self.getRank(name, function (err, rank) { - if (self.dead) + if (self.dead) { return; - if(err) { + } + + if (err) { actor.socket.emit("errorMsg", { - msg: "Internal error " + err + msg: "Internal error: " + err }); return; } - if(rank >= actor.rank) { + if (rank >= actor.rank) { actor.socket.emit("errorMsg", { msg: "You don't have permission to ban " + name }); return; } - self.namebans[name] = actor.name; - for(var i = 0; i < self.users.length; i++) { - if(self.users[i].name.toLowerCase() == name) { - self.kick(self.users[i], "You're banned!"); + if (typeof reason !== "string") { + reason = ""; + } + + reason = reason.substring(0, 255); + + // If in the channel already, kick the banned user + for (var i = 0; i < self.users.length; i++) { + if (self.users[i].name.toLowerCase() == name) { + self.users[i].kick("You're banned!"); break; } } self.logger.log("*** " + actor.name + " namebanned " + name); - var notice = { - username: "[server]", - msg: actor.name + " banned " + name, - meta: { - addClass: "server-whisper" , - addClassToNameAndTimestamp: true - }, - time: Date.now() - }; - self.users.forEach(function(u) { - if(self.hasPermission(u, "ban")) { - self.sendBanlist(u); - u.socket.emit("chatMsg", notice); - } - }); + self.sendModMessage(actor.name + " banned " + name, self.permissions.ban); - if(!self.registered) { + db.channels.isNameBanned(self.name, name, function (err, banned) { + if (!err && banned) { + actor.socket.emit("errorMsg", { + msg: name + " is already banned" + }); + return; + } + + if (self.dead) { + return; + } + + // channel, ip, name, reason, actor + db.channels.ban(self.name, "*", name, reason, actor.name); + // TODO send banlist? + }); + }); +}; + +/** + * Removes a ban by ID + */ +Channel.prototype.handleUnban = function (actor, data) { + var self = this; + if (!this.hasPermission(actor, "ban")) { + return; + } + + if (typeof data.id !== "number") { + data.id = parseInt(data.id); + if (isNaN(data.id)) { + return; + } + } + + data.actor = actor.name; + + if (!self.registered) { + return; + } + + db.channels.unbanId(self.name, data.id, function (err, res) { + if (err) { + actor.socket.emit("errorMsg", { + msg: err + }); return; } - db.channels.ban(self.name, "*", name, "", actor.name); + self.sendUnban(self.users, data); }); -} +}; -Channel.prototype.unbanName = function(actor, name) { +/** + * Sends an unban packet + */ +Channel.prototype.sendUnban = function (users, data) { var self = this; - if(!self.hasPermission(actor, "ban")) { - actor.kick("Attempted unban with insufficient permission"); - return false; + users.forEach(function (u) { + if (self.hasPermission(u, "ban")) { + u.socket.emit("banlistRemove", data); + } + }); + self.logger.log("*** " + data.actor + " unbanned " + data.name); + self.sendModMessage(data.actor + " unbanned " + data.name, self.permissions.ban); +}; + +/** + * Bans all IP addresses associated with a username + */ +Channel.prototype.handleBanAllIP = function (actor, name, reason, range) { + var self = this; + if (!self.hasPermission(actor, "ban")) { + return; } - self.namebans[name] = null; - delete self.namebans[name]; - self.logger.log("*** " + actor.name + " un-namebanned " + name); - if (!self.registered) + if (typeof name !== "string") { return; + } - db.channels.unbanName(self.name, name, function (err, res) { - if (self.dead) - return; - - self.users.forEach(function(u) { - self.sendBanlist(u); + if (!self.registered) { + actor.socket.emit("errorMsg", { + msg: "Banning is not supported for unregistered rooms" }); - }); -} + return; + } -Channel.prototype.tryIPBan = function(actor, name, range) { - var self = this; - if(!self.hasPermission(actor, "ban")) { - return; - } - if(typeof name != "string") { - return; - } name = name.toLowerCase(); - if(name == actor.name.toLowerCase()) { + if (name === actor.name.toLowerCase()) { actor.socket.emit("costanza", { msg: "Trying to ban yourself?" }); return; } - db.listIPsForName(name, function (err, ips) { - if (self.dead) - return; - if(err) { + db.getIPs(name, function (err, ips) { + if (self.dead) { + return; + } + + if (err) { actor.socket.emit("errorMsg", { msg: "Internal error: " + err }); return; } + ips.forEach(function (ip) { - if(range) - ip = ip.replace(/(\d+)\.(\d+)\.(\d+)\.(\d+)/, "$1.$2.$3"); - self.getIPRank(ip, function (err, rank) { - if (self.dead) - return; - - if(err) { - actor.socket.emit("errorMsg", { - msg: "Internal error: " + err - }); - return; - } - - if(rank >= actor.rank) { - actor.socket.emit("errorMsg", { - msg: "You don't have permission to ban IP: " + - $util.maskIP(ip) - }); - return; - } - - self.ipbans[ip] = [name, actor.name]; - self.logger.log("*** " + actor.name + " banned " + ip + - " (" + name + ")"); - - for(var i = 0; i < self.users.length; i++) { - if(self.users[i].ip.indexOf(ip) == 0) { - self.kick(self.users[i], "Your IP is banned!"); - i--; - } - } - - if(!self.registered) - return; - - db.channels.ban(self.name, ip, name, "", actor.name, - function (err, res) { - if (self.dead) - return; - - var notice = { - username: "[server]", - msg: actor.name + " banned " + $util.maskIP(ip) + - " (" + name + ")", - meta: { - addClass: "server-whisper", - addClassToNameAndTimestamp: true - }, - time: Date.now() - }; - self.users.forEach(function(u) { - if(self.hasPermission(u, "ban")) { - u.socket.emit("chatMsg", notice); - self.sendBanlist(u); - } - }); - }); - }); + self.tryBanIP(actor, ip, name, range); }); }); -} +}; -Channel.prototype.unbanIP = function(actor, ip) { - var self = this; - if(!self.hasPermission(actor, "ban")) { - actor.kick("Attempted unban with insufficient permission"); - return false; +/** + * Bans an individual IP + */ +Channel.prototype.banIP = function (actor, ip, name, reason, range) { + if (range) { + ip = ip.replace(/(\d+)\.(\d+)\.(\d+)\.(\d+)/, "$1.$2.$3"); } - self.ipbans[ip] = null; - self.users.forEach(function(u) { - self.sendBanlist(u); - }); - - self.logger.log("*** " + actor.name + " unbanned " + ip); - - if(!self.registered) - return false; - - // Update database ban table - db.channels.unbanIP(self.name, ip); -} - -Channel.prototype.tryUnban = function(actor, data) { - if(typeof data.ip_hidden === "string") { - var ip = this.hideIP(data.ip_hidden); - this.unbanIP(actor, ip); + if (typeof reason !== "string") { + reason = ""; } - if(typeof data.name === "string") { - this.unbanName(actor, data.name); - } -} -Channel.prototype.search = function(query, callback) { - var self = this; - if(!self.registered) { - callback([]); - return; - } - db.channels.searchLibrary(self.name, query, function (err, res) { - if(err) { - res = []; - } + reason = reason.substring(0, 255); - res.sort(function(a, b) { - var x = a.title.toLowerCase(); - var y = b.title.toLowerCase(); - - return (x == y) ? 0 : (x < y ? -1 : 1); - }); - - res.forEach(function (r) { - r.duration = $util.formatTime(r.seconds); - }); - callback(res); - }); -} - -/* REGION User interaction */ - -Channel.prototype.addPendingJoin = function (user, password) { - var self = this; - if (!("pendingJoins" in self)) { - self.pendingJoins = []; - } - for (var i = 0; i < self.pendingJoins.length; i++) { - if (self.pendingJoins[i].user === user) { + self.getIPRank(ip, function (err, rank) { + if (self.dead) { return; } - } - self.pendingJoins.push({ - user: user, - pw: password - }); -}; -Channel.prototype.handlePendingJoins = function () { - var self = this; - self.pendingJoins.forEach(function (u) { - self.userJoin(u.user, u.pw); - }); - delete self["pendingJoins"]; -}; + if (err) { + actor.socket.emit("errorMsg", { + msg: "Internal error: " + err + }); + return; + } -Channel.prototype.userJoin = function(user, password) { - if (typeof password === "string" && password.length > 100) { - password = password.substring(0, 100); - } - var self = this; - if (!self.ready) { - self.addPendingJoin(user, password); - return; - } + if (rank >= actor.rank) { + actor.socket.emit("errorMsg", { + msg: "You don't have permission to ban IP: " + util.maskIP(ip) + }); + return; + } - if (this.opts.password !== false && - this.opts.password !== password && - user.rank < 2) { - user.socket.emit("needPassword", password !== undefined && - this.opts.password !== password); - return; - } - - user.channel = this; - if (("pendingChannel" in user)) { - user.socket.emit("cancelNeedPassword"); - delete user["pendingChannel"]; - } - - var parts = user.ip.split("."); - var slash24 = parts[0] + "." + parts[1] + "." + parts[2]; - // GTFO - if((user.ip in this.ipbans && this.ipbans[user.ip] != null) || - (slash24 in this.ipbans && this.ipbans[slash24] != null)) { - this.logger.log("--- Kicking " + user.ip + " - banned"); - this.kick(user, "You're banned!"); - return; - } - if(user.name && user.name.toLowerCase() in this.namebans && - this.namebans[user.name.toLowerCase()] != null) { - this.kick(user, "You're banned!"); - return; - } - - // Join the socket pool for this channel - user.socket.join(this.name); - - // Enable AFK timer - user.autoAFK(); - - // Prevent duplicate login - if(user.name != "") { - for(var i = 0; i < this.users.length; i++) { - if(this.users[i].name.toLowerCase() == user.name.toLowerCase()) { - if (this.users[i] == user) { - Logger.errlog.log("Wat: userJoin() called on user "+ - "already in the channel"); - break; - } - this.kick(this.users[i], "Duplicate login"); + self.logger.log("*** " + actor.name + " banned " + ip + " (" + name + ")"); + self.sendModMessage(actor.name + " banned " + ip + " (" + name + ")", self.permissions.ban); + // If in the channel already, kick the banned user + for (var i = 0; i < self.users.length; i++) { + if (self.users[i].ip === ip) { + self.users[i].kick("You're banned!"); + break; } } - } - this.users.push(user); - this.broadcastVoteskipUpdate(); - if(user.name != "") { - self.getRank(user.name, function (err, rank) { - if (self.dead) - return; - if(err) { - user.rank = user.global_rank; - user.saverank = false; - } else { - user.saverank = true; - user.rank = rank; - } - user.socket.emit("rank", user.rank); - self.broadcastNewUser(user); - }); - } - this.broadcastUsercount(); - - // Set the new guy up - this.sendPlaylist(user); - this.sendMediaUpdate(user); - user.socket.emit("setPlaylistLocked", {locked: !this.openqueue}); - this.sendUserlist(user); - this.sendRecentChat(user); - user.socket.emit("channelCSSJS", {css: this.css, js: this.js}); - if(this.poll) { - user.socket.emit("newPoll", this.poll.packUpdate()); - } - user.socket.emit("channelOpts", this.opts); - user.socket.emit("setPermissions", this.permissions); - user.socket.emit("setMotd", this.motd); - user.socket.emit("drinkCount", this.drinks); - - this.logger.log("+++ " + user.ip + " joined"); - Logger.syslog.log(user.ip + " joined channel " + this.name); -} - -Channel.prototype.userLeave = function(user) { - user.channel = null; - // Their socket might already be dead, so wrap in a try-catch - try { - user.socket.leave(this.name); - } - catch(e) {} - - // Undo vote for people who leave - if(this.poll) { - this.poll.unvote(user.ip); - this.broadcastPollUpdate(); - } - if(this.voteskip) { - this.voteskip.unvote(user.ip); - } - - // If they were leading, return control to the server - if(this.leader == user) { - this.changeLeader(""); - } - - // Remove the user from the client list for this channel - var idx = this.users.indexOf(user); - if(idx >= 0 && idx < this.users.length) - this.users.splice(idx, 1); - this.checkVoteskipPass(); - this.broadcastUsercount(); - if(user.name != "") { - this.sendAll("userLeave", { - name: user.name - }); - } - this.logger.log("--- " + user.ip + " (" + user.name + ") left"); - if(this.users.length == 0) { - this.logger.log("*** Channel empty, unloading"); - this.server.unloadChannel(this); - } -} - -Channel.prototype.kick = function(user, reason) { - user.socket.emit("kick", { - reason: reason - }); - user.socket.disconnect(true); - user.channel = null; -} - -Channel.prototype.hideIP = function(ip) { - var chars = new Array(15); - for(var i = 0; i < ip.length; i++) { - chars[i] = String.fromCharCode(ip.charCodeAt(i) ^ this.ipkey.charCodeAt(i)); - } - return chars.join(""); -} - -Channel.prototype.sendLoginHistory = function(user) { - if(user.rank < 2) - return; - - user.socket.emit("recentLogins", this.login_hist); -} - -Channel.prototype.sendBanlist = function(user) { - if(this.hasPermission(user, "ban")) { - var ents = []; - for(var ip in this.ipbans) { - if(this.ipbans[ip] != null) { - var name = this.ipbans[ip][0]; - var ip_hidden = this.hideIP(ip); - var disp = ip; - if(user.rank < 255) { - disp = $util.maskIP(ip); - } - ents.push({ - ip_displayed: disp, - ip_hidden: ip_hidden, - name: name, - aliases: this.ip_alias[ip] || [], - banner: this.ipbans[ip][1] - }); - } + if (!self.registered) { + return; } - for(var name in this.namebans) { - if(this.namebans[name] != null) { - ents.push({ - ip_displayed: "*", - ip_hidden: false, - name: name, - aliases: this.name_alias[name] || [], - banner: this.namebans[name] - }); - } - } - user.socket.emit("banlist", ents); - } -} -Channel.prototype.sendChatFilters = function(user) { - if(this.hasPermission(user, "filteredit")) { - var filts = new Array(this.filters.length); - for(var i = 0; i < this.filters.length; i++) { - filts[i] = this.filters[i].pack(); - } - user.socket.emit("chatFilters", filts); - } -} - -Channel.prototype.sendChannelRanks = function(user) { - if(user.rank >= 3 && this.registered) { - db.channels.allRanks(this.name, function (err, res) { - if(err) { - user.socket.emit("errorMsg", { - msg: "Internal error: " + err + db.channels.isIPBanned(self.name, ip, function (err, banned) { + if (!err && banned) { + var disp = actor.global_rank >= 255 ? ip : util.maskIP(ip); + actor.socket.emit("errorMsg", { + msg: disp + " is alraedy banned" }); return; } - user.socket.emit("channelRanks", res); + + if (self.dead) { + return; + } + + // channel, ip, name, reason, ban actor + db.channels.ban(self.name, ip, name, reason, actor.name, function (err) { + if (err) { + actor.socket.emit("errorMsg", { + msg: "Ban failed: " + err + }); + } + }); }); - } -} + }); +}; -Channel.prototype.sendPlaylist = function(user) { - user.socket.emit("playlist", this.playlist.items.toArray()); - if(this.playlist.current) - user.socket.emit("setCurrent", this.playlist.current.uid); - user.socket.emit("setPlaylistMeta", this.plmeta); -} -Channel.prototype.sendMediaUpdate = function(user) { - if(this.playlist.current != null) { - user.socket.emit("changeMedia", this.playlist.current.media.fullupdate()); - } -} +/** + * Sends the banlist + */ +Channel.prototype.sendBanlist = function (users) { + var self = this; -Channel.prototype.sendUserlist = function(user) { - var users = []; - for(var i = 0; i < this.users.length; i++) { - // Skip people who haven't logged in - if(this.users[i].name != "") { - users.push({ - name: this.users[i].name, - rank: this.users[i].rank, - meta: this.users[i].meta, - profile: this.users[i].profile + var bans = []; + var unmaskedbans = []; + db.channels.listBans(self.name, function (err, banlist) { + if (err) { + return; + } + + for (var i = 0; i < banlist.length; i++) { + bans.push({ + id: banlist[i].id, + ip: banlist[i].ip === "*" ? "*" : util.maskIP(banlist[i].ip), + name: banlist[i].name, + reason: banlist[i].reason, + bannedby: banlist[i].bannedby + }); + unmaskedbans.push({ + id: banlist[i].id, + ip: banlist[i].ip, + name: banlist[i].name, + reason: banlist[i].reason, + bannedby: banlist[i].bannedby }); } - } - user.socket.emit("userlist", users); - if (this.leader !== null) { - user.socket.emit("setLeader", this.leader.name); - } -} -// Send the last 15 messages for context -Channel.prototype.sendRecentChat = function(user) { - for(var i = 0; i < this.chatbuffer.length; i++) { - user.socket.emit("chatMsg", this.chatbuffer[i]); - } -} + users.forEach(function (u) { + if (!self.hasPermission(u, "ban")) { + return; + } -/* REGION Broadcasts to all clients */ + if (u.rank >= 255) { + u.socket.emit("banlist", unmaskedbans); + } else { + u.socket.emit("banlist", bans); + } + }); + }); +}; -Channel.prototype.sendAll = function(message, data) { - if(this.name == "") - return; - this.server.io.sockets.in(this.name).emit(message, data); - if (this.server.cfg["enable-ssl"]) - this.server.ioSecure.sockets.in(this.name).emit(message, data); -} - -Channel.prototype.sendAllWithRank = function(rank, msg, data) { - for(var i = 0; i < this.users.length; i++) { - if(this.users[i].rank >= rank) { - this.users[i].socket.emit(msg, data); +/** + * Sends the channel ranks list + */ +Channel.prototype.sendChannelRanks = function (users) { + var self = this; + db.channels.allRanks(self.name, function (err, ranks) { + if (err) { + return; } - } -} -Channel.prototype.sendAllExcept = function(user, msg, data) { - for(var i = 0; i < this.users.length; i++) { - if (this.users[i] !== user) { - this.users[i].socket.emit(msg, data); + users.forEach(function (u) { + if (u.rank >= 3) { + u.socket.emit("channelRanks", ranks); + } + }); + }); +}; + +/** + * Sends the chat filter list + */ +Channel.prototype.sendChatFilters = function (users) { + var self = this; + + var pkt = self.filters.map(function (f) { + return f.pack(); + }); + + users.forEach(function (u) { + if (!self.hasPermission(u, "filteredit")) { + return; } - } -} -Channel.prototype.broadcastPlaylistMeta = function() { + u.socket.emit("chatFilters", pkt); + }); +}; + +/** + * Sends the channel permissions + */ +Channel.prototype.sendPermissions = function (users) { + var perms = this.permissions; + users.forEach(function (u) { + u.socket.emit("setPermissions", perms); + }); +}; + +/** + * Sends the playlist + */ +Channel.prototype.sendPlaylist = function (users) { + var self = this; + + var pl = self.playlist.items.toArray(); + var current = null; + if (self.playlist.current) { + current = self.playlist.current.uid; + } + + users.forEach(function (u) { + u.socket.emit("playlist", pl); + u.socket.emit("setPlaylistMeta", self.plmeta); + if (current !== null) { + u.socket.emit("setCurrent", current); + } + }); +}; + +/** + * Updates the playlist count/time + */ +Channel.prototype.updatePlaylistMeta = function () { var total = 0; var iter = this.playlist.items.first; - while(iter !== null) { - if(iter.media !== null) + while (iter !== null) { + if (iter.media !== null) { total += iter.media.seconds; + } iter = iter.next; } - var timestr = $util.formatTime(total); - var packet = { + + var timestr = util.formatTime(total); + this.plmeta = { count: this.playlist.items.length, time: timestr }; - this.plmeta = packet; - this.sendAll("setPlaylistMeta", packet); -} +}; -Channel.prototype.broadcastUsercount = function() { - this.sendAll("usercount", this.users.length); -} - -Channel.prototype.broadcastNewUser = function(user) { +/** + * Send the playlist count/time + */ +Channel.prototype.sendPlaylistMeta = function (users) { var self = this; - // If the channel is empty and isn't registered, the first person - // gets ownership of the channel (temporarily) - if(self.dbloaded && self.users.length == 1 && !self.registered) { - user.rank = (user.rank < 10) ? 10 : user.rank; - user.socket.emit("channelNotRegistered"); - user.socket.emit("rank", user.rank); + users.forEach(function (u) { + u.socket.emit("setPlaylistMeta", self.plmeta); + }); +}; + +/** + * Sends the playlist lock + */ +Channel.prototype.sendPlaylistLock = function (users) { + var lock = this.playlistLock; + users.forEach(function (u) { + u.socket.emit("setPlaylistLocked", lock); + }); +}; + +/** + * Sends a changeMedia packet + */ +Channel.prototype.sendMediaUpdate = function (users) { + var update = this.playlist.getFullUpdate(); + if (update) { + users.forEach(function (u) { + u.socket.emit("changeMedia", update); + }); } - db.getAliases(user.ip, function (err, aliases) { - if (self.dead) - return; +}; - if(err) { - aliases = []; +/** + * Sends the drink count + */ +Channel.prototype.sendDrinkCount = function (users) { + var drinks = this.drinks; + users.forEach(function (u) { + u.socket.emit("drinkCount", drinks); + }); +}; + +/** + * Send the userlist + */ +Channel.prototype.sendUserlist = function (toUsers) { + var self = this; + var base = []; + var mod = []; + var sadmin = []; + + for (var i = 0; i < self.users.length; i++) { + var u = self.users[i]; + if (u.name === "") { + continue; } - self.ip_alias[user.ip] = aliases; - aliases.forEach(function (alias) { - self.name_alias[alias] = aliases; - }); + var data = self.packUserData(self.users[i]); + base.push(data.base); + mod.push(data.mod); + sadmin.push(data.sadmin); + } - self.login_hist.unshift({ - name: user.name, - aliases: self.ip_alias[user.ip], - time: Date.now() - }); - - if(self.login_hist.length > 20) - self.login_hist.pop(); - - if(user.name.toLowerCase() in self.namebans && - self.namebans[user.name.toLowerCase()] !== null) { - self.kick(user, "You're banned!"); - return; - } - if (self.mutedUsers.contains(user.name.toLowerCase()) || - self.mutedUsers.contains("[shadow]"+user.name.toLowerCase())) { - user.meta.icon = "icon-volume-off"; - } - - var pkt = { - name: user.name, - rank: user.rank, - meta: user.meta, - profile: user.profile - }; - if (self.mutedUsers.contains("[shadow]"+user.name.toLowerCase())) { - self.sendAllExcept(user, "addUser", pkt); - pkt.meta.icon = false; - user.socket.emit("addUser", pkt); + toUsers.forEach(function (u) { + if (u.global_rank >= 255) { + u.socket.emit("userlist", sadmin); + } else if (u.rank >= 2) { + u.socket.emit("userlist", mod); } else { - self.sendAll("addUser", pkt); + u.socket.emit("userlist", base); } - if(user.rank > 0) { - self.saveInitialRank(user); + if (self.leader != null) { + u.socket.emit("setLeader", self.leader.name); + } + }); +}; + +/** + * Send the user count + */ +Channel.prototype.sendUsercount = function (users) { + var self = this; + users.forEach(function (u) { + u.socket.emit("usercount", self.users.length); + }); +}; + +/** + * Send the chat buffer + */ +Channel.prototype.sendRecentChat = function (users) { + var self = this; + users.forEach(function (u) { + for (var i = 0; i < self.chatbuffer.length; i++) { + u.socket.emit("chatMsg", self.chatbuffer[i]); + } + }); +}; + +/** + * Sends a user profile + */ +Channel.prototype.sendUserProfile = function (users, user) { + var packet = { + name: user.name, + profile: user.profile + }; + + users.forEach(function (u) { + u.socket.emit("setUserProfile", packet); + }); +}; + +/** + * Packs userdata for addUser or userlist + */ +Channel.prototype.packUserData = function (user) { + var base = { + name: user.name, + rank: user.rank, + profile: user.profile, + meta: { + afk: user.meta.afk, + muted: user.meta.muted && !user.meta.smuted + } + }; + + var mod = { + name: user.name, + rank: user.rank, + profile: user.profile, + meta: { + afk: user.meta.afk, + muted: user.meta.muted, + smuted: user.meta.smuted, + aliases: user.meta.aliases, + ip: util.maskIP(user.ip) + } + }; + + var sadmin = { + name: user.name, + rank: user.rank, + profile: user.profile, + meta: { + afk: user.meta.afk, + muted: user.meta.muted, + smuted: user.meta.smuted, + aliases: user.meta.aliases, + ip: user.ip + } + }; + + return { + base: base, + mod: mod, + sadmin: sadmin + }; +}; + +/** + * Sends a user.meta update, optionally filtering by minimum rank + */ +Channel.prototype.sendUserMeta = function (users, user, minrank) { + var self = this; + var userdata = self.packUserData(user); + self.users.filter(function (u) { + return typeof minrank !== "number" || u.rank > minrank + }).forEach(function (u) { + if (u.rank >= 255) { + u.socket.emit("setUserMeta", { + name: user.name, + meta: userdata.sadmin.meta + }); + } else if (u.rank >= 2) { + u.socket.emit("setUserMeta", { + name: user.name, + meta: userdata.mod.meta + }); + } else { + u.socket.emit("setUserMeta", { + name: user.name, + meta: userdata.base.meta + }); + } + }); +}; + +/** + * Send a user join notification + */ +Channel.prototype.sendUserJoin = function (users, user) { + var self = this; + db.getAliases(user.ip, function (err, aliases) { + if (self.dead) { + return; } - var msg = user.name + " joined (aliases: "; - msg += self.ip_alias[user.ip].join(", ") + ")"; - var pkt = { - username: "[server]", - msg: msg, - meta: { - addClass: "server-whisper", - addClassToNameAndTimestamp: true - }, - time: Date.now() - }; - self.sendAllWithRank(2, "joinMessage", pkt); + if (err || aliases.length === 0) { + aliases = [user.name]; + } + + user.meta.aliases = aliases; + + if (self.isShadowMuted(user.name)) { + user.meta.muted = true; + user.meta.shadowmuted = true; + } else if (self.isMuted(user.name)) { + user.meta.muted = true; + user.meta.shadowmuted = false; + } + + var data = self.packUserData(user); + + users.forEach(function (u) { + if (u.global_rank >= 255) { + u.socket.emit("addUser", data.sadmin); + } else if (u.rank >= 2) { + u.socket.emit("addUser", data.mod); + } else { + u.socket.emit("addUser", data.base); + } + }); + + self.sendModMessage(user.name + " joined (aliases: " + aliases.join(",") + ")", 2); }); -} +}; -Channel.prototype.broadcastPoll = function() { - var self = this; - var unhidden = this.poll.packUpdate(true); - var hidden = this.poll.packUpdate(false); +/** + * Sends a notification that a user left + */ +Channel.prototype.sendUserLeave = function (users, user) { + var data = { + name: user.name + }; - this.users.forEach(function (u) { - if (self.hasPermission(u, "viewhiddenpoll")) - u.socket.emit("newPoll", unhidden); - else - u.socket.emit("newPoll", hidden); + users.forEach(function (u) { + u.socket.emit("userLeave", data); }); -} +}; -Channel.prototype.broadcastPollUpdate = function() { +/** + * Sends the current poll + */ +Channel.prototype.sendPoll = function (users) { var self = this; - var unhidden = this.poll.packUpdate(true); - var hidden = this.poll.packUpdate(false); + if (!self.poll) { + return; + } - this.users.forEach(function (u) { - if (self.hasPermission(u, "viewhiddenpoll")) + var obscured = self.poll.packUpdate(false); + var unobscured = self.poll.packUpdate(true); + + users.forEach(function (u) { + if (self.hasPermission(u, "viewpollresults")) { + u.socket.emit("newPoll", unobscured); + } else { + u.socket.emit("newPoll", obscured); + } + }); +}; + +/** + * Sends a poll notification + */ +Channel.prototype.sendPollUpdate = function (users) { + var self = this; + var unhidden = self.poll.packUpdate(true); + var hidden = self.poll.packUpdate(false); + + users.forEach(function (u) { + if (self.hasPermission(u, "viewhiddenpoll")) { u.socket.emit("updatePoll", unhidden); - else + } else { u.socket.emit("updatePoll", hidden); + } }); -} +}; -Channel.prototype.broadcastPollClose = function() { - this.sendAll("closePoll"); -} +/** + * Sends a "poll closed" notification + */ +Channel.prototype.sendPollClose = function (users) { + users.forEach(function (u) { + u.socket.emit("closePoll"); + }); +}; -Channel.prototype.broadcastOpts = function() { - this.sendAll("channelOpts", this.opts); -} - -Channel.prototype.broadcastBanlist = function() { - var ents = []; - var adminents = []; - for(var ip in this.ipbans) { - if(this.ipbans[ip] != null) { - var name = this.ipbans[ip][0]; - var ip_hidden = this.hideIP(ip); - ents.push({ - ip_displayed: $util.maskIP(ip), - ip_hidden: ip_hidden, - name: name, - aliases: this.ip_alias[ip] || [], - banner: this.ipbans[ip][1] - }); - adminents.push({ - ip_displayed: ip, - ip_hidden: ip_hidden, - name: name, - aliases: this.ip_alias[ip] || [], - banner: this.ipbans[ip][1] - }); - } - } - for(var name in this.namebans) { - if(this.namebans[name] != null) { - ents.push({ - ip_displayed: "*", - ip_hidden: false, - name: name, - aliases: this.name_alias[name] || [], - banner: this.namebans[name] - }); - adminents.push({ - ip_displayed: "*", - ip_hidden: false, - name: name, - aliases: this.name_alias[name] || [], - banner: this.namebans[name] - }); - } - } - for(var i = 0; i < this.users.length; i++) { - if(this.hasPermission(this.users[i], "ban")) { - if(this.users[i].rank >= 255) { - this.users[i].socket.emit("banlist", adminents); - } - else { - this.users[i].socket.emit("banlist", ents); - } - } - } -} - -Channel.prototype.broadcastChatFilters = function() { - var filts = new Array(this.filters.length); - for(var i = 0; i < this.filters.length; i++) { - filts[i] = this.filters[i].pack(); - } - for(var i = 0; i < this.users.length; i++) { - if(this.hasPermission(this.users[i], "filteredit")) { - this.users[i].socket.emit("chatFilters", filts); - } - } -} +/** + * Broadcasts the channel options + */ +Channel.prototype.sendOpts = function (users) { + var opts = this.opts; + users.forEach(function (u) { + u.socket.emit("channelOpts", opts); + }); +}; +/** + * Calculates the number of eligible users to voteskip + */ Channel.prototype.calcVoteskipMax = function () { var self = this; - // good ol' map-reduce return self.users.map(function (u) { - if (!self.hasPermission(u, "voteskip")) + if (!self.hasPermission(u, "voteskip")) { return 0; + } + return u.meta.afk ? 0 : 1; }).reduce(function (a, b) { return a + b; }, 0); }; -Channel.prototype.getVoteskipPacket = function() { - var amt = this.voteskip ? this.voteskip.counts[0] : 0; - var count = this.calcVoteskipMax(); - var need = this.voteskip ? Math.ceil(count * this.opts.voteskip_ratio) : 0; +/** + * Creates a voteskip update packet + */ +Channel.prototype.getVoteskipPacket = function () { + var have = this.voteskip ? this.voteskip.counts[0] : 0; + var max = this.calcVoteskipMax(); + var need = this.voteskip ? Math.ceil(max * this.opts.voteskip_ratio) : 0; return { - count: amt, + count: have, need: need }; -} +}; -Channel.prototype.broadcastVoteskipUpdate = function () { - var pkt = this.getVoteskipPacket(); - this.users.forEach(function (u) { +/** + * Sends a voteskip update packet + */ +Channel.prototype.sendVoteskipUpdate = function (users) { + var update = this.getVoteskipPacket(); + users.forEach(function (u) { if (u.rank >= 1.5) { - u.socket.emit("voteskip", pkt); + u.socket.emit("voteskip", update); } }); }; -Channel.prototype.broadcastMotd = function() { - this.sendAll("setMotd", this.motd); -} +/** + * Sends the inline CSS and JS + */ +Channel.prototype.sendCSSJS = function (users) { + var data = { + css: this.css, + js: this.js + }; -Channel.prototype.broadcastDrinks = function() { - this.sendAll("drinkCount", this.drinks); -} + users.forEach(function (u) { + u.socket.emit("channelCSSJS", data); + }); +}; -/* REGION Playlist Stuff */ +/** + * Sends the MOTD + */ +Channel.prototype.sendMotd = function (users) { + var motd = this.motd; + users.forEach(function (u) { + u.socket.emit("setMotd", motd); + }); +}; -Channel.prototype.onVideoChange = function () { +/** + * Sends the drink count + */ +Channel.prototype.sendDrinks = function (users) { + var drinks = this.drinks; + users.forEach(function (u) { + u.socket.emit("drinkCount", drinks); + }); +}; + +/** + * Resets video-related variables + */ +Channel.prototype.resetVideo = function () { this.voteskip = false; - this.broadcastVoteskipUpdate(); + this.sendVoteskipUpdate(this.users); this.drinks = 0; - this.broadcastDrinks(); -} + this.sendDrinks(this.users); +}; -function isLive(type) { - return type == "li" // Livestream.com - || type == "tw" // Twitch.tv - || type == "jt" // Justin.tv - || type == "rt" // RTMP - || type == "jw" // JWPlayer - || type == "us" // Ustream.tv - || type == "im" // Imgur album - || type == "cu";// Custom Embed -} - -Channel.prototype.queueAdd = function(item, after) { - var chan = this; - function afterAdd() { - chan.sendAll("queue", { - item: item.pack(), - after: after - }); - chan.broadcastPlaylistMeta(); - } - if(after === "prepend") - this.playlist.prepend(item, afterAdd); - else if(after === "append") - this.playlist.append(item, afterAdd); - else - this.playlist.insertAfter(item, after, afterAdd); -} - -Channel.prototype.autoTemp = function(item, user) { - if(isLive(item.media.type)) { - item.temp = true; - } - if(!this.hasPermission(user, "addnontemp")) { - item.temp = true; - } -} - -Channel.prototype.tryQueue = function(user, data) { - if(!this.hasPermission(user, "playlistadd")) { - return; - } - if(typeof data.pos !== "string") { - return; - } - if(typeof data.id !== "string" && data.id !== false) { +/** + * Handles a queue message from a client + */ +Channel.prototype.handleQueue = function (user, data) { + // Verify the user has permission to add + if (!this.hasPermission(user, "playlistadd")) { return; } + // Verify data types + if (typeof data.id !== "string" && data.id !== false) { + return; + } + var id = data.id || false; + + if (typeof data.type !== "string") { + return; + } + var type = data.type; + var link = util.formatLink(id, type); + + // Verify user has the permission to add at the position given if (data.pos === "next" && !this.hasPermission(user, "playlistnext")) { return; } + var pos = data.pos || "end"; + // Verify user has permission to add a YouTube playlist, if relevant + if (data.type === "yp" && !this.hasPermission(user, "playlistaddlist")) { + user.socket.emit("queueFail", { + msg: "You don't have permission to add playlists", + link: link + }); + return; + } + + // Verify the user has permission to add livestreams, if relevant + if (util.isLive(type) && !this.hasPermission(user, "playlistaddlive")) { + user.socket.emit("queueFail", { + msg: "You don't have permission to add livestreams", + link: link + }); + return; + } + + // Verify the user has permission to add a Custom Embed, if relevant + if (data.type === "cu" && !this.hasPermission(user, "playlistaddcustom")) { + user.socket.emit("queueFail", { + msg: "You don't have permission to add custom embeds", + link: null + }); + return; + } + + /** + * Always reset any user-provided title if it's not a custom embed. + * Additionally reset if it is a custom embed but a title is not provided + */ + if (typeof data.title !== "string" || data.type !== "cu") { + data.title = false; + } + var title = data.title || false; + + var queueby = user != null ? user.name : ""; + var temp = data.temp || !this.hasPermission(user, "addnontemp"); + + // Throttle video adds var limit = { burst: 3, sustained: 1 }; - if (user.rank >= 2 || this.leader == user) { + if (user.rank >= 2 || this.leader === user) { limit = { burst: 10, sustained: 2 @@ -1423,257 +1508,227 @@ Channel.prototype.tryQueue = function(user, data) { return; } - if (typeof data.title !== "string" || data.type !== "cu") - data.title = false; - - data.queueby = user ? user.name : ""; - data.temp = !this.hasPermission(user, "addnontemp"); - - if (data.list) { - if (data.pos === "next") { - data.list.reverse(); - if (this.playlist.items.length === 0) - data.list.unshift(data.list.pop()); + // Actually add the video + this.addMedia({ + id: id, + title: title, + pos: pos, + queueby: queueby, + temp: temp, + type: type, + maxlength: this.hasPermission(user, "exceedmaxlength") ? 0 : this.opts.maxlength + }, function (err, media) { + if (err) { + user.socket.emit("queueFail", { + msg: err, + link: link + }); + return; } - var i = 0; - var self = this; - var next = function () { - if (self.dead) - return; - if (i < data.list.length) { - data.list[i].pos = data.pos; - self.tryQueue(user, data.list[i]); - i++; - setTimeout(next, 2000); - } - }; - next(); - } else { - this.addMedia(data, user); - } -} -Channel.prototype.addMedia = function(data, user) { + if (media.restricted) { + user.socket.emit("queueWarn", { + msg: "This video is blocked in the following countries: " + + media.restricted, + link: link + }); + return; + } + }); +}; + +/** + * Add a video to the playlist + */ +Channel.prototype.addMedia = function (data, callback) { var self = this; - if(data.type === "yp" && - !self.hasPermission(user, "playlistaddlist")) { - user.socket.emit("queueFail", { - msg: "You don't have permission to add " + - "playlists", - link: $util.formatLink(data.id, data.type) - }); - return; - } - if(data.type === "cu" && - !self.hasPermission(user, "playlistaddcustom")) { - user.socket.emit("queueFail", { - msg: "You don't have permission to add " + - "custom embeds", - link: null - }); - return; - } - if(isLive(data.type) && - !self.hasPermission(user, "playlistaddlive")) { - user.socket.emit("queueFail", { - msg: "You don't have " + - "permission to add livestreams", - link: $util.formatLink(data.id, data.type) - }); - return; - } - data.temp = data.temp || isLive(data.type); - data.queueby = user ? user.name : ""; - data.maxlength = self.hasPermission(user, "exceedmaxlength") - ? 0 - : this.opts.maxlength; - if (data.pos === "end") - data.pos = "append"; - if (data.type === "cu" && data.title) { + if (data.type === "cu" && typeof data.title === "string") { var t = data.title; - if(t.length > 100) + if (t.length > 100) { t = t.substring(0, 97) + "..."; + } data.title = t; } - var afterData = function (q, c, m) { - if (data.maxlength && m.seconds > data.maxlength) { - user.socket.emit("queueFail", { - msg: "Media is too long!", - link: $util.formatLink(m.id, m.type) - }); - q.release(); + if (data.pos === "end") { + data.pos = "append"; + } + + var afterLookup = function (lock, shouldCache, media) { + if (data.maxlength && media.seconds > data.maxlength) { + callback("Maximum length exceeded: " + data.maxlength + " seconds", null); + lock.release(); return; } - m.pos = data.pos; - m.queueby = data.queueby; - m.temp = data.temp; - var res = self.playlist.addMedia(m); + media.pos = data.pos; + media.queueby = data.queueby; + media.temp = data.temp; + if (data.title && media.type === "cu") { + media.title = data.title; + } + + var res = self.playlist.addMedia(media); if (res.error) { - user.socket.emit("queueFail", { - msg: res.error, - link: $util.formatLink(m.id, m.type) - }); - q.release(); + callback(res.error, null); + lock.release(); return; } - if (m.restricted) { - user.socket.emit("queueWarn", { - msg: "This video is blocked in the following countries: " + - m.restricted, - link: $util.formatLink(m.id, m.type) - }); - } + self.logger.log("### " + data.queueby + " queued " + media.title); var item = res.item; - self.logger.log("### " + user.name + " queued " + - item.media.title); - self.sendAll("queue", { + var packet = { item: item.pack(), after: item.prev ? item.prev.uid : "prepend" + }; + self.users.forEach(function (u) { + u.socket.emit("queue", packet); }); - self.broadcastPlaylistMeta(); - if (!c && !item.temp) - self.cacheMedia(item.media); - q.release(); + + self.updatePlaylistMeta(); + self.sendPlaylistMeta(self.users); + + if (shouldCache) { + self.cacheMedia(media); + } + + lock.release(); + callback(null, media); }; - // Pre-cached data (e.g. from a playlist) - if (typeof data.title === "string" && data.type !== "cu") { - self.plqueue.queue(function (q) { + // Cached video data + if (data.type !== "cu" && typeof data.title === "string") { + self.plqueue.queue(function (lock) { var m = new Media(data.id, data.title, data.seconds, data.type); - afterData.bind(self, q, false)(m); + afterLookup(lock, false, m); }); return; } - // special case for youtube playlists + // YouTube playlists if (data.type === "yp") { - self.plqueue.queue(function (q) { - if (self.dead) - return; - InfoGetter.getMedia(data.id, data.type, - function (e, vids) { + self.plqueue.queue(function (lock) { + InfoGetter.getMedia(data.id, data.type, function (e, vids) { if (e) { - user.socket.emit("queueFail", { - msg: e, - link: $util.formatLink(data.id, data.type) - }); - q.release(); + callback(e, null); + lock.release(); return; } + // If queueing next, reverse queue order so the videos end up + // in the correct order if (data.pos === "next") { vids.reverse(); - if (self.playlist.length === 0) + // Special case to ensure correct playlist order + if (self.playlist.length === 0) { vids.unshift(vids.pop()); + } } - var fake = { release: function () { } }; - var cb = afterData.bind(self, fake, false); + // We only want to release the lock after the entire playlist + // is processed. Set up a dummy so the same code will work. + var dummy = { + release: function () { } + }; + for (var i = 0; i < vids.length; i++) { - cb(vids[i]); + afterLookup(dummy, true, vids[i]); } - q.release(); + + lock.release(); }); }); return; } - // Don't check library for livestreams or if the channel is - // unregistered - if (!self.registered || isLive(data.type)) { - self.plqueue.queue(function (q) { - if (self.dead) - return; - var cb = afterData.bind(self, q, false); - InfoGetter.getMedia(data.id, data.type, - function (e, m) { - if (self.dead) - return; + // Cases where there is no cached data in the database + if (!self.registered || util.isLive(data.type)) { + self.plqueue.queue(function (lock) { + InfoGetter.getMedia(data.id, data.type, function (e, media) { if (e) { - user.socket.emit("queueFail", { - msg: e, - link: $util.formatLink(data.id, data.type) - }); - q.release(); + callback(e, null); + lock.release(); return; } - cb(m); - }); - }); - } else { - self.plqueue.queue(function (q) { - if (self.dead) - return; - db.channels.getLibraryItem(self.name, data.id, - function (err, item) { - if (self.dead) - return; - - if (err && err !== "Item not in library") { - user.socket.emit("queueFail", { - msg: "Internal error: " + err, - link: $util.formatLink(data.id, data.type) - }); - return; - } - - if (item !== null) { - if (data.maxlength && item.seconds > data.maxlength) { - user.socket.emit("queueFail", { - msg: "Media is too long!", - link: $util.formatLink(item.id, item.type) - }); - return; - } - - afterData.bind(self, q, true)(item); - } else { - InfoGetter.getMedia(data.id, data.type, - function (e, m) { - if (self.dead) - return; - if (e) { - user.socket.emit("queueFail", { - msg: e, - link: $util.formatLink(data.id, data.type) - }); - q.release(); - return; - } - - afterData.bind(self, q, false)(m); - }); - } + afterLookup(lock, false, media); }); }); + return; } + + // Finally, the "normal" case + self.plqueue.queue(function (lock) { + if (self.dead) { + return; + } + + var lookupNewMedia = function () { + InfoGetter.getMedia(data.id, data.type, function (e, media) { + if (self.dead) { + return; + } + + if (e) { + callback(e, null); + lock.release(); + return; + } + + afterLookup(lock, true, media); + }); + }; + + db.channels.getLibraryItem(self.name, data.id, function (err, item) { + if (self.dead) { + return; + } + + if (err && err !== "Item not in library") { + user.socket.emit("queueFail", { + msg: "Internal error: " + err, + link: util.formatLink(data.id, data.type) + }); + lock.release(); + return; + } + + if (item !== null) { + afterLookup(lock, true, item); + } else { + lookupNewMedia(); + } + }); + }); }; -Channel.prototype.tryQueuePlaylist = function(user, data) { +/** + * Handles a user queueing a user playlist + */ +Channel.prototype.handleQueuePlaylist = function (user, data) { var self = this; if (!self.hasPermission(user, "playlistaddlist")) { return; } - if(typeof data.name !== "string" || - typeof data.pos !== "string") { + if (typeof data.name !== "string") { return; } + var name = data.name; - if (data.pos == "next" && !this.hasPermission(user, "playlistnext")) { + if (data.pos === "next" && !self.hasPermission(user, "playlistaddnext")) { return; } + var pos = data.pos || "end"; - self.server.db.getUserPlaylist(user.name, data.name, - function (err, pl) { - if (self.dead) + var temp = data.temp || !self.hasPermission(user, "addnontemp"); + + db.getUserPlaylist(user.name, name, function (err, pl) { + if (self.dead) { return; + } if (err) { user.socket.emit("errorMsg", { @@ -1683,499 +1738,668 @@ Channel.prototype.tryQueuePlaylist = function(user, data) { } try { - if (data.pos === "next") { + // Ensure correct order when queueing next + if (pos === "next") { pl.reverse(); - if (pl.length > 0 && self.playlist.items.length === 0) + if (pl.length > 0 && self.playlist.items.length === 0) { pl.unshift(pl.pop()); + } } for (var i = 0; i < pl.length; i++) { - pl[i].pos = data.pos; - pl[i].temp = !self.hasPermission(user, "addnontemp"); - self.addMedia(pl[i], user); + pl[i].pos = pos; + pl[i].temp = temp; + self.addMedia(pl[i], function (err, media) { + if (err) { + user.socket.emit("queueFail", { + msg: err, + link: util.formatLink(pl[i].id, pl[i].type) + }); + } + }); } } catch (e) { Logger.errlog.log("Loading user playlist failed!"); - Logger.errlog.log("PL: " + user.name + "-" + data.name); + Logger.errlog.log("PL: " + user.name + "-" + name); Logger.errlog.log(e.stack); + user.socket.emit("queueFail", { + msg: "Internal error occurred when loading playlist. The administrator has been notified.", + link: null + }); } }); -} +}; -Channel.prototype.setTemp = function(uid, temp) { - var item = this.playlist.items.find(uid); - if(!item) +/** + * Handles a user message to delete a playlist item + */ +Channel.prototype.handleDelete = function (user, data) { + var self = this; + + if (!self.hasPermission(user, "playlistdelete")) { return; + } + + if (typeof data !== "number") { + return; + } + + var plitem = self.playlist.items.find(data); + + self.deleteMedia(data, function (err) { + if (!err && plitem && plitem.media) { + self.logger.log("### " + user.name + " deleted " + plitem.media.title); + } + }); +}; + +/** + * Deletes a playlist item + */ +Channel.prototype.deleteMedia = function (uid, callback) { + var self = this; + self.plqueue.queue(function (lock) { + if (self.dead) { + return; + } + + if (self.playlist.remove(uid)) { + self.sendAll("delete", { + uid: uid + }); + self.updatePlaylistMeta(); + self.sendPlaylistMeta(self.users); + callback(null); + } else { + callback("Delete failed"); + } + + lock.release(); + }); +}; + +/** + * Sets the temporary status of a playlist item + */ +Channel.prototype.setTemp = function (uid, temp) { + var item = this.playlist.items.find(uid); + if (item == null) { + return; + } + item.temp = temp; this.sendAll("setTemp", { uid: uid, temp: temp }); - if(!temp) { + // TODO might change the way this works + if (!temp) { this.cacheMedia(item.media); } -} +}; -Channel.prototype.trySetTemp = function(user, data) { - if(!this.hasPermission(user, "settemp")) { +/** + * Handles a user message to set a playlist item as temporary/not + */ +Channel.prototype.handleSetTemp = function (user, data) { + if (!this.hasPermission(user, "settemp")) { return; } - if(typeof data.uid != "number" || typeof data.temp != "boolean") { + + if (typeof data.uid !== "number" || typeof data.temp !== "boolean") { return; } this.setTemp(data.uid, data.temp); -} + // TODO log? +}; - -Channel.prototype.dequeue = function(uid) { +/** + * Moves a playlist item in the playlist + */ +Channel.prototype.move = function (from, after, callback) { + callback = typeof callback === "function" ? callback : function () { }; var self = this; - self.plqueue.queue(function (q) { - if (self.dead) - return; - if (self.playlist.remove(uid)) { - self.sendAll("delete", { - uid: uid - }); - self.broadcastPlaylistMeta(); + if (from === after) { + callback("Cannot move playlist item after itself!", null); + return; + } + + self.plqueue.queue(function (lock) { + if (self.dead) { + return; } - q.release(); + if (self.playlist.move(from, after)) { + self.sendAll("moveVideo", { + from: from, + after: after + }); + callback(null, true); + } else { + callback(true, null); + } + + lock.release(); }); -} +}; -Channel.prototype.tryDequeue = function(user, data) { - if(!this.hasPermission(user, "playlistdelete")) - return; - - if(typeof data !== "number") { - return; - } - - var plitem = this.playlist.items.find(data); - if(plitem && plitem.media) - this.logger.log("### " + user.name + " deleted " + plitem.media.title); - this.dequeue(data); -} - -Channel.prototype.tryUncache = function(user, data) { +/** + * Handles a user message to move a playlist item + */ +Channel.prototype.handleMove = function (user, data) { var self = this; - if(user.rank < 2) { + + if (!self.hasPermission(user, "playlistmove")) { return; } - if(typeof data.id != "string") { + + if (typeof data.from !== "number" || (typeof data.after !== "number" && typeof data.after !== "string")) { return; } - if (!self.registered) - return; - db.channels.deleteFromLibrary(self.name, data.id, - function (err, res) { - if (self.dead) - return; - if(err) - return; - - self.logger.log("*** " + user.name + " deleted " + data.id + - " from library"); + self.move(data.from, data.after, function (err) { + if (!err) { + var fromit = self.playlist.items.find(data.from); + var afterit = self.playlist.items.find(data.after); + var aftertitle = (afterit && afterit.media) ? afterit.media.title : ""; + if (fromit) { + self.logger.log("### " + user.name + " moved " + fromit.media.title + + (aftertitle ? " after " + aftertitle : "")); + } + } }); -} +}; -Channel.prototype.playNext = function() { - this.playlist.next(); -} - -Channel.prototype.tryPlayNext = function(user) { - if(!this.hasPermission(user, "playlistjump")) { - return; +/** + * Handles a user message to remove a video from the library + */ +Channel.prototype.handleUncache = function (user, data) { + var self = this; + if (!self.registered) { + return; } - this.logger.log("### " + user.name + " skipped the video"); - - this.playNext(); -} - -Channel.prototype.jumpTo = function(uid) { - return this.playlist.jump(uid); -} - -Channel.prototype.tryJumpTo = function(user, data) { - if(!this.hasPermission(user, "playlistjump")) { - return; + if (user.rank < 2) { + return; } - if(typeof data !== "number") { + if (typeof data.id !== "string") { + return; + } + + db.channels.deleteFromLibrary(self.name, data.id, function (err, res) { + if (self.dead) { + return; + } + + if (err) { + return; + } + + self.logger.log("*** " + user.name + " deleted " + data.id + " from library"); + }); +}; + +/** + * Handles a user message to skip to the next video in the playlist + */ +Channel.prototype.handlePlayNext = function (user) { + if (!this.hasPermission(user, "playlistjump")) { return; } this.logger.log("### " + user.name + " skipped the video"); + this.playlist.next(); +}; - this.jumpTo(data); -} +/** + * Handles a user message to jump to a video in the playlist + */ +Channel.prototype.handleJumpTo = function (user, data) { + if (!this.hasPermission(user, "playlistjump")) { + return; + } -Channel.prototype.clearqueue = function() { + if (typeof data !== "string" && typeof data !== "number") { + return; + } + + this.logger.log("### " + user.name + " skipped the video"); + this.playlist.jump(data); +}; + +/** + * Clears the playlist + */ +Channel.prototype.clear = function () { this.playlist.clear(); this.plqueue.reset(); - this.sendAll("playlist", this.playlist.items.toArray()); - this.broadcastPlaylistMeta(); -} + this.updatePlaylistMeta(); + this.sendPlaylist(this.users); +}; -Channel.prototype.tryClearqueue = function(user) { - if(!this.hasPermission(user, "playlistclear")) { +/** + * Handles a user message to clear the playlist + */ +Channel.prototype.handleClear = function (user) { + if (!this.hasPermission(user, "playlistclear")) { return; } this.logger.log("### " + user.name + " cleared the playlist"); - this.clearqueue(); -} + this.clear(); +}; -Channel.prototype.shufflequeue = function() { - var n = []; +/** + * Shuffles the playlist + */ +Channel.prototype.shuffle = function () { var pl = this.playlist.items.toArray(false); this.playlist.clear(); this.plqueue.reset(); - while(pl.length > 0) { - var i = parseInt(Math.random() * pl.length); + while (pl.length > 0) { + var i = Math.floor(Math.random() * pl.length); var item = this.playlist.makeItem(pl[i].media); item.temp = pl[i].temp; item.queueby = pl[i].queueby; this.playlist.items.append(item); pl.splice(i, 1); } + this.playlist.current = this.playlist.items.first; - this.sendAll("playlist", this.playlist.items.toArray()); - this.sendAll("setPlaylistMeta", this.plmeta); + this.sendPlaylist(this.users); this.playlist.startPlayback(); -} +}; -Channel.prototype.tryShufflequeue = function(user) { - if(!this.hasPermission(user, "playlistshuffle")) { - return; - } - this.logger.log("### " + user.name + " shuffled the playlist"); - this.shufflequeue(); -} - -Channel.prototype.tryUpdate = function(user, data) { - if(this.leader != user) { - user.kick("Received mediaUpdate from non-leader"); +/** + * Handles a user message to shuffle the playlist + */ +Channel.prototype.handleShuffle = function (user) { + if (!this.hasPermission(user, "playlistshuffle")) { return; } - if(typeof data.id !== "string" || typeof data.currentTime !== "number") { + this.logger.log("### " + user.name + " shuffle the playlist"); + this.shuffle(); +}; + +/** + * Handles a video update from a leader + */ +Channel.prototype.handleUpdate = function (user, data) { + if (this.leader !== user) { return; } - if(this.playlist.current === null) { + if (typeof data.id !== "string" || typeof data.currentTime !== "number") { return; } - if(isLive(this.playlist.current.media.type) - && this.playlist.current.media.type != "jw") { + if (this.playlist.current === null) { return; } - if (this.playlist.current.media.id !== data.id || - isNaN(+data.currentTime)) { + var media = this.playlist.current.media; + + if (util.isLive(media.type) && media.type !== "jw") { return; } - this.playlist.current.media.currentTime = +data.currentTime; - this.playlist.current.media.paused = Boolean(data.paused); - this.sendAll("mediaUpdate", this.playlist.current.media.timeupdate()); -} - -Channel.prototype.move = function(data, user) { - var self = this; - self.plqueue.queue(function (q) { - if (self.dead) - return; - - if (self.playlist.move(data.from, data.after)) { - var fromit = self.playlist.items.find(data.from); - var afterit = self.playlist.items.find(data.after); - var aftertitle = (afterit && afterit.media) - ? afterit.media.title : ""; - if (fromit) { - self.logger.log("### " + user.name + " moved " + - fromit.media.title + - (aftertitle ? " after " + aftertitle : "")); - } - - self.sendAll("moveVideo", { - from: data.from, - after: data.after, - }); - } - - q.release(); - }); -} - -Channel.prototype.tryMove = function(user, data) { - if(!this.hasPermission(user, "playlistmove")) { + if (media.id !== data.id || isNaN(data.currentTime)) { return; } - if(typeof data.from !== "number" || (typeof data.after !== "number" && typeof data.after !== "string")) { + media.currentTime = data.currentTime; + media.paused = Boolean(data.paused); + this.sendAll("mediaUpdate", media.timeupdate()); +}; + +/** + * Handles a user message to open a poll + */ +Channel.prototype.handleOpenPoll = function (user, data) { + if (!this.hasPermission(user, "pollctl")) { return; } - this.move(data, user); -} - -/* REGION Polls */ - -Channel.prototype.tryOpenPoll = function(user, data) { - if(!this.hasPermission(user, "pollctl") && this.leader != user) { - return; - } - - if(typeof data.title !== "string" || !(data.opts instanceof Array)) { + if (typeof data.title !== "string" || !(data.opts instanceof Array)) { return; } + var title = data.title.substring(0, 255); + var opts = []; for (var i = 0; i < data.opts.length; i++) { - data.opts[i] = ""+data.opts[i]; + opts[i] = (""+data.opts[i]).substring(0, 255); } var obscured = (data.obscured === true); - var poll = new Poll(user.name, data.title, data.opts, obscured); + var poll = new Poll(user.name, title, opts, obscured); this.poll = poll; - this.broadcastPoll(); + this.sendPoll(this.users, true); this.logger.log("*** " + user.name + " Opened Poll: '" + poll.title + "'"); -} +}; -Channel.prototype.tryClosePoll = function(user) { - if(!this.hasPermission(user, "pollctl")) { +/** + * Handles a user message to close the active poll + */ +Channel.prototype.handleClosePoll = function (user) { + if (!this.hasPermission(user, "pollctl")) { return; } - if(this.poll) { + if (this.poll) { if (this.poll.obscured) { this.poll.obscured = false; - this.broadcastPollUpdate(); + this.sendPollUpdate(this.users); } + this.logger.log("*** " + user.name + " closed the active poll"); this.poll = false; - this.broadcastPollClose(); + this.sendAll("closePoll"); } -} +}; -Channel.prototype.tryVote = function(user, data) { - - if(!this.hasPermission(user, "pollvote")) { - return; - } - if(typeof data.option !== "number") { +/** + * Handles a user message to vote in a poll + */ +Channel.prototype.handlePollVote = function (user, data) { + if (!this.hasPermission(user, "pollvote")) { return; } - if(this.poll) { + if (typeof data.option !== "number") { + return; + } + + if (this.poll) { this.poll.vote(user.ip, data.option); - this.broadcastPollUpdate(); + this.sendPollUpdate(this.users); } -} +}; -Channel.prototype.tryVoteskip = function(user) { - if(!this.opts.allow_voteskip) { +/** + * Handles a user message to voteskip the current video + */ +Channel.prototype.handleVoteskip = function (user) { + if (!this.opts.allow_voteskip) { return; } - if(!this.hasPermission(user, "voteskip")) + if (!this.hasPermission(user, "voteskip")) { return; - // Voteskip = auto-unafk + } + user.setAFK(false); user.autoAFK(); - if(!this.voteskip) { + if (!this.voteskip) { this.voteskip = new Poll("voteskip", "voteskip", ["yes"]); } this.voteskip.vote(user.ip, 0); - this.logger.log("### " + user.name + " voteskipped"); + this.logger.log("### " + (user.name ? user.name : "anonymous") + " voteskipped"); this.checkVoteskipPass(); -} +}; +/** + * Checks if the voteskip requirement is met + */ Channel.prototype.checkVoteskipPass = function () { - if(!this.opts.allow_voteskip) + if (!this.opts.allow_voteskip) { return false; + } - if(!this.voteskip) + if (!this.voteskip) { return false; + } - var count = this.calcVoteskipMax(); - var need = Math.ceil(count * this.opts.voteskip_ratio); - if(this.voteskip.counts[0] >= need) - this.playNext(); + if (this.playlist.length === 0) { + return false; + } - this.broadcastVoteskipUpdate(); + var max = this.calcVoteskipMax(); + var need = Math.ceil(max * this.opts.voteskip_ratio); + if (this.voteskip.counts[0] >= need) { + this.logger.log("### Voteskip passed, skipping to next video"); + this.playlist.next(); + } + + this.sendVoteskipUpdate(this.users); return true; -} +}; +/** + * Sets the locked state of the playlist + */ +Channel.prototype.setLock = function (locked) { + this.playlistLock = locked; + this.sendPlaylistLock(this.users); +}; -/* REGION Channel Option stuff */ - -Channel.prototype.setLock = function(locked) { - this.openqueue = !locked; - this.sendAll("setPlaylistLocked", {locked: locked}); -} - -Channel.prototype.trySetLock = function(user, data) { - if(user.rank < 2) { +/** + * Handles a user message to change the locked state of the playlist + */ +Channel.prototype.handleSetLock = function (user, data) { + if (!this.hasPermission(user, "playlistlock")) { return; } + data.locked = Boolean(data.locked); this.logger.log("*** " + user.name + " set playlist lock to " + data.locked); this.setLock(data.locked); -} +}; -Channel.prototype.tryToggleLock = function(user) { - if(user.rank < 2) { +/** + * Handles a user message to toggle the locked state of the playlist + */ +Channel.prototype.handleToggleLock = function (user) { + this.handleSetLock(user, { locked: !this.playlistLock }); +}; + +/** + * Imports a list of chat filters, replacing the current list + */ +Channel.prototype.importFilters = function (filters) { + this.filters = filters; + this.sendChatFilters(this.users); +}; + +/** + * Handles a user message to import a list of chat filters + */ +Channel.prototype.handleImportFilters = function (user, data) { + if (!this.hasPermission(user, "filterimport")) { return; } - this.logger.log("*** " + user.name + " set playlist lock to " + this.openqueue); - this.setLock(this.openqueue); -} + if (!(data instanceof Array)) { + return; + } -Channel.prototype.tryRemoveFilter = function(user, f) { - if(!this.hasPermission(user, "filteredit")) { + this.filters = data.map(this.validateChatFilter.bind(this)) + .filter(function (f) { return f !== false; }); + + this.sendChatFilters(this.users); +}; + +/** + * Validates data for a chat filter + */ +Channel.prototype.validateChatFilter = function (f) { + if (typeof f.source !== "string" || typeof f.flags !== "string" || + typeof f.replace !== "string") { + return false; + } + + if (typeof f.name !== "string") { + f.name = f.source; + } + + f.replace = f.replace.substring(0, 1000); + f.replace = XSS.sanitizeHTML(f.replace); + f.flags = f.flags.substring(0, 4); + + try { + new RegExp(f.source, f.flags); + } catch (e) { + return false; + } + + var filter = new Filter(f.name, f.source, f.flags, f.replace); + filter.active = Boolean(f.active); + filter.filterlinks = Boolean(f.filterlinks); + return filter; +}; + +/** + * Updates a chat filter, or adds a new one if the filter does not exist + */ +Channel.prototype.updateFilter = function (filter) { + var self = this; + + if (!filter.name) { + filter.name = filter.source; + } + + var found = false; + for (var i = 0; i < self.filters.length; i++) { + if (self.filters[i].name === filter.name) { + found = true; + self.filters[i] = filter; + break; + } + } + + if (!found) { + self.filters.push(filter); + } + + self.users.forEach(function (u) { + if (self.hasPermission(u, "filteredit")) { + u.socket.emit("updateChatFilter", filter); + } + }); +}; + +/** + * Handles a user message to update a filter + */ +Channel.prototype.handleUpdateFilter = function (user, f) { + if (!this.hasPermission(user, "filteredit")) { + user.kick("Attempted updateFilter with insufficient permission"); + return; + } + + filter = this.validateChatFilter(f); + if (!filter) { + return; + } + + this.logger.log("%%% " + user.name + " updated filter: " + f.name); + this.updateFilter(filter); +}; + +/** + * Removes a chat filter + */ +Channel.prototype.removeFilter = function (filter) { + var self = this; + + for (var i = 0; i < self.filters.length; i++) { + if (self.filters[i].name === filter.name) { + self.filters.splice(i, 1); + self.users.forEach(function (u) { + if (self.hasPermission(u, "filteredit")) { + u.socket.emit("deleteChatFilter", filter); + } + }); + break; + } + } +}; + +/** + * Handles a user message to delete a chat filter + */ +Channel.prototype.handleRemoveFilter = function (user, f) { + if (!this.hasPermission(user, "filteredit")) { user.kick("Attempted removeFilter with insufficient permission"); return; } - // Don't care about the other parameters since we're just removing if (typeof f.name !== "string") { return; } this.logger.log("%%% " + user.name + " removed filter: " + f.name); this.removeFilter(f); -} +}; -Channel.prototype.removeFilter = function(filter) { - for(var i = 0; i < this.filters.length; i++) { - if(this.filters[i].name == filter.name) { - this.filters.splice(i, 1); - break; - } - } - this.broadcastChatFilters(); -} - -Channel.prototype.updateFilter = function(filter, emit) { - if(filter.name == "") - filter.name = filter.source; - var found = false; - for(var i = 0; i < this.filters.length; i++) { - if(this.filters[i].name == filter.name) { - found = true; - this.filters[i] = filter; - break; - } - } - if(!found) { - this.filters.push(filter); - } - if(emit !== false) - this.broadcastChatFilters(); -} - -Channel.prototype.tryUpdateFilter = function(user, f) { - if(!this.hasPermission(user, "filteredit")) { - user.kick("Attempted updateFilter with insufficient permission"); +/** + * Changes the order of chat filters + */ +Channel.prototype.moveFilter = function (from, to) { + if (from < 0 || to < 0 || from >= this.filters.length || to >= this.filters.length) { return; } - if (typeof f.source !== "string" || typeof f.flags !== "string" || - typeof f.replace !== "string") { - return; - } + var f = this.filters[from]; + to = to > from ? to + 1 : to; + from = to > from ? from : from + 1; - if (f.replace.length > 1000) { - f.replace = f.replace.substring(0, 1000); - } - - if (f.flags.length > 4) { - f.flags = f.flags.substring(0, 4); - } - - var re = f.source; - var flags = f.flags; - // Temporary fix - // 2013-09-12 Temporary my ass - f.replace = f.replace.replace(/style/g, "stlye"); - f.replace = sanitize(f.replace).xss(); - f.replace = f.replace.replace(/stlye/g, "style"); - try { - new RegExp(re, flags); - } - catch(e) { - return; - } - var filter = new Filter(f.name, f.source, f.flags, f.replace); - filter.active = !!f.active; - filter.filterlinks = !!f.filterlinks; - this.logger.log("%%% " + user.name + " updated filter: " + f.name); - this.updateFilter(filter); -} - -Channel.prototype.moveFilter = function(data) { - if(data.from < 0 || data.to < 0 || data.from >= this.filters.length || - data.to > this.filters.length) { - return; - } - var f = this.filters[data.from]; - var to = data.to > data.from ? data.to + 1 : data.to; - var from = data.to > data.from ? data.from : data.from + 1; this.filters.splice(to, 0, f); this.filters.splice(from, 1); - this.broadcastChatFilters(); -} + // TODO broadcast +}; -Channel.prototype.tryMoveFilter = function(user, data) { - if(!this.hasPermission(user, "filteredit")) { +/** + * Handles a user message to change the chat filter order + */ +Channel.prototype.handleMoveFilter = function (user, data) { + if (!this.hasPermission(user, "filteredit")) { user.kick("Attempted moveFilter with insufficient permission"); return; } - if(typeof data.to !== "number" || typeof data.from !== "number") { + if (typeof data.to !== "number" || typeof data.from !== "number") { return; } - this.moveFilter(data); -} -Channel.prototype.tryUpdatePermissions = function(user, perms) { - if(user.rank < 3) { - user.kick("Attempted setPermissions with insufficient permission"); + this.moveFilter(data.from, data.to); +}; + +/** + * Handles a user message to change the channel permissions + */ +Channel.prototype.handleSetPermissions = function (user, perms) { + if (user.rank < 3) { + user.kick("Attempted setPermissions as a non-admin"); return; } - for(var key in perms) { - this.permissions[key] = perms[key]; + + for (var key in perms) { + if (key in this.permissions) { + this.permissions[key] = perms[key]; + } } + this.logger.log("%%% " + user.name + " updated permissions"); this.sendAll("setPermissions", this.permissions); -} +}; -Channel.prototype.tryUpdateOptions = function(user, data) { - if(user.rank < 2) { - user.kick("Attempted setOptions with insufficient permission"); +/** + * Handles a user message to change the channel settings + */ +Channel.prototype.handleUpdateOptions = function (user, data) { + if (user.rank < 2) { + user.kick("Attempted setOptions as a non-moderator"); return; } - const adminonly = { - pagetitle: true, - externalcss: true, - externaljs: true, - show_public: true, - password: true - }; - if ("allow_voteskip" in data) { - var vs = Boolean(data.allow_voteskip); - this.opts.voteskip = vs; + this.opts.voteskip = Boolean(data.allow_voteskip); } if ("voteskip_ratio" in data) { @@ -2191,7 +2415,8 @@ Channel.prototype.tryUpdateOptions = function(user, data) { if (isNaN(tm) || tm < 0) { tm = 0; } - var same = tm == this.opts.afk_timeout; + + var same = tm === this.opts.afk_timeout; this.opts.afk_timeout = tm; if (!same) { this.users.forEach(function (u) { @@ -2201,7 +2426,7 @@ Channel.prototype.tryUpdateOptions = function(user, data) { } if ("pagetitle" in data && user.rank >= 3) { - this.opts.pagetitle = ""+data.pagetitle; + this.opts.pagetitle = (""+data.pagetitle).substring(0, 100); } if ("maxlength" in data) { @@ -2213,11 +2438,11 @@ Channel.prototype.tryUpdateOptions = function(user, data) { } if ("externalcss" in data && user.rank >= 3) { - this.opts.externalcss = ""+data.externalcss; + this.opts.externalcss = (""+data.externalcss).substring(0, 255); } if ("externaljs" in data && user.rank >= 3) { - this.opts.externaljs = ""+data.externaljs; + this.opts.externaljs = (""+data.externaljs).substring(0, 255); } if ("chat_antiflood" in data) { @@ -2231,16 +2456,23 @@ Channel.prototype.tryUpdateOptions = function(user, data) { sustained: 1 }; } + var b = parseInt(data.chat_antiflood_params.burst); - if (isNaN(b) || b < 0) + if (isNaN(b) || b < 0) { b = 1; - var s = parseFloat(data.chat_antiflood_params.sustained); - if (isNaN(s) || s <= 0) + } + + var s = parseInt(data.chat_antiflood_params.sustained); + if (isNaN(s) || s <= 0) { s = 1; + } + var c = b / s; - this.opts.chat_antiflood_params.burst = b; - this.opts.chat_antiflood_params.sustained = s; - this.opts.chat_antiflood_params.cooldown = c; + this.opts.chat_antiflood_params = { + burst: b, + sustained: s, + cooldown: c + }; } if ("show_public" in data && user.rank >= 3) { @@ -2252,73 +2484,73 @@ Channel.prototype.tryUpdateOptions = function(user, data) { } if ("password" in data && user.rank >= 3) { - var pw = data.password+""; - pw = pw === "" ? false : pw; + var pw = data.password + ""; + pw = pw === "" ? false : pw.substring(0, 100); this.opts.password = pw; } this.logger.log("%%% " + user.name + " updated channel options"); - this.broadcastOpts(); -} + this.sendOpts(this.users); +}; -Channel.prototype.trySetCSS = function(user, data) { - if(user.rank < 3) { - user.kick("Attempted setChannelCSS with insufficient permission"); +/** + * Handles a user message to set the inline channel CSS + */ +Channel.prototype.handleSetCSS = function (user, data) { + if (user.rank < 3) { + user.kick("Attempted setChannelCSS as non-admin"); return; } if (typeof data.css !== "string") { return; } - var css = data.css || ""; - if(css.length > 20000) { - css = css.substring(0, 20000); - } - this.css = css; - this.sendAll("channelCSSJS", { - css: this.css, - js: this.js - }); - this.logger.log("%%% " + user.name + " set channel CSS"); -} + var css = data.css.substring(0, 20000); -Channel.prototype.trySetJS = function(user, data) { - if(user.rank < 3) { - user.kick("Attempted setChannelJS with insufficient permission"); + this.css = css; + this.sendCSSJS(this.users); + + this.logger.log("%%% " + user.name + " updated the channel CSS"); +}; + +/** + * Handles a user message to set the inline channel CSS + */ +Channel.prototype.handleSetJS = function (user, data) { + if (user.rank < 3) { + user.kick("Attempted setChannelJS as non-admin"); return; } + if (typeof data.js !== "string") { return; } + var js = data.js.substring(0, 20000); - var js = data.js || ""; - if(js.length > 20000) { - js = js.substring(0, 20000); - } this.js = js; - this.sendAll("channelCSSJS", { - css: this.css, - js: this.js - }); - this.logger.log("%%% " + user.name + " set channel JS"); -} + this.sendCSSJS(this.users); -Channel.prototype.updateMotd = function(motd) { + this.logger.log("%%% " + user.name + " updated the channel JS"); +}; + +/** + * Sets the MOTD + */ +Channel.prototype.setMotd = function (motd) { + motd = XSS.sanitizeHTML(motd); var html = motd.replace(/\n/g, "
"); - // Temporary fix - html = html.replace(/style/g, "stlye"); - html = sanitize(html).xss(); - html = html.replace(/stlye/g, "style"); - //html = this.filterMessage(html); this.motd = { motd: motd, html: html }; - this.broadcastMotd(); -} + this.sendMotd(this.users); +}; -Channel.prototype.tryUpdateMotd = function(user, data) { - if(!this.hasPermission(user, "motdedit")) { +/** + * Handles a user message to update the MOTD + */ +Channel.prototype.handleSetMotd = function (user, data) { + if (!this.hasPermission(user, "motdedit")) { user.kick("Attempted setMotd with insufficient permission"); return; } @@ -2326,254 +2558,405 @@ Channel.prototype.tryUpdateMotd = function(user, data) { if (typeof data.motd !== "string") { return; } + var motd = data.motd.substring(0, 20000); - if (data.motd.length > 20000) { - data.motd = data.motd.substring(0, 20000); + this.setMotd(motd); + this.logger.log("%%% " + user.name + " updated the MOTD"); +}; + +/** + * Handles a user chat message + */ +Channel.prototype.handleChat = function (user, data) { + if (!this.hasPermission(user, "chat")) { + return; } - this.updateMotd(data.motd); - this.logger.log("%%% " + user.name + " set the MOTD"); -} - -/* REGION Chat */ - -Channel.prototype.tryChat = function(user, data) { - if (data.meta === undefined) { + if (typeof data.meta !== "object") { data.meta = {}; } - if(user.name == "") { + if (!user.name) { return; } - if(!this.hasPermission(user, "chat")) - return; - - if (this.mutedUsers.contains(user.name.toLowerCase())) { - user.socket.emit("noflood", { - action: "chat", - msg: "You have been muted on this channel." - }); + if (typeof data.msg !== "string") { return; } + var msg = data.msg.substring(0, 240); - if(typeof data.msg !== "string") { - return; - } + var muted = this.isMuted(user.name); + var smuted = this.isShadowMuted(user.name); - // Validate meta var meta = {}; if (user.rank >= 2) { if ("modflair" in data.meta && data.meta.modflair === user.rank) { meta.modflair = data.meta.modflair; } } - data.meta = meta; - - var msg = data.msg; - if(msg.length > 240) { - msg = msg.substring(0, 240); - } if (user.rank < 2 && this.opts.chat_antiflood && user.chatLimiter.throttle(this.opts.chat_antiflood_params)) { - user.socket.emit("chatCooldown", 1000/this.opts.chat_antiflood_params.sustained); - return; + user.socket.emit("chatCooldown", 1000 / this.opts.chat_antiflood_params.sustained); } - if (this.mutedUsers.contains("[shadow]" + user.name.toLowerCase())) { - msg = sanitize(msg).escape(); + if (smuted) { + msg = XSS.sanitizeText(msg); msg = this.filterMessage(msg); var msgobj = { username: user.name, msg: msg, - meta: data.meta, + meta: meta, time: Date.now() }; - user.socket.emit("chatMsg", msgobj); + this.shadowMutedUsers().forEach(function (u) { + u.socket.emit("chatMsg", msgobj); + }); return; } if (msg.indexOf("/") === 0) { - ChatCommand.handle(this, user, msg, data.meta); + if (!ChatCommand.handle(this, user, msg, meta)) { + this.sendMessage(user, msg, meta); + } } else { if (msg.indexOf(">") === 0) { - data.meta.addClass = "greentext"; + meta.addClass = "greentext"; } - this.sendMessage(user, msg, data.meta); + this.sendMessage(user, msg, meta); } -} +}; -Channel.prototype.filterMessage = function(msg) { +/** + * Filters a chat message + */ +Channel.prototype.filterMessage = function (msg) { const link = /(\w+:\/\/(?:[^:\/\[\]\s]+|\[[0-9a-f:]+\])(?::\d+)?(?:\/[^\/\s]*)*)/ig; - var subs = msg.split(link); - // Apply other filters - for(var j = 0; j < subs.length; j++) { - if(this.opts.enable_link_regex && subs[j].match(link)) { - var orig = subs[j]; - for(var i = 0; i < this.filters.length; i++) { - if(!this.filters[i].filterlinks || !this.filters[i].active) + var parts = msg.split(link); + + for (var j = 0; j < parts.length; j++) { + // Case 1: The substring is a URL + if (this.opts.enable_link_regex && parts[j].match(link)) { + var original = parts[j]; + // Apply chat filters that are active and filter links + for (var i = 0; i < this.filters.length; i++) { + if (!this.filters[i].filterlinks || !this.filters[i].active) { continue; - subs[j] = this.filters[i].filter(subs[j]); + } + parts[j] = this.filters[i].filter(parts[j]); } - // only apply link filter if another filter hasn't changed - // the link - if (subs[j] === orig) { - subs[j] = url.format(url.parse(subs[j])); - subs[j] = subs[j].replace(link, - "$1"); + // Unchanged, apply link filter + if (parts[j] === original) { + parts[j] = url.format(url.parse(parts[j])); + parts[j] = parts[j].replace(link, "$1"); } + continue; - } - for(var i = 0; i < this.filters.length; i++) { - if(!this.filters[i].active) - continue; - subs[j] = this.filters[i].filter(subs[j]); + } else { + // Substring is not a URL + for (var i = 0; i < this.filters.length; i++) { + if (!this.filters[i].active) { + continue; + } + + parts[j] = this.filters[i].filter(parts[j]); + } } } - return subs.join(""); -} -Channel.prototype.sendMessage = function (user, msg, meta, filter) { - msg = sanitize(msg).escape(); + // Recombine the message + return parts.join(""); +}; + +/** + * Sends a chat message + */ +Channel.prototype.sendMessage = function (user, msg, meta) { + msg = XSS.sanitizeText(msg); msg = this.filterMessage(msg); var msgobj = { username: user.name, msg: msg, meta: meta, time: Date.now() + }; + + this.sendAll("chatMsg", msgobj); + this.chatbuffer.push(msgobj); + if (this.chatbuffer.length > 15) { + this.chatbuffer.shift(); } - if (filter && filter.byRank !== undefined) { - this.sendAllWithRank("chatMsg", msgobj, filter.byRank); - } else { - this.sendAll("chatMsg", msgobj); - this.chatbuffer.push(msgobj); - if(this.chatbuffer.length > 15) - this.chatbuffer.shift(); - var unescaped = sanitize(msg).entityDecode(); - this.logger.log("<" + user.name + (meta.addClass ? "." + meta.addClass : "") - + "> " + unescaped); - } + this.logger.log("<" + user.name + (meta.addClass ? "." + meta.addClass : "") + "> " + + XSS.decodeText(msg)); }; -/* REGION Rank stuff */ - -Channel.prototype.trySetRank = function(user, data) { +/** + * Handles a user message to change another user's rank + */ +Channel.prototype.handleSetRank = function (user, data) { var self = this; - if(user.rank < 2) { - user.kick("Attempted setChannelRank with insufficient permission"); + if (user.rank < 2) { + user.kick("Attempted setChannelRank as a non-moderator"); return; } - if(typeof data.user !== "string" || typeof data.rank !== "number") { + if (typeof data.user !== "string" || typeof data.rank !== "number") { return; } + var name = data.user.substring(0, 20); + var rank = data.rank; - if(data.rank >= user.rank) - return; - - if(data.rank < 1) + if (isNaN(rank) || rank < 1 || rank >= user.rank) { return; + } var receiver; - for(var i = 0; i < self.users.length; i++) { - if(self.users[i].name == data.user) { + var lowerName = name.toLowerCase(); + for (var i = 0; i < self.users.length; i++) { + if (self.users[i].name.toLowerCase() === lowerName) { receiver = self.users[i]; break; } } - if(receiver) { - if(receiver.rank >= user.rank) + var updateDB = function () { + self.getRank(name, function (err, oldrank) { + if (self.dead) { + return; + } + + if (err) { + user.socket.emit("channelRankFail", { + msg: "Updating user rank failed: " + err + }); + return; + } + + if (oldrank >= user.rank) { + user.socket.emit("channelRankFail", { + msg: "Updating user rank failed: " + name + " has equal or higher " + + "rank than you" + }); + return; + } + + db.channels.setRank(self.name, name, rank, function (err, res) { + if (self.dead) { + return; + } + + if (err) { + user.socket.emit("channelRankFail", { + msg: "Updating user rank failed: " + err + }); + return; + } + + self.logger.log("*** " + user.name + " set " + name + "'s rank to " + rank); + self.sendAll("setUserRank", { + name: name, + rank: rank + }); + }); + }); + }; + + if (receiver) { + if (Math.max(receiver.rank, receiver.global_rank) > user.rank) { return; - receiver.rank = data.rank; - if(receiver.loggedIn) { - self.saveRank(receiver, function (err, res) { - if (self.dead) - return; - - self.logger.log("*** " + user.name + " set " + - data.user + "'s rank to " + data.rank); - self.sendAllWithRank(3, "setChannelRank", data); - }); } - self.sendAll("setUserRank", { - name: receiver.name, - rank: receiver.rank - }); - } else if(self.registered) { - self.getRank(data.user, function (err, rrank) { - if (self.dead) - return; - if(err) - return; - if(rrank >= user.rank) - return; - db.channels.setRank(self.name, data.user, - data.rank, function (err, res) { - - if (self.dead) - return; - - self.logger.log("*** " + user.name + " set " + - data.user + "'s rank to " + data.rank); - self.sendAllWithRank(3, "setChannelRank", data); - }); - }); + if (receiver.loggedIn) { + updateDB(); + } + } else if (self.registered) { + updateDB(); } -} +}; -Channel.prototype.changeLeader = function(name) { - if(this.leader != null) { +/** + * Assigns a leader for video playback + */ +Channel.prototype.changeLeader = function (name) { + if (this.leader != null) { var old = this.leader; this.leader = null; - if(old.rank == 1.5) { + if (old.rank === 1.5) { old.rank = old.oldrank; + old.socket.emit("rank", old.rank); + this.sendAll("setUserRank", { + name: old.name, + rank: old.rank + }); } - this.sendAll("setUserRank", { - name: old.name, - rank: old.rank - }); } - if(name == "") { + + if (!name) { this.sendAll("setLeader", ""); this.logger.log("*** Resuming autolead"); this.playlist.lead(true); return; } - for(var i = 0; i < this.users.length; i++) { - if(this.users[i].name == name) { + + for (var i = 0; i < this.users.length; i++) { + if (this.users[i].name === name) { this.sendAll("setLeader", name); this.logger.log("*** Assigned leader: " + name); this.playlist.lead(false); this.leader = this.users[i]; - if(this.users[i].rank < 1.5) { + if (this.users[i].rank < 1.5) { this.users[i].oldrank = this.users[i].rank; this.users[i].rank = 1.5; + this.users[i].socket.emit("rank", 1.5); + this.sendAll("setUserRank", { + name: name, + rank: this.users[i].rank + }); } - this.sendAll("setUserRank", { - name: name, - rank: this.users[i].rank - }); + break; } } -} +}; -Channel.prototype.tryChangeLeader = function(user, data) { - if(user.rank < 2) { +/** + * Handles a user message to assign a new leader + */ +Channel.prototype.handleChangeLeader = function (user, data) { + if (!this.hasPermission(user, "leaderctl")) { user.kick("Attempted assignLeader with insufficient permission"); return; } - if(typeof data.name !== "string") { + if (typeof data.name !== "string") { return; } this.changeLeader(data.name); this.logger.log("### " + user.name + " assigned leader to " + data.name); -} +}; + +/** + * Searches channel library + */ +Channel.prototype.search = function (query, callback) { + var self = this; + if (!self.registered) { + callback([]); + return; + } + + if (typeof query !== "string") { + query = ""; + } + + query = query.substring(0, 100); + + db.channels.searchLibrary(self.name, query, function (err, res) { + if (err) { + res = []; + } + + res.sort(function(a, b) { + var x = a.title.toLowerCase(); + var y = b.title.toLowerCase(); + + return (x == y) ? 0 : (x < y ? -1 : 1); + }); + + res.forEach(function (r) { + r.duration = util.formatTime(r.seconds); + }); + + callback(res); + }); +}; + +/** + * Sends the result of readLog() to a user if the user has sufficient permission + */ +Channel.prototype.handleReadLog = function (user) { + var self = this; + + if (user.rank < 3) { + user.kick("Attempted readChanLog with insufficient permission"); + return; + } + + if (!self.registered) { + user.socket.emit("readChanLog", { + success: false, + data: "Channel log is only available to registered channels." + }); + return; + } + + var filterIp = user.global_rank < 255; + self.readLog(filterIp, function (err, data) { + if (err) { + user.socket.emit("readChanLog", { + success: false, + data: "Reading channel log failed." + }); + } else { + user.socket.emit("readChanLog", { + success: true, + data: data + }); + } + }); +}; + +/** + * Reads the last 100KiB of the channel's log file, masking IP addresses if desired + */ +Channel.prototype.readLog = function (filterIp, callback) { + var maxLen = 102400; // Limit to last 100KiB + var file = this.logger.filename; + + fs.stat(file, function (err, data) { + if (err) { + callback(err, null); + return; + } + + var start = Math.max(data.size - maxLen, 0); + var end = data.size - 1; + + var rs = fs.createReadStream(file, { + start: start, + end: end + }); + + var buffer = ""; + rs.on("data", function (data) { + buffer += data; + }); + + rs.on("end", function () { + if (filterIp) { + buffer = buffer.replace( + /\d+\.\d+\.(\d+\.\d+)/g, + "x.x.$1" + ).replace( + /\d+\.\d+\.(\d+)/g, + "x.x.$.*" + ); + } + + callback(null, buffer); + }); + }); +}; + +/** + * Broadcasts a message to the entire channel + */ +Channel.prototype.sendAll = function (msg, data) { + this.users.forEach(function (u) { + u.socket.emit(msg, data); + }); +}; module.exports = Channel; diff --git a/lib/server.js b/lib/server.js index fb9d3fcc..9a18e5a8 100644 --- a/lib/server.js +++ b/lib/server.js @@ -40,7 +40,7 @@ var http = require("http"); var https = require("https"); var express = require("express"); var Logger = require("./logger"); -var Channel = require("./channel-new"); +var Channel = require("./channel"); var User = require("./user"); var $util = require("./utilities"); var ActionLog = require("./actionlog"); diff --git a/lib/user.js b/lib/user.js index 6158c801..0de72d9a 100644 --- a/lib/user.js +++ b/lib/user.js @@ -297,7 +297,7 @@ User.prototype.initChannelCallbacks = function () { }); wrapTypecheck("mediaUpdate", function (data) { - self.channel.handleMediaUpdate(self, data); + self.channel.handleUpdate(self, data); }); wrapTypecheck("searchMedia", function (data) {