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