From 1c790249845f9a2f6dc36710afa0f143bd06d1e9 Mon Sep 17 00:00:00 2001 From: calzoneman Date: Sat, 4 Jan 2014 23:15:54 -0600 Subject: [PATCH] Finish most of the channel.js rewrite --- lib/channel-new.js | 1198 ++++++++++++++++++++++++++++++++++++++++++-- lib/emitter.js | 48 ++ lib/server.js | 4 +- 3 files changed, 1208 insertions(+), 42 deletions(-) create mode 100644 lib/emitter.js diff --git a/lib/channel-new.js b/lib/channel-new.js index ceda41e1..d703b40f 100644 --- a/lib/channel-new.js +++ b/lib/channel-new.js @@ -4,8 +4,8 @@ var Playlist = require("./playlist"); var Filter = require("./filter").Filter; var Logger = require("./logger"); var AsyncQueue = require("./asyncqueue"); +var MakeEmitter = require("./emitter"); -var EventEmitter = require("events").EventEmitter; var fs = require("fs"); var path = require("path"); @@ -18,6 +18,7 @@ var DEFAULT_FILTERS = [ ]; function Channel(name) { + MakeEmitter(this); var self = this; // Alias `this` to prevent scoping issues Logger.syslog.log("Loading channel " + name); @@ -38,7 +39,7 @@ function Channel(name) { self.voteskip = null; self.permissions = { playlistadd: 1.5, // Add video to the playlist - playlistnext: 1.5, // TODO I don't think this is used + 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 @@ -116,7 +117,31 @@ function Channel(name) { }); }; -Channel.prototype = EventEmitter.prototype; +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; @@ -306,11 +331,10 @@ Channel.prototype.hasPermission = function (user, key) { * Called immediately if the ready flag is already set */ Channel.prototype.whenReady = function (fn) { - var self = this; - if (self.ready) { + if (this.ready) { setImmediate(fn); } else { - self.on("ready", fn); + this.on("ready", fn); } }; @@ -520,20 +544,19 @@ Channel.prototype.part = function (user) { * Set the MOTD and broadcast it to connected users */ Channel.prototype.setMOTD = function (message) { - var self = this; - self.motd.motd = message; + this.motd.motd = message; // TODO XSS filter - self.motd.html = message.replace(/\n/g, "
"); - self.sendMOTD(self.users); + this.motd.html = message.replace(/\n/g, "
"); + this.sendMOTD(this.users); }; /** * Send the MOTD to the given users */ Channel.prototype.sendMOTD = function (users) { - var self = this; + var motd = this.motd; users.forEach(function (u) { - u.socket.emit("setMotd", self.motd); + u.socket.emit("setMotd", motd); }); }; @@ -555,7 +578,7 @@ Channel.prototype.sendModMessage = function (msg, minrank) { time: Date.now() }; - self.users.forEach(function(u) { + this.users.forEach(function(u) { if (u.rank > minrank) { u.socket.emit("chatMsg", notice); } @@ -571,8 +594,8 @@ Channel.prototype.cacheMedia = function (media) { return false; } - if (self.registered) { - db.channels.addToLibrary(self.name, media); + if (this.registered) { + db.channels.addToLibrary(this.name, media); } }; @@ -649,20 +672,19 @@ Channel.prototype.tryNameBan = function (actor, name, reason) { * Removes a name ban */ Channel.prototype.tryUnbanName = function (actor, name) { - var self = this; - if (!self.hasPermission(actor, "ban")) { + if (!this.hasPermission(actor, "ban")) { return; } - delete self.namebans[name]; - self.logger.log("*** " + actor.name + " un-namebanned " + name); - self.sendModMessage(actor.name + " unbanned " + name, self.permissions.ban); + delete this.namebans[name]; + this.logger.log("*** " + actor.name + " un-namebanned " + name); + this.sendModMessage(actor.name + " unbanned " + name, this.permissions.ban); - if (!self.registered) { + if (!this.registered) { return; } - db.channels.unbanName(self.name, name); + db.channels.unbanName(this.name, name); // TODO send banlist? }; @@ -768,21 +790,20 @@ Channel.prototype.tryBanIP = function (actor, ip, name, reason, range) { * Removes an IP ban */ Channel.prototype.unbanIP = function (actor, ip) { - var self = this; - if (!self.hasPermission(actor, "ban")) { + if (!this.hasPermission(actor, "ban")) { return; } - var record = self.ipbans[ip]; - delete self.ipbans[ip]; - self.logger.log("*** " + actor.name + " unbanned " + ip + " (" + record.name + ")"); - self.sendModMessage(actor.name + " unbanned " + util.maskIP(ip) + " (" + record.name + ")", self.permissions.ban); + var record = this.ipbans[ip]; + delete this.ipbans[ip]; + this.logger.log("*** " + actor.name + " unbanned " + ip + " (" + record.name + ")"); + this.sendModMessage(actor.name + " unbanned " + util.maskIP(ip) + " (" + record.name + ")", this.permissions.ban); - if (!self.registered) { + if (!this.registered) { return; } - db.channels.unbanIP(self.name, ip); + db.channels.unbanIP(this.name, ip); }; /** @@ -891,6 +912,16 @@ Channel.prototype.sendPlaylistMeta = function (users) { }); }; +/** + * 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 */ @@ -1061,9 +1092,9 @@ Channel.prototype.sendPollClose = function (users) { * Broadcasts the channel options */ Channel.prototype.sendOpts = function (users) { - var self = this; + var opts = this.opts; users.forEach(function (u) { - u.socket.emit("channelOpts", self.opts); + u.socket.emit("channelOpts", opts); }); }; @@ -1072,7 +1103,7 @@ Channel.prototype.sendOpts = function (users) { */ Channel.prototype.calcVoteskipMax = function () { var self = this; - return this.users.map(function (u) { + return self.users.map(function (u) { if (!self.hasPermission(u, "voteskip")) { return 0; } @@ -1100,8 +1131,7 @@ Channel.prototype.getVoteskipPacket = function () { * Sends a voteskip update packet */ Channel.prototype.sendVoteskipUpdate = function (users) { - var self = this; - var update = self.getVoteskipPacket(); + var update = this.getVoteskipPacket(); users.forEach(function (u) { if (u.rank >= 1.5) { u.socket.emit("voteskip", update); @@ -1113,9 +1143,9 @@ Channel.prototype.sendVoteskipUpdate = function (users) { * Sends the MOTD */ Channel.prototype.sendMotd = function (users) { - var self = this; + var motd = this.motd; users.forEach(function (u) { - u.socket.emit("setMotd", self.motd); + u.socket.emit("setMotd", motd); }); }; @@ -1123,9 +1153,9 @@ Channel.prototype.sendMotd = function (users) { * Sends the drink count */ Channel.prototype.sendDrinks = function (users) { - var self = this; + var drinks = this.drinks; users.forEach(function (u) { - u.socket.emit("drinkCount", self.drinks); + u.socket.emit("drinkCount", drinks); }); }; @@ -1347,7 +1377,7 @@ Channel.prototype.addMedia = function (data, callback) { }; for (var i = 0; i < vids.length; i++) { - afterData(dummy, false, vids[i]); + afterData(dummy, true, vids[i]); } lock.release(); @@ -1374,10 +1404,1098 @@ Channel.prototype.addMedia = function (data, callback) { // 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; + } + + afterData(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) { + afterData(lock, true, item); + } }); }; +/** + * 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) { + if (!this.hasPermission(user, "playlistdelete")) { + return; + } + + if (typeof data !== "number") { + return; + } + + this.deleteMedia(data, function (err) { + if (!err) { + this.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.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; + + self.plqueue.queue(function (lock) { + if (self.dead) { + return; + } + + if (self.playlist.move(data.from, data.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.playNext(); +}; + +/** + * 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.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); + 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.sendPoll(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.sendPoll(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; + } + + var max = this.calcVoteskipMax(); + var need = Math.ceil(count * this.opts.voteskip_ratio); + if (this.voteskip.counts[0] >= need) { + this.logger.log("### Voteskip passed, skipping to next video"); + this.playNext(); + } + + 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) { + // TODO permission node? + if (user.rank < 2) { + 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.playlistLocked }); +}; + +/** + * Updates a chat filter, or adds a new one if the filter does not exist + */ +Channel.prototype.updateFilter = function (filter) { + 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); + } +}; + +/** + * 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; + } + + if (typeof f.source !== "string" || typeof f.flags !== "string" || + typeof f.replace !== "string") { + return; + } + + if (typeof f.name !== "string") { + f.name = f.source; + } + + f.replace = f.replace.substring(0, 1000); + f.flags = f.flags.substring(0, 4); + + // TODO XSS prevention + try { + new RegExp(f.source, f.flags); + } catch (e) { + return; + } + + var filter = new Filter(f.name, f.source, f.flags, f.replace); + filter.active = Boolean(f.active); + filter.filterlinks = Boolean(f.filterlinks); + + this.logger.log("%%% " + user.name + " updated filter: " + f.name); + this.updateFilter(filter); +}; + +/** + * Removes a chat filter + */ +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; + } + } +}; + +/** + * 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.handleUpdatePermissions = 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) { + // TODO XSS + 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) { + // TODO XSS + 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", msgobject); + }); + return; + } + + if (msg.indexOf("/") === 0) { + if (!ChatCommand.handle(this, user, msg, meta)) { + this.sendMessage(user, msg, meta); + } + } else { + if (msg.indexOf(">") === 0) { + data.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) { + // TODO HTML escape + 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 : "") + "> " + + 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 || err) { + return; + } + + if (oldrank >= user.rank) { + return; + } + + db.channels.setRank(self.name, name, rank, function (err, res) { + if (self.dead || err) { + return; + } + + self.logger.log("*** " + user.name + " set " + name + "'s rank to " + rank); + }); + }); + }; + + if (receiver) { + if (Math.max(receiver.rank, receiver.global_rank) > user.rank) { + return; + } + + if (receiver.loggedIn) { + updateDB(); + } + + self.sendAll("setUserRank", { + name: name, + rank: rank + }); + } 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) { + // TODO permission node? + if (user.rank < 2) { + 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 */ diff --git a/lib/emitter.js b/lib/emitter.js new file mode 100644 index 00000000..05654beb --- /dev/null +++ b/lib/emitter.js @@ -0,0 +1,48 @@ +function MakeEmitter(obj) { + obj.__evHandlers = {}; + + obj.on = function (ev, fn) { + if (!(ev in this.__evHandlers)) { + this.__evHandlers[ev] = []; + } + this.__evHandlers[ev].push({ + fn: fn, + remove: false + }); + }; + + obj.once = function (ev, fn) { + if (!(ev in this.__evHandlers)) { + this.__evHandlers[ev] = []; + } + this.__evHandlers[ev].push({ + fn: fn, + remove: true + }); + }; + + obj.emit = function (ev /*, arguments */) { + var self = this; + var handlers = self.__evHandlers[ev]; + if (!(handlers instanceof Array)) { + handlers = []; + } else { + handlers = Array.prototype.slice.call(handlers); + } + + var args = Array.prototype.slice.call(arguments); + args.shift(); + + handlers.forEach(function (handler) { + handler.fn.apply(self, args); + if (handler.remove) { + var i = self.__evHandlers[ev].indexOf(handler); + if (i >= 0) { + self.__evHandlers[ev].splice(i, 1); + } + } + }); + }; +} + +module.exports = MakeEmitter; diff --git a/lib/server.js b/lib/server.js index 5b2767e1..2b2b25a7 100644 --- a/lib/server.js +++ b/lib/server.js @@ -236,7 +236,7 @@ Server.prototype.getChannel = function (name) { Server.prototype.unloadChannel = function (chan) { if (chan.registered) - chan.saveDump(); + chan.saveState(); chan.playlist.die(); chan.logger.close(); @@ -278,7 +278,7 @@ Server.prototype.shutdown = function () { for (var i = 0; i < this.channels.length; i++) { if (this.channels[i].registered) { Logger.syslog.log("Saving /r/" + this.channels[i].name); - this.channels[i].saveDump(); + this.channels[i].saveState(); } } Logger.syslog.log("Goodbye");