package: build with babel for ES2015 support

* Rename lib/ -> src/
* Add `postinstall` npm target for compiling src files to lib
* Add `build-watch` npm target for development with babel --watch
* Add `lib/` to .gitignore
* Add `source-map-support` module for babel-generated sourcemaps
This commit is contained in:
calzoneman 2015-09-20 22:06:53 -07:00
parent d042619b21
commit 0109a87e55
55 changed files with 9 additions and 3 deletions

View file

@ -0,0 +1,70 @@
var Account = require("../account");
var ChannelModule = require("./module");
var Flags = require("../flags");
function AccessControlModule(channel) {
ChannelModule.apply(this, arguments);
}
AccessControlModule.prototype = Object.create(ChannelModule.prototype);
var pending = 0;
AccessControlModule.prototype.onUserPreJoin = function (user, data, cb) {
var chan = this.channel,
opts = this.channel.modules.options;
var self = this;
if (user.socket.disconnected) {
return cb("User disconnected", ChannelModule.DENY);
}
if (opts.get("password") !== false && data.pw !== opts.get("password")) {
user.socket.on("disconnect", function () {
if (!user.is(Flags.U_IN_CHANNEL)) {
cb("User disconnected", ChannelModule.DENY);
}
});
if (user.is(Flags.U_LOGGED_IN) && user.account.effectiveRank >= 2) {
cb(null, ChannelModule.PASSTHROUGH);
user.socket.emit("cancelNeedPassword");
} else {
user.socket.emit("needPassword", typeof data.pw !== "undefined");
/* Option 1: log in as a moderator */
user.waitFlag(Flags.U_LOGGED_IN, function () {
user.refreshAccount({ channel: self.channel.name }, function (err, account) {
/* Already joined the channel by some other condition */
if (user.is(Flags.U_IN_CHANNEL)) {
return;
}
if (account.effectiveRank >= 2) {
cb(null, ChannelModule.PASSTHROUGH);
user.socket.emit("cancelNeedPassword");
}
});
});
/* Option 2: Enter correct password */
var pwListener = function (pw) {
if (chan.dead || user.is(Flags.U_IN_CHANNEL)) {
return;
}
if (pw !== opts.get("password")) {
user.socket.emit("needPassword", true);
return;
}
user.socket.emit("cancelNeedPassword");
cb(null, ChannelModule.PASSTHROUGH);
};
user.socket.on("channelPassword", pwListener);
}
} else {
cb(null, ChannelModule.PASSTHROUGH);
}
};
module.exports = AccessControlModule;

689
src/channel/channel.js Normal file
View file

@ -0,0 +1,689 @@
var MakeEmitter = require("../emitter");
var Logger = require("../logger");
var ChannelModule = require("./module");
var Flags = require("../flags");
var Account = require("../account");
var util = require("../utilities");
var fs = require("graceful-fs");
var path = require("path");
var sio = require("socket.io");
var db = require("../database");
const SIZE_LIMIT = 1048576;
/**
* Previously, async channel functions were riddled with race conditions due to
* an event causing the channel to be unloaded while a pending callback still
* needed to reference it.
*
* This solution should be better than constantly checking whether the channel
* has been unloaded in nested callbacks. The channel won't be unloaded until
* nothing needs it anymore. Conceptually similar to a reference count.
*/
function ActiveLock(channel) {
this.channel = channel;
this.count = 0;
}
ActiveLock.prototype = {
lock: function () {
this.count++;
},
release: function () {
this.count--;
if (this.count === 0) {
/* sanity check */
if (this.channel.users.length > 0) {
Logger.errlog.log("Warning: ActiveLock count=0 but users.length > 0 (" +
"channel: " + this.channel.name + ")");
this.count = this.channel.users.length;
} else {
this.channel.emit("empty");
}
}
}
};
function Channel(name) {
MakeEmitter(this);
this.name = name;
this.uniqueName = name.toLowerCase();
this.modules = {};
this.logger = new Logger.Logger(path.join(__dirname, "..", "..", "chanlogs",
this.uniqueName + ".log"));
this.users = [];
this.activeLock = new ActiveLock(this);
this.flags = 0;
var self = this;
db.channels.load(this, function (err) {
if (err && err !== "Channel is not registered") {
return;
} else {
self.initModules();
self.loadState();
}
});
}
Channel.prototype.is = function (flag) {
return Boolean(this.flags & flag);
};
Channel.prototype.setFlag = function (flag) {
this.flags |= flag;
this.emit("setFlag", flag);
};
Channel.prototype.clearFlag = function (flag) {
this.flags &= ~flag;
this.emit("clearFlag", flag);
};
Channel.prototype.waitFlag = function (flag, cb) {
var self = this;
if (self.is(flag)) {
cb();
} else {
var wait = function (f) {
if (f === flag) {
self.unbind("setFlag", wait);
cb();
}
};
self.on("setFlag", wait);
}
};
Channel.prototype.moderators = function () {
return this.users.filter(function (u) {
return u.account.effectiveRank >= 2;
});
};
Channel.prototype.initModules = function () {
const modules = {
"./permissions" : "permissions",
"./emotes" : "emotes",
"./chat" : "chat",
"./drink" : "drink",
"./filters" : "filters",
"./customization" : "customization",
"./opts" : "options",
"./library" : "library",
"./playlist" : "playlist",
"./mediarefresher": "mediarefresher",
"./voteskip" : "voteskip",
"./poll" : "poll",
"./kickban" : "kickban",
"./ranks" : "rank",
"./accesscontrol" : "password"
};
var self = this;
var inited = [];
Object.keys(modules).forEach(function (m) {
var ctor = require(m);
var module = new ctor(self);
self.modules[modules[m]] = module;
inited.push(modules[m]);
});
self.logger.log("[init] Loaded modules: " + inited.join(", "));
};
Channel.prototype.getDiskSize = function (cb) {
if (this._getDiskSizeTimeout > Date.now()) {
return cb(null, this._cachedDiskSize);
}
var self = this;
var file = path.join(__dirname, "..", "..", "chandump", self.uniqueName);
fs.stat(file, function (err, stats) {
if (err) {
return cb(err);
}
self._cachedDiskSize = stats.size;
cb(null, self._cachedDiskSize);
});
};
Channel.prototype.loadState = function () {
var self = this;
var file = path.join(__dirname, "..", "..", "chandump", self.uniqueName);
/* Don't load from disk if not registered */
if (!self.is(Flags.C_REGISTERED)) {
self.modules.permissions.loadUnregistered();
self.setFlag(Flags.C_READY);
return;
}
var errorLoad = function (msg) {
if (self.modules.customization) {
self.modules.customization.load({
motd: msg
});
}
self.setFlag(Flags.C_READY | Flags.C_ERROR);
};
fs.stat(file, function (err, stats) {
if (!err) {
var mb = stats.size / 1048576;
mb = Math.floor(mb * 100) / 100;
if (mb > SIZE_LIMIT / 1048576) {
Logger.errlog.log("Large chandump detected: " + self.uniqueName +
" (" + mb + " MiB)");
var msg = "This channel's state size has exceeded the memory limit " +
"enforced by this server. Please contact an administrator " +
"for assistance.";
errorLoad(msg);
return;
}
}
continueLoad();
});
var continueLoad = function () {
fs.readFile(file, function (err, data) {
if (err) {
/* ENOENT means the file didn't exist. This is normal for new channels */
if (err.code === "ENOENT") {
self.setFlag(Flags.C_READY);
Object.keys(self.modules).forEach(function (m) {
self.modules[m].load({});
});
} else {
Logger.errlog.log("Failed to open channel dump " + self.uniqueName);
Logger.errlog.log(err);
errorLoad("Unknown error occurred when loading channel state. " +
"Contact an administrator for assistance.");
}
return;
}
self.logger.log("[init] Loading channel state from disk");
try {
data = JSON.parse(data);
Object.keys(self.modules).forEach(function (m) {
self.modules[m].load(data);
});
self.setFlag(Flags.C_READY);
} catch (e) {
Logger.errlog.log("Channel dump for " + self.uniqueName + " is not " +
"valid");
Logger.errlog.log(e);
errorLoad("Unknown error occurred when loading channel state. Contact " +
"an administrator for assistance.");
}
});
};
};
Channel.prototype.saveState = function () {
var self = this;
var file = path.join(__dirname, "..", "..", "chandump", self.uniqueName);
/**
* Don't overwrite saved state data if the current state is dirty,
* or if this channel is unregistered
*/
if (self.is(Flags.C_ERROR) || !self.is(Flags.C_REGISTERED)) {
return;
}
self.logger.log("[init] Saving channel state to disk");
var data = {};
Object.keys(this.modules).forEach(function (m) {
self.modules[m].save(data);
});
var json = JSON.stringify(data);
/**
* Synchronous on purpose.
* When the server is shutting down, saveState() is called on all channels and
* then the process terminates. Async writeFile causes a race condition that wipes
* channels.
*/
var err = fs.writeFileSync(file, json);
// Check for large chandump and warn moderators/admins
self.getDiskSize(function (err, size) {
if (!err && size > SIZE_LIMIT && self.users) {
self.users.forEach(function (u) {
if (u.account.effectiveRank >= 2) {
u.socket.emit("warnLargeChandump", {
limit: SIZE_LIMIT,
actual: size
});
}
});
}
});
};
Channel.prototype.checkModules = function (fn, args, cb) {
var self = this;
this.waitFlag(Flags.C_READY, function () {
self.activeLock.lock();
var keys = Object.keys(self.modules);
var next = function (err, result) {
if (result !== ChannelModule.PASSTHROUGH) {
/* Either an error occured, or the module denied the user access */
cb(err, result);
self.activeLock.release();
return;
}
var m = keys.shift();
if (m === undefined) {
/* No more modules to check */
cb(null, ChannelModule.PASSTHROUGH);
self.activeLock.release();
return;
}
var module = self.modules[m];
module[fn].apply(module, args);
};
args.push(next);
next(null, ChannelModule.PASSTHROUGH);
});
};
Channel.prototype.notifyModules = function (fn, args) {
var self = this;
this.waitFlag(Flags.C_READY, function () {
var keys = Object.keys(self.modules);
keys.forEach(function (k) {
self.modules[k][fn].apply(self.modules[k], args);
});
});
};
Channel.prototype.joinUser = function (user, data) {
var self = this;
self.activeLock.lock();
self.waitFlag(Flags.C_READY, function () {
/* User closed the connection before the channel finished loading */
if (user.socket.disconnected) {
self.activeLock.release();
return;
}
if (self.is(Flags.C_REGISTERED)) {
user.refreshAccount({ channel: self.name }, function (err, account) {
if (err) {
Logger.errlog.log("user.refreshAccount failed at Channel.joinUser");
Logger.errlog.log(err.stack);
self.activeLock.release();
return;
}
afterAccount();
});
} else {
afterAccount();
}
function afterAccount() {
if (self.dead || user.socket.disconnected) {
if (self.activeLock) self.activeLock.release();
return;
}
self.checkModules("onUserPreJoin", [user, data], function (err, result) {
if (result === ChannelModule.PASSTHROUGH) {
if (user.account.channelRank !== user.account.globalRank) {
user.socket.emit("rank", user.account.effectiveRank);
}
self.acceptUser(user);
} else {
user.account.channelRank = 0;
user.account.effectiveRank = user.account.globalRank;
self.activeLock.release();
}
});
}
});
};
Channel.prototype.acceptUser = function (user) {
user.channel = this;
user.setFlag(Flags.U_IN_CHANNEL);
user.socket.join(this.name);
user.autoAFK();
user.socket.on("readChanLog", this.handleReadLog.bind(this, user));
Logger.syslog.log(user.realip + " joined " + this.name);
if (user.socket._isUsingTor) {
if (this.modules.options && this.modules.options.get("torbanned")) {
user.kick("This channel has banned connections from Tor.");
this.logger.log("[login] Blocked connection from Tor exit at " +
user.displayip);
return;
}
this.logger.log("[login] Accepted connection from Tor exit at " +
user.displayip);
} else {
this.logger.log("[login] Accepted connection from " + user.displayip);
}
var self = this;
user.waitFlag(Flags.U_LOGGED_IN, function () {
for (var i = 0; i < self.users.length; i++) {
if (self.users[i] !== user &&
self.users[i].getLowerName() === user.getLowerName()) {
self.users[i].kick("Duplicate login");
}
}
var loginStr = "[login] " + user.displayip + " logged in as " + user.getName();
if (user.account.globalRank === 0) loginStr += " (guest)";
loginStr += " (aliases: " + user.account.aliases.join(",") + ")";
self.logger.log(loginStr);
self.sendUserJoin(self.users, user);
});
this.users.push(user);
user.socket.on("disconnect", this.partUser.bind(this, user));
Object.keys(this.modules).forEach(function (m) {
if (user.dead) return;
self.modules[m].onUserPostJoin(user);
});
this.sendUserlist([user]);
this.sendUsercount(this.users);
if (!this.is(Flags.C_REGISTERED)) {
user.socket.emit("channelNotRegistered");
}
};
Channel.prototype.partUser = function (user) {
if (!this.logger) {
Logger.errlog.log("partUser called on dead channel");
return;
}
this.logger.log("[login] " + user.displayip + " (" + user.getName() + ") " +
"disconnected.");
user.channel = null;
/* Should be unnecessary because partUser only occurs if the socket dies */
user.clearFlag(Flags.U_IN_CHANNEL);
if (user.is(Flags.U_LOGGED_IN)) {
this.broadcastAll("userLeave", { name: user.getName() });
}
var idx = this.users.indexOf(user);
if (idx >= 0) {
this.users.splice(idx, 1);
}
var self = this;
Object.keys(this.modules).forEach(function (m) {
self.modules[m].onUserPart(user);
});
this.sendUsercount(this.users);
this.activeLock.release();
user.die();
};
Channel.prototype.packUserData = function (user) {
var base = {
name: user.getName(),
rank: user.account.effectiveRank,
profile: user.account.profile,
meta: {
afk: user.is(Flags.U_AFK),
muted: user.is(Flags.U_MUTED) && !user.is(Flags.U_SMUTED)
}
};
var mod = {
name: user.getName(),
rank: user.account.effectiveRank,
profile: user.account.profile,
meta: {
afk: user.is(Flags.U_AFK),
muted: user.is(Flags.U_MUTED),
smuted: user.is(Flags.U_SMUTED),
aliases: user.account.aliases,
ip: user.displayip
}
};
var sadmin = {
name: user.getName(),
rank: user.account.effectiveRank,
profile: user.account.profile,
meta: {
afk: user.is(Flags.U_AFK),
muted: user.is(Flags.U_MUTED),
smuted: user.is(Flags.U_SMUTED),
aliases: user.account.aliases,
ip: user.realip
}
};
return {
base: base,
mod: mod,
sadmin: sadmin
};
};
Channel.prototype.sendUserMeta = function (users, user, minrank) {
var self = this;
var userdata = self.packUserData(user);
users.filter(function (u) {
return typeof minrank !== "number" || u.account.effectiveRank > minrank
}).forEach(function (u) {
if (u.account.globalRank >= 255) {
u.socket.emit("setUserMeta", {
name: user.getName(),
meta: userdata.sadmin.meta
});
} else if (u.account.effectiveRank >= 2) {
u.socket.emit("setUserMeta", {
name: user.getName(),
meta: userdata.mod.meta
});
} else {
u.socket.emit("setUserMeta", {
name: user.getName(),
meta: userdata.base.meta
});
}
});
};
Channel.prototype.sendUserProfile = function (users, user) {
var packet = {
name: user.getName(),
profile: user.account.profile
};
users.forEach(function (u) {
u.socket.emit("setUserProfile", packet);
});
};
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.getName() === "") {
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.account.globalRank >= 255) {
u.socket.emit("userlist", sadmin);
} else if (u.account.effectiveRank >= 2) {
u.socket.emit("userlist", mod);
} else {
u.socket.emit("userlist", base);
}
if (self.leader != null) {
u.socket.emit("setLeader", self.leader.name);
}
});
};
Channel.prototype.sendUsercount = function (users) {
var self = this;
users.forEach(function (u) {
u.socket.emit("usercount", self.users.length);
});
};
Channel.prototype.sendUserJoin = function (users, user) {
var self = this;
if (user.account.aliases.length === 0) {
user.account.aliases.push(user.getName());
}
var data = self.packUserData(user);
users.forEach(function (u) {
if (u.account.globalRank >= 255) {
u.socket.emit("addUser", data.sadmin);
} else if (u.account.effectiveRank >= 2) {
u.socket.emit("addUser", data.mod);
} else {
u.socket.emit("addUser", data.base);
}
});
self.modules.chat.sendModMessage(user.getName() + " joined (aliases: " +
user.account.aliases.join(",") + ")", 2);
};
Channel.prototype.readLog = function (cb) {
var maxLen = 102400;
var file = this.logger.filename;
this.activeLock.lock();
var self = this;
fs.stat(file, function (err, data) {
if (err) {
self.activeLock.release();
return cb(err, null);
}
var start = Math.max(data.size - maxLen, 0);
var end = data.size - 1;
var read = fs.createReadStream(file, {
start: start,
end: end
});
var buffer = "";
read.on("data", function (data) {
buffer += data;
});
read.on("end", function () {
cb(null, buffer);
self.activeLock.release();
});
});
};
Channel.prototype.handleReadLog = function (user) {
if (user.account.effectiveRank < 3) {
user.kick("Attempted readChanLog with insufficient permission");
return;
}
if (!this.is(Flags.C_REGISTERED)) {
user.socket.emit("readChanLog", {
success: false,
data: "Channel log is only available to registered channels."
});
return;
}
var shouldMaskIP = user.account.globalRank < 255;
this.readLog(function (err, data) {
if (err) {
user.socket.emit("readChanLog", {
success: false,
data: "Error reading channel log"
});
} else {
user.socket.emit("readChanLog", {
success: true,
data: data
});
}
});
};
Channel.prototype._broadcast = function (msg, data, ns) {
sio.instance.in(ns).emit(msg, data);
};
Channel.prototype.broadcastAll = function (msg, data) {
this._broadcast(msg, data, this.name);
};
Channel.prototype.packInfo = function (isAdmin) {
var data = {
name: this.name,
usercount: this.users.length,
users: [],
registered: this.is(Flags.C_REGISTERED)
};
for (var i = 0; i < this.users.length; i++) {
if (this.users[i].name !== "") {
var name = this.users[i].getName();
var rank = this.users[i].account.effectiveRank;
if (rank >= 255) {
name = "!" + name;
} else if (rank >= 4) {
name = "~" + name;
} else if (rank >= 3) {
name = "&" + name;
} else if (rank >= 2) {
name = "@" + name;
}
data.users.push(name);
}
}
if (isAdmin) {
data.activeLockCount = this.activeLock.count;
}
var self = this;
var keys = Object.keys(this.modules);
keys.forEach(function (k) {
self.modules[k].packInfo(data, isAdmin);
});
return data;
};
module.exports = Channel;

615
src/channel/chat.js Normal file
View file

@ -0,0 +1,615 @@
var Config = require("../config");
var User = require("../user");
var XSS = require("../xss");
var ChannelModule = require("./module");
var util = require("../utilities");
var Flags = require("../flags");
var url = require("url");
var counters = require("../counters");
const SHADOW_TAG = "[shadow]";
const LINK = /(\w+:\/\/(?:[^:\/\[\]\s]+|\[[0-9a-f:]+\])(?::\d+)?(?:\/[^\/\s]*)*)/ig;
const LINK_PLACEHOLDER = '\ueeee';
const LINK_PLACEHOLDER_RE = /\ueeee/g;
const TYPE_CHAT = {
msg: "string",
meta: "object,optional"
};
const TYPE_PM = {
msg: "string",
to: "string",
meta: "object,optional"
};
// Limit to 10 messages/sec
const MIN_ANTIFLOOD = {
burst: 20,
sustained: 10
};
function ChatModule(channel) {
ChannelModule.apply(this, arguments);
this.buffer = [];
this.muted = new util.Set();
this.commandHandlers = {};
/* Default commands */
this.registerCommand("/me", this.handleCmdMe.bind(this));
this.registerCommand("/sp", this.handleCmdSp.bind(this));
this.registerCommand("/say", this.handleCmdSay.bind(this));
this.registerCommand("/rcv", this.handleCmdSay.bind(this));
this.registerCommand("/shout", this.handleCmdSay.bind(this));
this.registerCommand("/clear", this.handleCmdClear.bind(this));
this.registerCommand("/a", this.handleCmdAdminflair.bind(this));
this.registerCommand("/afk", this.handleCmdAfk.bind(this));
this.registerCommand("/mute", this.handleCmdMute.bind(this));
this.registerCommand("/smute", this.handleCmdSMute.bind(this));
this.registerCommand("/unmute", this.handleCmdUnmute.bind(this));
this.registerCommand("/unsmute", this.handleCmdUnmute.bind(this));
}
ChatModule.prototype = Object.create(ChannelModule.prototype);
ChatModule.prototype.load = function (data) {
this.buffer = [];
this.muted = new util.Set();
if ("chatbuffer" in data) {
for (var i = 0; i < data.chatbuffer.length; i++) {
this.buffer.push(data.chatbuffer[i]);
}
}
if ("chatmuted" in data) {
for (var i = 0; i < data.chatmuted.length; i++) {
this.muted.add(data.chatmuted[i]);
}
}
};
ChatModule.prototype.save = function (data) {
data.chatbuffer = this.buffer;
data.chatmuted = Array.prototype.slice.call(this.muted);
};
ChatModule.prototype.packInfo = function (data, isAdmin) {
data.chat = Array.prototype.slice.call(this.buffer);
};
ChatModule.prototype.onUserPostJoin = function (user) {
var self = this;
user.waitFlag(Flags.U_LOGGED_IN, function () {
var muteperm = self.channel.modules.permissions.permissions.mute;
if (self.isShadowMuted(user.getName())) {
user.setFlag(Flags.U_SMUTED | Flags.U_MUTED);
self.channel.sendUserMeta(self.channel.users, user, muteperm);
} else if (self.isMuted(user.getName())) {
user.setFlag(Flags.U_MUTED);
self.channel.sendUserMeta(self.channel.users, user, muteperm);
}
});
user.socket.typecheckedOn("chatMsg", TYPE_CHAT, this.handleChatMsg.bind(this, user));
user.socket.typecheckedOn("pm", TYPE_PM, this.handlePm.bind(this, user));
this.buffer.forEach(function (msg) {
user.socket.emit("chatMsg", msg);
});
};
ChatModule.prototype.isMuted = function (name) {
return this.muted.contains(name.toLowerCase()) ||
this.muted.contains(SHADOW_TAG + name.toLowerCase());
};
ChatModule.prototype.mutedUsers = function () {
var self = this;
return self.channel.users.filter(function (u) {
return self.isMuted(u.getName());
});
};
ChatModule.prototype.isShadowMuted = function (name) {
return this.muted.contains(SHADOW_TAG + name.toLowerCase());
};
ChatModule.prototype.shadowMutedUsers = function () {
var self = this;
return self.channel.users.filter(function (u) {
return self.isShadowMuted(u.getName());
});
};
ChatModule.prototype.handleChatMsg = function (user, data) {
var self = this;
counters.add("chat:incoming");
if (!this.channel || !this.channel.modules.permissions.canChat(user)) {
return;
}
// Limit to 240 characters
data.msg = data.msg.substring(0, 240);
// If channel doesn't permit them, strip ASCII control characters
if (!this.channel.modules.options ||
!this.channel.modules.options.get("allow_ascii_control")) {
data.msg = data.msg.replace(/[\x00-\x1f]+/g, " ");
}
// Disallow blankposting
if (!data.msg.trim()) {
return;
}
if (!user.is(Flags.U_LOGGED_IN)) {
return;
}
var meta = {};
data.meta = data.meta || {};
if (user.account.effectiveRank >= 2) {
if ("modflair" in data.meta && data.meta.modflair === user.account.effectiveRank) {
meta.modflair = data.meta.modflair;
}
}
data.meta = meta;
this.channel.checkModules("onUserPreChat", [user, data], function (err, result) {
if (result === ChannelModule.PASSTHROUGH) {
self.processChatMsg(user, data);
}
});
};
ChatModule.prototype.handlePm = function (user, data) {
if (!this.channel) {
return;
}
if (!user.is(Flags.U_LOGGED_IN)) {
return user.socket.emit("errorMsg", {
msg: "You must be signed in to send PMs"
});
}
if (data.msg.match(Config.get("link-domain-blacklist-regex"))) {
this.channel.logger.log(user.displayip + " (" + user.getName() + ") was kicked for " +
"blacklisted domain");
user.kick();
this.sendModMessage(user.getName() + " was kicked: blacklisted domain in " +
"private message", 2);
return;
}
var reallyTo = data.to;
data.to = data.to.toLowerCase();
if (data.to === user.getLowerName()) {
user.socket.emit("errorMsg", {
msg: "You can't PM yourself!"
});
return;
}
if (!util.isValidUserName(data.to)) {
user.socket.emit("errorMsg", {
msg: "PM failed: " + data.to + " isn't a valid username."
});
return;
}
if (user.chatLimiter.throttle(MIN_ANTIFLOOD)) {
user.socket.emit("cooldown", 1000 / MIN_ANTIFLOOD.sustained);
return;
}
data.msg = data.msg.substring(0, 240);
var to = null;
for (var i = 0; i < this.channel.users.length; i++) {
if (this.channel.users[i].getLowerName() === data.to) {
to = this.channel.users[i];
break;
}
}
if (!to) {
user.socket.emit("errorMsg", {
msg: "PM failed: " + data.to + " isn't connected to this channel."
});
return;
}
var meta = {};
data.meta = data.meta || {};
if (user.rank >= 2) {
if ("modflair" in data.meta && data.meta.modflair === user.rank) {
meta.modflair = data.meta.modflair;
}
}
if (data.msg.indexOf(">") === 0) {
meta.addClass = "greentext";
}
data.meta = meta;
var msgobj = this.formatMessage(user.getName(), data);
msgobj.to = to.getName();
to.socket.emit("pm", msgobj);
user.socket.emit("pm", msgobj);
};
ChatModule.prototype.processChatMsg = function (user, data) {
if (data.msg.match(Config.get("link-domain-blacklist-regex"))) {
this.channel.logger.log(user.displayip + " (" + user.getName() + ") was kicked for " +
"blacklisted domain");
user.kick();
this.sendModMessage(user.getName() + " was kicked: blacklisted domain in " +
"chat message", 2);
return;
}
if (data.msg.indexOf("/afk") === -1) {
user.setAFK(false);
}
var msgobj = this.formatMessage(user.getName(), data);
var antiflood = MIN_ANTIFLOOD;
if (this.channel.modules.options &&
this.channel.modules.options.get("chat_antiflood") &&
user.account.effectiveRank < 2) {
antiflood = this.channel.modules.options.get("chat_antiflood_params");
}
if (user.chatLimiter.throttle(antiflood)) {
user.socket.emit("cooldown", 1000 / antiflood.sustained);
return;
}
if (data.msg.indexOf(">") === 0) {
msgobj.meta.addClass = "greentext";
}
if (data.msg.indexOf("/") === 0) {
var space = data.msg.indexOf(" ");
var cmd;
if (space < 0) {
cmd = data.msg.substring(1);
} else {
cmd = data.msg.substring(1, space);
}
if (cmd in this.commandHandlers) {
this.commandHandlers[cmd](user, data.msg, data.meta);
return;
}
}
if (user.is(Flags.U_SMUTED)) {
this.shadowMutedUsers().forEach(function (u) {
u.socket.emit("chatMsg", msgobj);
});
msgobj.meta.shadow = true;
this.channel.moderators().forEach(function (u) {
u.socket.emit("chatMsg", msgobj);
});
return;
} else if (user.is(Flags.U_MUTED)) {
user.socket.emit("noflood", {
action: "chat",
msg: "You have been muted on this channel."
});
return;
}
this.sendMessage(msgobj);
counters.add("chat:sent");
};
ChatModule.prototype.formatMessage = function (username, data) {
var msg = XSS.sanitizeText(data.msg);
if (this.channel.modules.filters) {
msg = this.filterMessage(msg);
}
var obj = {
username: username,
msg: msg,
meta: data.meta,
time: Date.now()
};
return obj;
};
ChatModule.prototype.filterMessage = function (msg) {
var filters = this.channel.modules.filters.filters;
var chan = this.channel;
var convertLinks = this.channel.modules.options.get("enable_link_regex");
var links = msg.match(LINK);
var intermediate = msg.replace(LINK, LINK_PLACEHOLDER);
var result = filters.filter(intermediate, false);
result = result.replace(LINK_PLACEHOLDER_RE, function () {
var link = links.shift();
if (!link) {
return '';
}
var filtered = filters.filter(link, true);
if (filtered !== link) {
return filtered;
} else if (convertLinks) {
return "<a href=\"" + link + "\" target=\"_blank\">" + link + "</a>";
} else {
return link;
}
});
return XSS.sanitizeHTML(result);
};
ChatModule.prototype.sendModMessage = function (msg, minrank) {
if (isNaN(minrank)) {
minrank = 2;
}
var msgobj = {
username: "[server]",
msg: msg,
meta: {
addClass: "server-whisper",
addClassToNameAndTimestamp: true
},
time: Date.now()
};
this.channel.users.forEach(function (u) {
if (u.account.effectiveRank >= minrank) {
u.socket.emit("chatMsg", msgobj);
}
});
};
ChatModule.prototype.sendMessage = function (msgobj) {
this.channel.broadcastAll("chatMsg", msgobj);
this.buffer.push(msgobj);
if (this.buffer.length > 15) {
this.buffer.shift();
}
this.channel.logger.log("<" + msgobj.username + (msgobj.meta.addClass ?
"." + msgobj.meta.addClass : "") +
"> " + XSS.decodeText(msgobj.msg));
};
ChatModule.prototype.registerCommand = function (cmd, cb) {
cmd = cmd.replace(/^\//, "");
this.commandHandlers[cmd] = cb;
};
/**
* == Default commands ==
*/
ChatModule.prototype.handleCmdMe = function (user, msg, meta) {
meta.addClass = "action";
meta.action = true;
var args = msg.split(" ");
args.shift();
this.processChatMsg(user, { msg: args.join(" "), meta: meta });
};
ChatModule.prototype.handleCmdSp = function (user, msg, meta) {
meta.addClass = "spoiler";
var args = msg.split(" ");
args.shift();
this.processChatMsg(user, { msg: args.join(" "), meta: meta });
};
ChatModule.prototype.handleCmdSay = function (user, msg, meta) {
if (user.account.effectiveRank < 1.5) {
return;
}
meta.addClass = "shout";
meta.addClassToNameAndTimestamp = true;
meta.forceShowName = true;
var args = msg.split(" ");
args.shift();
this.processChatMsg(user, { msg: args.join(" "), meta: meta });
};
ChatModule.prototype.handleCmdClear = function (user, msg, meta) {
if (!this.channel.modules.permissions.canClearChat(user)) {
return;
}
this.buffer = [];
this.channel.broadcastAll("clearchat");
this.channel.logger.log("[mod] " + user.getName() + " used /clear");
};
ChatModule.prototype.handleCmdAdminflair = function (user, msg, meta) {
if (user.account.globalRank < 255) {
return;
}
var args = msg.split(" ");
args.shift();
var superadminflair = {
labelclass: "label-danger",
icon: "glyphicon-globe"
};
var cargs = [];
args.forEach(function (a) {
if (a.indexOf("!icon-") === 0) {
superadminflair.icon = "glyph" + a.substring(1);
} else if (a.indexOf("!label-") === 0) {
superadminflair.labelclass = a.substring(1);
} else {
cargs.push(a);
}
});
meta.superadminflair = superadminflair;
meta.forceShowName = true;
this.processChatMsg(user, { msg: cargs.join(" "), meta: meta });
};
ChatModule.prototype.handleCmdAfk = function (user, msg, meta) {
user.setAFK(!user.is(Flags.U_AFK));
};
ChatModule.prototype.handleCmdMute = function (user, msg, meta) {
if (!this.channel.modules.permissions.canMute(user)) {
return;
}
var muteperm = this.channel.modules.permissions.permissions.mute;
var args = msg.split(" ");
args.shift(); /* shift off /mute */
var name = args.shift();
if (typeof name !== "string") {
user.socket.emit("errorMsg", {
msg: "/mute requires a target name"
});
return;
}
name = name.toLowerCase();
var target;
for (var i = 0; i < this.channel.users.length; i++) {
if (this.channel.users[i].getLowerName() === name) {
target = this.channel.users[i];
break;
}
}
if (!target) {
user.socket.emit("errorMsg", {
msg: "/mute target " + name + " not present in channel."
});
return;
}
if (target.account.effectiveRank >= user.account.effectiveRank
|| target.account.globalRank > user.account.globalRank) {
user.socket.emit("errorMsg", {
msg: "/mute failed - " + target.getName() + " has equal or higher rank " +
"than you."
});
return;
}
target.setFlag(Flags.U_MUTED);
this.muted.add(name);
this.channel.sendUserMeta(this.channel.users, target, -1);
this.channel.logger.log("[mod] " + user.getName() + " muted " + target.getName());
this.sendModMessage(user.getName() + " muted " + target.getName(), muteperm);
};
ChatModule.prototype.handleCmdSMute = function (user, msg, meta) {
if (!this.channel.modules.permissions.canMute(user)) {
return;
}
var muteperm = this.channel.modules.permissions.permissions.mute;
var args = msg.split(" ");
args.shift(); /* shift off /smute */
var name = args.shift();
if (typeof name !== "string") {
user.socket.emit("errorMsg", {
msg: "/smute requires a target name"
});
return;
}
name = name.toLowerCase();
var target;
for (var i = 0; i < this.channel.users.length; i++) {
if (this.channel.users[i].getLowerName() === name) {
target = this.channel.users[i];
break;
}
}
if (!target) {
user.socket.emit("errorMsg", {
msg: "/smute target " + name + " not present in channel."
});
return;
}
if (target.account.effectiveRank >= user.account.effectiveRank
|| target.account.globalRank > user.account.globalRank) {
user.socket.emit("errorMsg", {
msg: "/smute failed - " + target.getName() + " has equal or higher rank " +
"than you."
});
return;
}
target.setFlag(Flags.U_MUTED | Flags.U_SMUTED);
this.muted.add(name);
this.muted.add(SHADOW_TAG + name);
this.channel.sendUserMeta(this.channel.users, target, muteperm);
this.channel.logger.log("[mod] " + user.getName() + " shadowmuted " + target.getName());
this.sendModMessage(user.getName() + " shadowmuted " + target.getName(), muteperm);
};
ChatModule.prototype.handleCmdUnmute = function (user, msg, meta) {
if (!this.channel.modules.permissions.canMute(user)) {
return;
}
var muteperm = this.channel.modules.permissions.permissions.mute;
var args = msg.split(" ");
args.shift(); /* shift off /mute */
var name = args.shift();
if (typeof name !== "string") {
user.socket.emit("errorMsg", {
msg: "/unmute requires a target name"
});
return;
}
name = name.toLowerCase();
if (!this.isMuted(name)) {
user.socket.emit("errorMsg", {
msg: name + " is not muted."
});
return;
}
this.muted.remove(name);
this.muted.remove(SHADOW_TAG + name);
var target;
for (var i = 0; i < this.channel.users.length; i++) {
if (this.channel.users[i].getLowerName() === name) {
target = this.channel.users[i];
break;
}
}
if (!target) {
return;
}
target.clearFlag(Flags.U_MUTED | Flags.U_SMUTED);
this.channel.sendUserMeta(this.channel.users, target, -1);
this.channel.logger.log("[mod] " + user.getName() + " unmuted " + target.getName());
this.sendModMessage(user.getName() + " unmuted " + target.getName(), muteperm);
};
module.exports = ChatModule;

View file

@ -0,0 +1,119 @@
var ChannelModule = require("./module");
var XSS = require("../xss");
const TYPE_SETCSS = {
css: "string"
};
const TYPE_SETJS = {
js: "string"
};
const TYPE_SETMOTD = {
motd: "string"
};
function CustomizationModule(channel) {
ChannelModule.apply(this, arguments);
this.css = "";
this.js = "";
this.motd = "";
}
CustomizationModule.prototype = Object.create(ChannelModule.prototype);
CustomizationModule.prototype.load = function (data) {
if ("css" in data) {
this.css = data.css;
}
if ("js" in data) {
this.js = data.js;
}
if ("motd" in data) {
if (typeof data.motd === "object" && data.motd.motd) {
// Old style MOTD, convert to new
this.motd = XSS.sanitizeHTML(data.motd.motd).replace(
/\n/g, "<br>\n");
} else if (typeof data.motd === "string") {
// The MOTD is filtered before it is saved, however it is also
// re-filtered on load in case the filtering rules change
this.motd = XSS.sanitizeHTML(data.motd);
}
}
};
CustomizationModule.prototype.save = function (data) {
data.css = this.css;
data.js = this.js;
data.motd = this.motd;
};
CustomizationModule.prototype.setMotd = function (motd) {
this.motd = XSS.sanitizeHTML(motd);
this.sendMotd(this.channel.users);
};
CustomizationModule.prototype.onUserPostJoin = function (user) {
this.sendCSSJS([user]);
this.sendMotd([user]);
user.socket.typecheckedOn("setChannelCSS", TYPE_SETCSS, this.handleSetCSS.bind(this, user));
user.socket.typecheckedOn("setChannelJS", TYPE_SETJS, this.handleSetJS.bind(this, user));
user.socket.typecheckedOn("setMotd", TYPE_SETMOTD, this.handleSetMotd.bind(this, user));
};
CustomizationModule.prototype.sendCSSJS = function (users) {
var data = {
css: this.css,
js: this.js
};
users.forEach(function (u) {
u.socket.emit("channelCSSJS", data);
});
};
CustomizationModule.prototype.sendMotd = function (users) {
var data = this.motd;
users.forEach(function (u) {
u.socket.emit("setMotd", data);
});
};
CustomizationModule.prototype.handleSetCSS = function (user, data) {
if (!this.channel.modules.permissions.canSetCSS(user)) {
user.kick("Attempted setChannelCSS as non-admin");
return;
}
this.css = data.css.substring(0, 20000);
this.sendCSSJS(this.channel.users);
this.channel.logger.log("[mod] " + user.getName() + " updated the channel CSS");
};
CustomizationModule.prototype.handleSetJS = function (user, data) {
if (!this.channel.modules.permissions.canSetJS(user)) {
user.kick("Attempted setChannelJS as non-admin");
return;
}
this.js = data.js.substring(0, 20000);
this.sendCSSJS(this.channel.users);
this.channel.logger.log("[mod] " + user.getName() + " updated the channel JS");
};
CustomizationModule.prototype.handleSetMotd = function (user, data) {
if (!this.channel.modules.permissions.canEditMotd(user)) {
user.kick("Attempted setMotd with insufficient permission");
return;
}
var motd = data.motd.substring(0, 20000);
this.setMotd(motd);
this.channel.logger.log("[mod] " + user.getName() + " updated the MOTD");
};
module.exports = CustomizationModule;

56
src/channel/drink.js Normal file
View file

@ -0,0 +1,56 @@
var ChannelModule = require("./module");
function DrinkModule(channel) {
ChannelModule.apply(this, arguments);
this.drinks = 0;
}
DrinkModule.prototype = Object.create(ChannelModule.prototype);
DrinkModule.prototype.onUserPostJoin = function (user) {
user.socket.emit("drinkCount", this.drinks);
};
DrinkModule.prototype.onUserPreChat = function (user, data, cb) {
var msg = data.msg;
var perms = this.channel.modules.permissions;
if (msg.match(/^\/d-?[0-9]*/) && perms.canCallDrink(user)) {
msg = msg.substring(2);
var m = msg.match(/^(-?[0-9]+)/);
var count;
if (m) {
count = parseInt(m[1]);
if (isNaN(count) || count < -10000 || count > 10000) {
return;
}
msg = msg.replace(m[1], "").trim();
if (msg || count > 0) {
msg += " drink! (x" + count + ")";
} else {
this.drinks += count;
this.channel.broadcastAll("drinkCount", this.drinks);
return cb(null, ChannelModule.DENY);
}
} else {
msg = msg.trim() + " drink!";
count = 1;
}
this.drinks += count;
this.channel.broadcastAll("drinkCount", this.drinks);
data.msg = msg;
data.meta.addClass = "drink";
data.meta.forceShowName = true;
cb(null, ChannelModule.PASSTHROUGH);
} else {
cb(null, ChannelModule.PASSTHROUGH);
}
};
DrinkModule.prototype.onMediaChange = function () {
this.drinks = 0;
this.channel.broadcastAll("drinkCount", 0);
};
module.exports = DrinkModule;

205
src/channel/emotes.js Normal file
View file

@ -0,0 +1,205 @@
var ChannelModule = require("./module");
var XSS = require("../xss");
function EmoteList(defaults) {
if (!defaults) {
defaults = [];
}
this.emotes = defaults.map(validateEmote).filter(function (f) {
return f !== false;
});
}
EmoteList.prototype = {
pack: function () {
return Array.prototype.slice.call(this.emotes);
},
importList: function (emotes) {
this.emotes = Array.prototype.slice.call(emotes);
},
updateEmote: function (emote) {
var found = false;
for (var i = 0; i < this.emotes.length; i++) {
if (this.emotes[i].name === emote.name) {
found = true;
this.emotes[i] = emote;
break;
}
}
/* If no emote was updated, add a new one */
if (!found) {
this.emotes.push(emote);
}
},
removeEmote: function (emote) {
var found = false;
for (var i = 0; i < this.emotes.length; i++) {
if (this.emotes[i].name === emote.name) {
this.emotes.splice(i, 1);
break;
}
}
},
moveEmote: function (from, to) {
if (from < 0 || to < 0 ||
from >= this.emotes.length || to >= this.emotes.length) {
return false;
}
var f = this.emotes[from];
/* Offset from/to indexes to account for the fact that removing
an element changes the position of one of them.
I could have just done a swap, but it's already implemented this way
and it works. */
to = to > from ? to + 1 : to;
from = to > from ? from : from + 1;
this.emotes.splice(to, 0, f);
this.emotes.splice(from, 1);
return true;
},
};
function validateEmote(f) {
if (typeof f.name !== "string" || typeof f.image !== "string") {
return false;
}
f.image = f.image.substring(0, 1000);
f.image = XSS.sanitizeText(f.image);
var s = XSS.sanitizeText(f.name).replace(/([\\\.\?\+\*\$\^\|\(\)\[\]\{\}])/g, "\\$1");
s = "(^|\\s)" + s + "(?!\\S)";
f.source = s;
try {
new RegExp(f.source, "gi");
} catch (e) {
return false;
}
return f;
};
function EmoteModule(channel) {
ChannelModule.apply(this, arguments);
this.emotes = new EmoteList();
}
EmoteModule.prototype = Object.create(ChannelModule.prototype);
EmoteModule.prototype.load = function (data) {
if ("emotes" in data) {
for (var i = 0; i < data.emotes.length; i++) {
this.emotes.updateEmote(data.emotes[i]);
}
}
};
EmoteModule.prototype.save = function (data) {
data.emotes = this.emotes.pack();
};
EmoteModule.prototype.packInfo = function (data, isAdmin) {
if (isAdmin) {
data.emoteCount = this.emotes.emotes.length;
}
};
EmoteModule.prototype.onUserPostJoin = function (user) {
user.socket.on("updateEmote", this.handleUpdateEmote.bind(this, user));
user.socket.on("importEmotes", this.handleImportEmotes.bind(this, user));
user.socket.on("moveEmote", this.handleMoveEmote.bind(this, user));
user.socket.on("removeEmote", this.handleRemoveEmote.bind(this, user));
this.sendEmotes([user]);
};
EmoteModule.prototype.sendEmotes = function (users) {
var f = this.emotes.pack();
var chan = this.channel;
users.forEach(function (u) {
u.socket.emit("emoteList", f);
});
};
EmoteModule.prototype.handleUpdateEmote = function (user, data) {
if (typeof data !== "object") {
return;
}
if (!this.channel.modules.permissions.canEditEmotes(user)) {
return;
}
var f = validateEmote(data);
if (!f) {
return;
}
this.emotes.updateEmote(f);
var chan = this.channel;
chan.broadcastAll("updateEmote", f);
chan.logger.log("[mod] " + user.getName() + " updated emote: " + f.name + " -> " +
f.image);
};
EmoteModule.prototype.handleImportEmotes = function (user, data) {
if (!(data instanceof Array)) {
return;
}
/* Note: importing requires a different permission node than simply
updating/removing */
if (!this.channel.modules.permissions.canImportEmotes(user)) {
return;
}
this.emotes.importList(data.map(validateEmote).filter(function (f) {
return f !== false;
}));
this.sendEmotes(this.channel.users);
};
EmoteModule.prototype.handleRemoveEmote = function (user, data) {
if (typeof data !== "object") {
return;
}
if (!this.channel.modules.permissions.canEditEmotes(user)) {
return;
}
if (typeof data.name !== "string") {
return;
}
this.emotes.removeEmote(data);
this.channel.logger.log("[mod] " + user.getName() + " removed emote: " + data.name);
this.channel.broadcastAll("removeEmote", data);
};
EmoteModule.prototype.handleMoveEmote = function (user, data) {
if (typeof data !== "object") {
return;
}
if (!this.channel.modules.permissions.canEditEmotes(user)) {
return;
}
if (typeof data.to !== "number" || typeof data.from !== "number") {
return;
}
this.emotes.moveEmote(data.from, data.to);
};
module.exports = EmoteModule;

300
src/channel/filters.js Normal file
View file

@ -0,0 +1,300 @@
var FilterList = require("cytubefilters");
var ChannelModule = require("./module");
var XSS = require("../xss");
var Logger = require("../logger");
/*
* Converts JavaScript-style replacements ($1, $2, etc.) with
* PCRE-style (\1, \2, etc.)
*/
function fixReplace(replace) {
return replace.replace(/\$(\d)/g, "\\$1");
}
function validateFilter(f) {
if (typeof f.source !== "string" || typeof f.flags !== "string" ||
typeof f.replace !== "string") {
return null;
}
if (typeof f.name !== "string") {
f.name = f.source;
}
f.replace = fixReplace(f.replace.substring(0, 1000));
f.replace = XSS.sanitizeHTML(f.replace);
f.flags = f.flags.substring(0, 4);
try {
FilterList.checkValidRegex(f.source);
} catch (e) {
return null;
}
var filter = {
name: f.name,
source: f.source,
replace: fixReplace(f.replace),
flags: f.flags,
active: !!f.active,
filterlinks: !!f.filterlinks
};
return filter;
}
function makeDefaultFilter(name, source, flags, replace) {
return {
name: name,
source: source,
flags: flags,
replace: replace,
active: true,
filterlinks: false
};
}
const DEFAULT_FILTERS = [
makeDefaultFilter("monospace", "`(.+?)`", "g", "<code>\\1</code>"),
makeDefaultFilter("bold", "\\*(.+?)\\*", "g", "<strong>\\1</strong>"),
makeDefaultFilter("italic", "_(.+?)_", "g", "<em>\\1</em>"),
makeDefaultFilter("strike", "~~(.+?)~~", "g", "<s>\\1</s>"),
makeDefaultFilter("inline spoiler", "\\[sp\\](.*?)\\[\\/sp\\]", "ig",
"<span class=\"spoiler\">\\1</span>")
];
function ChatFilterModule(channel) {
ChannelModule.apply(this, arguments);
this.filters = new FilterList();
}
ChatFilterModule.prototype = Object.create(ChannelModule.prototype);
ChatFilterModule.prototype.load = function (data) {
if ("filters" in data) {
var filters = data.filters.map(validateFilter).filter(function (f) {
return f !== null;
});
try {
this.filters = new FilterList(filters);
} catch (e) {
Logger.errlog.log("Filter load failed: " + e + " (channel:" +
this.channel.name);
this.channel.logger.log("Failed to load filters: " + e);
}
} else {
this.filters = new FilterList(DEFAULT_FILTERS);
}
};
ChatFilterModule.prototype.save = function (data) {
data.filters = this.filters.pack();
};
ChatFilterModule.prototype.packInfo = function (data, isAdmin) {
if (isAdmin) {
data.chatFilterCount = this.filters.length;
}
};
ChatFilterModule.prototype.onUserPostJoin = function (user) {
user.socket.on("addFilter", this.handleAddFilter.bind(this, user));
user.socket.on("updateFilter", this.handleUpdateFilter.bind(this, user));
user.socket.on("importFilters", this.handleImportFilters.bind(this, user));
user.socket.on("moveFilter", this.handleMoveFilter.bind(this, user));
user.socket.on("removeFilter", this.handleRemoveFilter.bind(this, user));
user.socket.on("requestChatFilters", this.handleRequestChatFilters.bind(this, user));
};
ChatFilterModule.prototype.sendChatFilters = function (users) {
var f = this.filters.pack();
var chan = this.channel;
users.forEach(function (u) {
if (chan.modules.permissions.canEditFilters(u)) {
u.socket.emit("chatFilters", f);
}
});
};
ChatFilterModule.prototype.handleAddFilter = function (user, data) {
if (typeof data !== "object") {
return;
}
if (!this.channel.modules.permissions.canEditFilters(user)) {
return;
}
try {
FilterList.checkValidRegex(data.source);
} catch (e) {
user.socket.emit("errorMsg", {
msg: "Invalid regex: " + e.message,
alert: true
});
return;
}
data = validateFilter(data);
if (!data) {
return;
}
try {
this.filters.addFilter(data);
} catch (e) {
user.socket.emit("errorMsg", {
msg: "Filter add failed: " + e.message,
alert: true
});
return;
}
user.socket.emit("addFilterSuccess");
var chan = this.channel;
chan.users.forEach(function (u) {
if (chan.modules.permissions.canEditFilters(u)) {
u.socket.emit("updateChatFilter", data);
}
});
chan.logger.log("[mod] " + user.getName() + " added filter: " + data.name + " -> " +
"s/" + data.source + "/" + data.replace + "/" + data.flags +
" active: " + data.active + ", filterlinks: " + data.filterlinks);
};
ChatFilterModule.prototype.handleUpdateFilter = function (user, data) {
if (typeof data !== "object") {
return;
}
if (!this.channel.modules.permissions.canEditFilters(user)) {
return;
}
try {
FilterList.checkValidRegex(data.source);
} catch (e) {
user.socket.emit("errorMsg", {
msg: "Invalid regex: " + e.message,
alert: true
});
return;
}
data = validateFilter(data);
if (!data) {
return;
}
try {
this.filters.updateFilter(data);
} catch (e) {
user.socket.emit("errorMsg", {
msg: "Filter update failed: " + e.message,
alert: true
});
return;
}
var chan = this.channel;
chan.users.forEach(function (u) {
if (chan.modules.permissions.canEditFilters(u)) {
u.socket.emit("updateChatFilter", data);
}
});
chan.logger.log("[mod] " + user.getName() + " updated filter: " + data.name + " -> " +
"s/" + data.source + "/" + data.replace + "/" + data.flags +
" active: " + data.active + ", filterlinks: " + data.filterlinks);
};
ChatFilterModule.prototype.handleImportFilters = function (user, data) {
if (!(data instanceof Array)) {
return;
}
/* Note: importing requires a different permission node than simply
updating/removing */
if (!this.channel.modules.permissions.canImportFilters(user)) {
return;
}
try {
this.filters = new FilterList(data.map(validateFilter).filter(function (f) {
return f !== null;
}));
} catch (e) {
user.socket.emit("errorMsg", {
msg: "Filter import failed: " + e.message,
alert: true
});
return;
}
this.channel.logger.log("[mod] " + user.getName() + " imported the filter list");
this.sendChatFilters(this.channel.users);
};
ChatFilterModule.prototype.handleRemoveFilter = function (user, data) {
if (typeof data !== "object") {
return;
}
if (!this.channel.modules.permissions.canEditFilters(user)) {
return;
}
if (typeof data.name !== "string") {
return;
}
try {
this.filters.removeFilter(data);
} catch (e) {
user.socket.emit("errorMsg", {
msg: "Filter removal failed: " + e.message,
alert: true
});
return;
}
var chan = this.channel;
chan.users.forEach(function (u) {
if (chan.modules.permissions.canEditFilters(u)) {
u.socket.emit("deleteChatFilter", data);
}
});
this.channel.logger.log("[mod] " + user.getName() + " removed filter: " + data.name);
};
ChatFilterModule.prototype.handleMoveFilter = function (user, data) {
if (typeof data !== "object") {
return;
}
if (!this.channel.modules.permissions.canEditFilters(user)) {
return;
}
if (typeof data.to !== "number" || typeof data.from !== "number") {
return;
}
try {
this.filters.moveFilter(data.from, data.to);
} catch (e) {
user.socket.emit("errorMsg", {
msg: "Filter move failed: " + e.message,
alert: true
});
return;
}
};
ChatFilterModule.prototype.handleRequestChatFilters = function (user) {
this.sendChatFilters([user]);
};
module.exports = ChatFilterModule;

429
src/channel/kickban.js Normal file
View file

@ -0,0 +1,429 @@
var ChannelModule = require("./module");
var db = require("../database");
var Flags = require("../flags");
var util = require("../utilities");
var Account = require("../account");
var Q = require("q");
const TYPE_UNBAN = {
id: "number",
name: "string"
};
function KickBanModule(channel) {
ChannelModule.apply(this, arguments);
if (this.channel.modules.chat) {
this.channel.modules.chat.registerCommand("/kick", this.handleCmdKick.bind(this));
this.channel.modules.chat.registerCommand("/kickanons", this.handleCmdKickAnons.bind(this));
this.channel.modules.chat.registerCommand("/ban", this.handleCmdBan.bind(this));
this.channel.modules.chat.registerCommand("/ipban", this.handleCmdIPBan.bind(this));
this.channel.modules.chat.registerCommand("/banip", this.handleCmdIPBan.bind(this));
}
}
KickBanModule.prototype = Object.create(ChannelModule.prototype);
function checkIPBan(cname, ip, cb) {
db.channels.isIPBanned(cname, ip, function (err, banned) {
if (err) {
cb(false);
} else {
cb(banned);
}
});
}
function checkNameBan(cname, name, cb) {
db.channels.isNameBanned(cname, name, function (err, banned) {
if (err) {
cb(false);
} else {
cb(banned);
}
});
}
KickBanModule.prototype.onUserPreJoin = function (user, data, cb) {
if (!this.channel.is(Flags.C_REGISTERED)) {
return cb(null, ChannelModule.PASSTHROUGH);
}
var cname = this.channel.name;
checkIPBan(cname, user.realip, function (banned) {
if (banned) {
cb(null, ChannelModule.DENY);
user.kick("Your IP address is banned from this channel.");
} else {
checkNameBan(cname, user.getName(), function (banned) {
if (banned) {
cb(null, ChannelModule.DENY);
user.kick("Your username is banned from this channel.");
} else {
cb(null, ChannelModule.PASSTHROUGH);
}
});
}
});
};
KickBanModule.prototype.onUserPostJoin = function (user) {
if (!this.channel.is(Flags.C_REGISTERED)) {
return;
}
var chan = this.channel;
user.waitFlag(Flags.U_LOGGED_IN, function () {
chan.activeLock.lock();
db.channels.isNameBanned(chan.name, user.getName(), function (err, banned) {
if (!err && banned) {
user.kick("You are banned from this channel.");
if (chan.modules.chat) {
chan.modules.chat.sendModMessage(user.getName() + " was kicked (" +
"name is banned)");
}
}
chan.activeLock.release();
});
});
var self = this;
user.socket.on("requestBanlist", function () { self.sendBanlist([user]); });
user.socket.typecheckedOn("unban", TYPE_UNBAN, this.handleUnban.bind(this, user));
};
KickBanModule.prototype.sendBanlist = function (users) {
if (!this.channel.is(Flags.C_REGISTERED)) {
return;
}
var perms = this.channel.modules.permissions;
var bans = [];
var unmaskedbans = [];
db.channels.listBans(this.channel.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.cloakIP(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 (!perms.canBan(u)) {
return;
}
if (u.account.effectiveRank >= 255) {
u.socket.emit("banlist", unmaskedbans);
} else {
u.socket.emit("banlist", bans);
}
});
});
};
KickBanModule.prototype.sendUnban = function (users, data) {
var perms = this.channel.modules.permissions;
users.forEach(function (u) {
if (perms.canBan(u)) {
u.socket.emit("banlistRemove", data);
}
});
};
KickBanModule.prototype.handleCmdKick = function (user, msg, meta) {
if (!this.channel.modules.permissions.canKick(user)) {
return;
}
var args = msg.split(" ");
args.shift(); /* shift off /kick */
if (args.length === 0 || args[0].trim() === "") {
return user.socket.emit("errorMsg", {
msg: "No kick target specified. If you're trying to kick " +
"anonymous users, use /kickanons"
});
}
var name = args.shift().toLowerCase();
var reason = args.join(" ");
var target = null;
for (var i = 0; i < this.channel.users.length; i++) {
if (this.channel.users[i].getLowerName() === name) {
target = this.channel.users[i];
break;
}
}
if (target === null) {
return;
}
if (target.account.effectiveRank >= user.account.effectiveRank
|| target.account.globalRank > user.account.globalRank) {
return user.socket.emit("errorMsg", {
msg: "You do not have permission to kick " + target.getName()
});
}
target.kick(reason);
this.channel.logger.log("[mod] " + user.getName() + " kicked " + target.getName() +
" (" + reason + ")");
if (this.channel.modules.chat) {
this.channel.modules.chat.sendModMessage(user.getName() + " kicked " +
target.getName());
}
};
KickBanModule.prototype.handleCmdKickAnons = function (user, msg, meta) {
if (!this.channel.modules.permissions.canKick(user)) {
return;
}
var users = Array.prototype.slice.call(this.channel.users);
users.forEach(function (u) {
if (!u.is(Flags.U_LOGGED_IN)) {
u.kick("anonymous user");
}
});
this.channel.logger.log("[mod] " + user.getName() + " kicked anonymous users.");
if (this.channel.modules.chat) {
this.channel.modules.chat.sendModMessage(user.getName() + " kicked anonymous " +
"users");
}
};
/* /ban - name bans */
KickBanModule.prototype.handleCmdBan = function (user, msg, meta) {
var args = msg.split(" ");
args.shift(); /* shift off /ban */
if (args.length === 0 || args[0].trim() === "") {
return user.socket.emit("errorMsg", {
msg: "No ban target specified."
});
}
var name = args.shift().toLowerCase();
var reason = args.join(" ");
var chan = this.channel;
chan.activeLock.lock();
this.banName(user, name, reason, function (err) {
chan.activeLock.release();
});
};
/* /ipban - bans name and IP addresses associated with it */
KickBanModule.prototype.handleCmdIPBan = function (user, msg, meta) {
var args = msg.split(" ");
args.shift(); /* shift off /ipban */
if (args.length === 0 || args[0].trim() === "") {
return user.socket.emit("errorMsg", {
msg: "No ban target specified."
});
}
var name = args.shift().toLowerCase();
var range = false;
if (args[0] === "range") {
range = "range";
args.shift();
} else if (args[0] === "wrange") {
range = "wrange";
args.shift();
}
var reason = args.join(" ");
var chan = this.channel;
chan.activeLock.lock();
this.banAll(user, name, range, reason, function (err) {
chan.activeLock.release();
});
};
KickBanModule.prototype.banName = function (actor, name, reason, cb) {
var self = this;
reason = reason.substring(0, 255);
var chan = this.channel;
var error = function (what) {
actor.socket.emit("errorMsg", { msg: what });
cb(what);
};
if (!chan.modules.permissions.canBan(actor)) {
return error("You do not have ban permissions on this channel");
}
name = name.toLowerCase();
if (name === actor.getLowerName()) {
actor.socket.emit("costanza", {
msg: "You can't ban yourself"
});
return cb("Attempted to ban self");
}
Q.nfcall(Account.rankForName, name, { channel: chan.name })
.then(function (rank) {
if (rank >= actor.account.effectiveRank) {
throw "You don't have permission to ban " + name;
}
return Q.nfcall(db.channels.isNameBanned, chan.name, name);
}).then(function (banned) {
if (banned) {
throw name + " is already banned";
}
if (chan.dead) { throw null; }
return Q.nfcall(db.channels.ban, chan.name, "*", name, reason, actor.getName());
}).then(function () {
chan.logger.log("[mod] " + actor.getName() + " namebanned " + name);
if (chan.modules.chat) {
chan.modules.chat.sendModMessage(actor.getName() + " namebanned " + name,
chan.modules.permissions.permissions.ban);
}
return true;
}).then(function () {
self.kickBanTarget(name, null);
setImmediate(function () {
cb(null);
});
}).catch(error).done();
};
KickBanModule.prototype.banIP = function (actor, ip, name, reason, cb) {
var self = this;
reason = reason.substring(0, 255);
var masked = util.cloakIP(ip);
var chan = this.channel;
var error = function (what) {
actor.socket.emit("errorMsg", { msg: what });
cb(what);
};
if (!chan.modules.permissions.canBan(actor)) {
return error("You do not have ban permissions on this channel");
}
Q.nfcall(Account.rankForIP, ip, { channel: chan.name }).then(function (rank) {
if (rank >= actor.account.effectiveRank) {
throw "You don't have permission to ban IP " + masked;
}
return Q.nfcall(db.channels.isIPBanned, chan.name, ip);
}).then(function (banned) {
if (banned) {
throw masked + " is already banned";
}
if (chan.dead) { throw null; }
return Q.nfcall(db.channels.ban, chan.name, ip, name, reason, actor.getName());
}).then(function () {
var cloaked = util.cloakIP(ip);
chan.logger.log("[mod] " + actor.getName() + " banned " + cloaked + " (" + name + ")");
if (chan.modules.chat) {
chan.modules.chat.sendModMessage(actor.getName() + " banned " +
cloaked + " (" + name + ")",
chan.modules.permissions.permissions.ban);
}
}).then(function () {
self.kickBanTarget(name, ip);
setImmediate(function () {
cb(null);
});
}).catch(error).done();
};
KickBanModule.prototype.banAll = function (actor, name, range, reason, cb) {
var self = this;
reason = reason.substring(0, 255);
var chan = self.channel;
var error = function (what) {
cb(what);
};
if (!chan.modules.permissions.canBan(actor)) {
return error("You do not have ban permissions on this channel");
}
self.banName(actor, name, reason, function (err) {
if (err && err.indexOf("is already banned") === -1) {
cb(err);
} else {
db.getIPs(name, function (err, ips) {
if (err) {
return error(err);
}
var all = ips.map(function (ip) {
if (range === "range") {
ip = util.getIPRange(ip);
} else if (range === "wrange") {
ip = util.getWideIPRange(ip);
}
return Q.nfcall(self.banIP.bind(self), actor, ip, name, reason);
});
Q.all(all).then(function () {
setImmediate(cb);
}).catch(error).done();
});
}
});
};
KickBanModule.prototype.kickBanTarget = function (name, ip) {
name = name.toLowerCase();
for (var i = 0; i < this.channel.users.length; i++) {
if (this.channel.users[i].getLowerName() === name ||
this.channel.users[i].realip === ip) {
this.channel.users[i].kick("You're banned!");
}
}
};
KickBanModule.prototype.handleUnban = function (user, data) {
if (!this.channel.modules.permissions.canBan(user)) {
return;
}
var self = this;
this.channel.activeLock.lock();
db.channels.unbanId(this.channel.name, data.id, function (err) {
if (err) {
return user.socket.emit("errorMsg", {
msg: err
});
}
self.sendUnban(self.channel.users, data);
self.channel.logger.log("[mod] " + user.getName() + " unbanned " + data.name);
if (self.channel.modules.chat) {
var banperm = self.channel.modules.permissions.permissions.ban;
self.channel.modules.chat.sendModMessage(user.getName() + " unbanned " +
data.name, banperm);
}
self.channel.activeLock.release();
});
};
module.exports = KickBanModule;

111
src/channel/library.js Normal file
View file

@ -0,0 +1,111 @@
var ChannelModule = require("./module");
var Flags = require("../flags");
var util = require("../utilities");
var InfoGetter = require("../get-info");
var db = require("../database");
var Media = require("../media");
const TYPE_UNCACHE = {
id: "string"
};
const TYPE_SEARCH_MEDIA = {
source: "string,optional",
query: "string"
};
function LibraryModule(channel) {
ChannelModule.apply(this, arguments);
}
LibraryModule.prototype = Object.create(ChannelModule.prototype);
LibraryModule.prototype.onUserPostJoin = function (user) {
user.socket.typecheckedOn("uncache", TYPE_UNCACHE, this.handleUncache.bind(this, user));
user.socket.typecheckedOn("searchMedia", TYPE_SEARCH_MEDIA, this.handleSearchMedia.bind(this, user));
};
LibraryModule.prototype.cacheMedia = function (media) {
if (this.channel.is(Flags.C_REGISTERED) && !util.isLive(media.type)) {
db.channels.addToLibrary(this.channel.name, media);
}
};
LibraryModule.prototype.getItem = function (id, cb) {
db.channels.getLibraryItem(this.channel.name, id, function (err, row) {
if (err) {
cb(err, null);
} else {
var meta = JSON.parse(row.meta || "{}");
cb(null, new Media(row.id, row.title, row.seconds, row.type, meta));
}
});
};
LibraryModule.prototype.handleUncache = function (user, data) {
if (!this.channel.is(Flags.C_REGISTERED)) {
return;
}
if (!this.channel.modules.permissions.canUncache(user)) {
return;
}
var chan = this.channel;
chan.activeLock.lock();
db.channels.deleteFromLibrary(chan.name, data.id, function (err, res) {
if (chan.dead || err) {
return;
}
chan.logger.log("[library] " + user.getName() + " deleted " + data.id +
"from the library");
chan.activeLock.release();
});
};
LibraryModule.prototype.handleSearchMedia = function (user, data) {
var query = data.query.substring(0, 100);
var searchYT = function () {
InfoGetter.Getters.ytSearch(query, function (e, vids) {
if (!e) {
user.socket.emit("searchResults", {
source: "yt",
results: vids
});
}
});
};
if (data.source === "yt" || !this.channel.is(Flags.C_REGISTERED) ||
!this.channel.modules.permissions.canSeePlaylist(user)) {
searchYT();
} else {
db.channels.searchLibrary(this.channel.name, query, function (err, res) {
if (err) {
res = [];
}
if (res.length === 0) {
return searchYT();
}
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);
});
user.socket.emit("searchResults", {
source: "library",
results: res
});
});
}
};
module.exports = LibraryModule;

View file

@ -0,0 +1,209 @@
var Vimeo = require("cytube-mediaquery/lib/provider/vimeo");
var ChannelModule = require("./module");
var Config = require("../config");
var InfoGetter = require("../get-info");
var Logger = require("../logger");
function MediaRefresherModule(channel) {
ChannelModule.apply(this, arguments);
this._interval = false;
this._media = null;
this._playlist = channel.modules.playlist;
}
MediaRefresherModule.prototype = Object.create(ChannelModule.prototype);
MediaRefresherModule.prototype.onPreMediaChange = function (data, cb) {
if (this._interval) clearInterval(this._interval);
this._media = data;
var pl = this._playlist;
switch (data.type) {
case "gd":
pl._refreshing = true;
return this.initGoogleDocs(data, function () {
pl._refreshing = false;
cb(null, ChannelModule.PASSTHROUGH);
});
case "gp":
pl._refreshing = true;
return this.initGooglePlus(data, function () {
pl._refreshing = false;
cb(null, ChannelModule.PASSTHROUGH);
});
case "vi":
pl._refreshing = true;
return this.initVimeo(data, function () {
pl._refreshing = false;
cb(null, ChannelModule.PASSTHROUGH);
});
default:
return cb(null, ChannelModule.PASSTHROUGH);
}
};
MediaRefresherModule.prototype.initGoogleDocs = function (data, cb) {
var self = this;
self.refreshGoogleDocs(data, cb);
/*
* Refresh every 55 minutes.
* The expiration is 1 hour, but refresh 5 minutes early to be safe
*/
self._interval = setInterval(function () {
self.refreshGoogleDocs(data);
}, 55 * 60 * 1000);
};
MediaRefresherModule.prototype.initVimeo = function (data, cb) {
if (!Config.get("vimeo-workaround")) {
if (cb) cb();
return;
}
var self = this;
self.channel.activeLock.lock();
Vimeo.extract(data.id).then(function (direct) {
if (self.dead || self.channel.dead)
return;
if (self._media === data) {
data.meta.direct = direct;
self.channel.logger.log("[mediarefresher] Refreshed vimeo video with ID " +
data.id);
}
self.channel.activeLock.release();
if (cb) cb();
}).catch(function (err) {
Logger.errlog.log("Unexpected vimeo::extract() fail: " + err.stack);
if (cb) cb();
});
};
MediaRefresherModule.prototype.refreshGoogleDocs = function (media, cb) {
var self = this;
if (self.dead || self.channel.dead) {
return;
}
self.channel.activeLock.lock();
InfoGetter.getMedia(media.id, "gd", function (err, data) {
if (self.dead || self.channel.dead) {
return;
}
if (typeof err === "string") {
err = err.replace(/Google Drive lookup failed for [\w-]+: /, "");
err = err.replace(/Forbidden/, "Access Denied");
err = err.replace(/You don't have permission to access this video\./,
"Access Denied");
}
switch (err) {
case "Moved Temporarily":
self.channel.logger.log("[mediarefresher] Google Docs refresh failed " +
"(likely redirect to login page-- make sure it is shared " +
"correctly)");
self.channel.activeLock.release();
if (cb) cb();
return;
case "Access Denied":
case "Not Found":
case "Internal Server Error":
case "Service Unavailable":
case "Google Drive does not permit videos longer than 1 hour to be played":
case "Google Drive videos must be shared publicly":
self.channel.logger.log("[mediarefresher] Google Docs refresh failed: " +
err);
self.channel.activeLock.release();
if (cb) cb();
return;
default:
if (err) {
self.channel.logger.log("[mediarefresher] Google Docs refresh failed: " +
err);
Logger.errlog.log("Google Docs refresh failed for ID " + media.id +
": " + err);
self.channel.activeLock.release();
if (cb) cb();
return;
}
}
if (media !== self._media) {
self.channel.activeLock.release();
if (cb) cb();
return;
}
self.channel.logger.log("[mediarefresher] Refreshed Google Docs video with ID " +
media.id);
media.meta = data.meta;
self.channel.activeLock.release();
if (cb) cb();
});
};
MediaRefresherModule.prototype.initGooglePlus = function (media, cb) {
var self = this;
if (self.dead || self.channel.dead) {
return;
}
self.channel.activeLock.lock();
InfoGetter.getMedia(media.id, "gp", function (err, data) {
if (self.dead || self.channel.dead) {
return;
}
if (typeof err === "string") {
err = err.replace(/Forbidden/, "Access Denied");
}
switch (err) {
case "Access Denied":
case "Not Found":
case "Internal Server Error":
case "Service Unavailable":
case "The video is still being processed":
case "A processing error has occured":
case "The video has been processed but is not yet accessible":
case ("Unable to retreive video information. Check that the video exists " +
"and is shared publicly"):
self.channel.logger.log("[mediarefresher] Google+ refresh failed: " +
err);
self.channel.activeLock.release();
if (cb) cb();
return;
default:
if (err) {
self.channel.logger.log("[mediarefresher] Google+ refresh failed: " +
err);
Logger.errlog.log("Google+ refresh failed for ID " + media.id +
": " + err);
self.channel.activeLock.release();
if (cb) cb();
return;
}
}
if (media !== self._media) {
self.channel.activeLock.release();
if (cb) cb();
return;
}
self.channel.logger.log("[mediarefresher] Refreshed Google+ video with ID " +
media.id);
media.meta = data.meta;
self.channel.activeLock.release();
if (cb) cb();
});
};
module.exports = MediaRefresherModule;

81
src/channel/module.js Normal file
View file

@ -0,0 +1,81 @@
function ChannelModule(channel) {
this.channel = channel;
}
ChannelModule.prototype = {
/**
* Called when the channel is loading its data from a JSON object.
*/
load: function (data) {
},
/**
* Called when the channel is saving its state to a JSON object.
*/
save: function (data) {
},
/**
* Called when the channel is being unloaded
*/
unload: function () {
},
/**
* Called to pack info, e.g. for channel detail view
*/
packInfo: function (data, isAdmin) {
},
/**
* Called when a user is attempting to join a channel.
*
* data is the data sent by the client with the joinChannel
* packet.
*/
onUserPreJoin: function (user, data, cb) {
cb(null, ChannelModule.PASSTHROUGH);
},
/**
* Called after a user has been accepted to the channel.
*/
onUserPostJoin: function (user) {
},
/**
* Called after a user has been disconnected from the channel.
*/
onUserPart: function (user) {
},
/**
* Called when a chatMsg event is received
*/
onUserPreChat: function (user, data, cb) {
cb(null, ChannelModule.PASSTHROUGH);
},
/**
* Called before a new video begins playing
*/
onPreMediaChange: function (data, cb) {
cb(null, ChannelModule.PASSTHROUGH);
},
/**
* Called when a new video begins playing
*/
onMediaChange: function (data) {
},
};
/* Channel module callback return codes */
ChannelModule.ERROR = -1;
ChannelModule.PASSTHROUGH = 0;
ChannelModule.DENY = 1;
module.exports = ChannelModule;

278
src/channel/opts.js Normal file
View file

@ -0,0 +1,278 @@
var ChannelModule = require("./module");
var Config = require("../config");
var Utilities = require("../utilities");
var url = require("url");
function OptionsModule(channel) {
ChannelModule.apply(this, arguments);
this.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: this.channel.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)
allow_dupes: false, // Allow duplicate videos on the playlist
torbanned: false, // Block connections from Tor exit nodes
allow_ascii_control: false,// Allow ASCII control characters (\x00-\x1f)
playlist_max_per_user: 0 // Maximum number of playlist items per user
};
}
OptionsModule.prototype = Object.create(ChannelModule.prototype);
OptionsModule.prototype.load = function (data) {
if ("opts" in data) {
for (var key in this.opts) {
if (key in data.opts) {
this.opts[key] = data.opts[key];
}
}
}
this.opts.chat_antiflood_params.burst = Math.min(20,
this.opts.chat_antiflood_params.burst);
this.opts.chat_antiflood_params.sustained = Math.min(10,
this.opts.chat_antiflood_params.sustained);
};
OptionsModule.prototype.save = function (data) {
data.opts = this.opts;
};
OptionsModule.prototype.packInfo = function (data, isAdmin) {
data.pagetitle = this.opts.pagetitle;
data.public = this.opts.show_public;
if (isAdmin) {
data.hasPassword = this.opts.password !== false;
}
};
OptionsModule.prototype.get = function (key) {
return this.opts[key];
};
OptionsModule.prototype.set = function (key, value) {
this.opts[key] = value;
};
OptionsModule.prototype.onUserPostJoin = function (user) {
user.socket.on("setOptions", this.handleSetOptions.bind(this, user));
this.sendOpts([user]);
};
OptionsModule.prototype.sendOpts = function (users) {
var opts = this.opts;
if (users === this.channel.users) {
this.channel.broadcastAll("channelOpts", opts);
} else {
users.forEach(function (user) {
user.socket.emit("channelOpts", opts);
});
}
};
OptionsModule.prototype.getPermissions = function () {
return this.channel.modules.permissions;
};
OptionsModule.prototype.handleSetOptions = function (user, data) {
if (typeof data !== "object") {
return;
}
if (!this.getPermissions().canSetOptions(user)) {
user.kick("Attempted setOptions as a non-moderator");
return;
}
if ("allow_voteskip" in data) {
this.opts.allow_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.channel.users.forEach(function (u) {
u.autoAFK();
});
}
}
if ("pagetitle" in data && user.account.effectiveRank >= 3) {
var title = (""+data.pagetitle).substring(0, 100);
if (!title.trim().match(Config.get("reserved-names.pagetitles"))) {
this.opts.pagetitle = (""+data.pagetitle).substring(0, 100);
} else {
user.socket.emit("errorMsg", {
msg: "That pagetitle is reserved",
alert: true
});
}
}
if ("maxlength" in data) {
var ml = 0;
if (typeof data.maxlength !== "number") {
ml = Utilities.parseTime(data.maxlength);
} else {
ml = parseInt(data.maxlength);
}
if (isNaN(ml) || ml < 0) {
ml = 0;
}
this.opts.maxlength = ml;
}
if ("externalcss" in data && user.account.effectiveRank >= 3) {
var link = (""+data.externalcss).substring(0, 255);
if (!link) {
this.opts.externalcss = "";
} else {
try {
var data = url.parse(link);
if (!data.protocol || !data.protocol.match(/^(https?|ftp):/)) {
throw "Unacceptable protocol " + data.protocol;
} else if (!data.host) {
throw "URL is missing host";
} else {
link = data.href;
}
} catch (e) {
user.socket.emit("errorMsg", {
msg: "Invalid URL for external CSS: " + e,
alert: true
});
return;
}
this.opts.externalcss = link;
}
}
if ("externaljs" in data && user.account.effectiveRank >= 3) {
var link = (""+data.externaljs).substring(0, 255);
if (!link) {
this.opts.externaljs = "";
} else {
try {
var data = url.parse(link);
if (!data.protocol || !data.protocol.match(/^(https?|ftp):/)) {
throw "Unacceptable protocol " + data.protocol;
} else if (!data.host) {
throw "URL is missing host";
} else {
link = data.href;
}
} catch (e) {
user.socket.emit("errorMsg", {
msg: "Invalid URL for external JS: " + e,
alert: true
});
return;
}
this.opts.externaljs = link;
}
}
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;
}
b = Math.min(20, b);
var s = parseFloat(data.chat_antiflood_params.sustained);
if (isNaN(s) || s <= 0) {
s = 1;
}
s = Math.min(10, s);
var c = b / s;
this.opts.chat_antiflood_params = {
burst: b,
sustained: s,
cooldown: c
};
}
if ("show_public" in data && user.account.effectiveRank >= 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.account.effectiveRank >= 3) {
var pw = data.password + "";
pw = pw === "" ? false : pw.substring(0, 100);
this.opts.password = pw;
}
if ("allow_dupes" in data) {
this.opts.allow_dupes = Boolean(data.allow_dupes);
}
if ("torbanned" in data && user.account.effectiveRank >= 3) {
this.opts.torbanned = Boolean(data.torbanned);
}
if ("allow_ascii_control" in data && user.account.effectiveRank >= 3) {
this.opts.allow_ascii_control = Boolean(data.allow_ascii_control);
}
if ("playlist_max_per_user" in data && user.account.effectiveRank >= 3) {
var max = parseInt(data.playlist_max_per_user);
if (!isNaN(max) && max >= 0) {
this.opts.playlist_max_per_user = max;
}
}
this.channel.logger.log("[mod] " + user.getName() + " updated channel options");
this.sendOpts(this.channel.users);
};
module.exports = OptionsModule;

392
src/channel/permissions.js Normal file
View file

@ -0,0 +1,392 @@
var ChannelModule = require("./module");
var User = require("../user");
const DEFAULT_PERMISSIONS = {
seeplaylist: -1, // See the playlist
playlistadd: 1.5, // Add video to the playlist
playlistnext: 1.5, // Add a video next on the playlist
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
playlistaddrawfile: 2, // Add raw file 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
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
viewvoteskip: 1.5, // View voteskip results
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
emoteedit: 3, // Control emotes
emoteimport: 3, // Import emote list
playlistlock: 2, // Lock/unlock the playlist
leaderctl: 2, // Give/take leader
drink: 1.5, // Use the /d command
chat: 0, // Send chat messages
chatclear: 2, // Use the /clear command
exceedmaxitems: 2 // Exceed maximum items per user limit
};
function PermissionsModule(channel) {
ChannelModule.apply(this, arguments);
this.permissions = {};
this.openPlaylist = false;
}
PermissionsModule.prototype = Object.create(ChannelModule.prototype);
PermissionsModule.prototype.load = function (data) {
this.permissions = {};
var preset = "permissions" in data ? data.permissions : {};
for (var key in DEFAULT_PERMISSIONS) {
if (key in preset) {
this.permissions[key] = preset[key];
} else {
this.permissions[key] = DEFAULT_PERMISSIONS[key];
}
}
if ("openPlaylist" in data) {
this.openPlaylist = data.openPlaylist;
} else if ("playlistLock" in data) {
this.openPlaylist = !data.playlistLock;
}
};
PermissionsModule.prototype.save = function (data) {
data.permissions = this.permissions;
data.openPlaylist = this.openPlaylist;
};
PermissionsModule.prototype.hasPermission = function (account, node) {
if (account instanceof User) {
account = account.account;
}
if (node.indexOf("playlist") === 0 && this.openPlaylist &&
account.effectiveRank >= this.permissions["o"+node]) {
return true;
}
return account.effectiveRank >= this.permissions[node];
};
PermissionsModule.prototype.sendPermissions = function (users) {
var perms = this.permissions;
if (users === this.channel.users) {
this.channel.broadcastAll("setPermissions", perms);
} else {
users.forEach(function (u) {
u.socket.emit("setPermissions", perms);
});
}
};
PermissionsModule.prototype.sendPlaylistLock = function (users) {
if (users === this.channel.users) {
this.channel.broadcastAll("setPlaylistLocked", !this.openPlaylist);
} else {
var locked = !this.openPlaylist;
users.forEach(function (u) {
u.socket.emit("setPlaylistLocked", locked);
});
}
};
PermissionsModule.prototype.onUserPostJoin = function (user) {
user.socket.on("setPermissions", this.handleSetPermissions.bind(this, user));
user.socket.on("togglePlaylistLock", this.handleTogglePlaylistLock.bind(this, user));
this.sendPermissions([user]);
this.sendPlaylistLock([user]);
};
PermissionsModule.prototype.handleTogglePlaylistLock = function (user) {
if (!this.hasPermission(user, "playlistlock")) {
return;
}
this.openPlaylist = !this.openPlaylist;
if (this.openPlaylist) {
this.channel.logger.log("[playlist] " + user.getName() + " unlocked the playlist");
} else {
this.channel.logger.log("[playlist] " + user.getName() + " locked the playlist");
}
this.sendPlaylistLock(this.channel.users);
};
PermissionsModule.prototype.handleSetPermissions = function (user, perms) {
if (typeof perms !== "object") {
return;
}
if (!this.canSetPermissions(user)) {
user.kick("Attempted setPermissions as a non-admin");
return;
}
for (var key in perms) {
if (typeof perms[key] !== "number") {
perms[key] = parseFloat(perms[key]);
if (isNaN(perms[key])) {
delete perms[key];
}
}
}
for (var key in perms) {
if (key in this.permissions) {
this.permissions[key] = perms[key];
}
}
if ("seeplaylist" in perms) {
if (this.channel.modules.playlist) {
this.channel.modules.playlist.sendPlaylist(this.channel.users);
}
}
this.channel.logger.log("[mod] " + user.getName() + " updated permissions");
this.sendPermissions(this.channel.users);
};
PermissionsModule.prototype.canAddVideo = function (account) {
return this.hasPermission(account, "playlistadd");
};
PermissionsModule.prototype.canSetTemp = function (account) {
return this.hasPermission(account, "settemp");
};
PermissionsModule.prototype.canSeePlaylist = function (account) {
return this.hasPermission(account, "seeplaylist");
};
PermissionsModule.prototype.canAddList = function (account) {
return this.hasPermission(account, "playlistaddlist");
};
PermissionsModule.prototype.canAddNonTemp = function (account) {
return this.hasPermission(account, "addnontemp");
};
PermissionsModule.prototype.canAddNext = function (account) {
return this.hasPermission(account, "playlistnext");
};
PermissionsModule.prototype.canAddLive = function (account) {
return this.hasPermission(account, "playlistaddlive");
};
PermissionsModule.prototype.canAddCustom = function (account) {
return this.hasPermission(account, "playlistaddcustom");
};
PermissionsModule.prototype.canAddRawFile = function (account) {
return this.hasPermission(account, "playlistaddrawfile");
};
PermissionsModule.prototype.canMoveVideo = function (account) {
return this.hasPermission(account, "playlistmove");
};
PermissionsModule.prototype.canDeleteVideo = function (account) {
return this.hasPermission(account, "playlistdelete")
};
PermissionsModule.prototype.canSkipVideo = function (account) {
return this.hasPermission(account, "playlistjump");
};
PermissionsModule.prototype.canToggleTemporary = function (account) {
return this.hasPermission(account, "settemp");
};
PermissionsModule.prototype.canExceedMaxLength = function (account) {
return this.hasPermission(account, "exceedmaxlength");
};
PermissionsModule.prototype.canShufflePlaylist = function (account) {
return this.hasPermission(account, "playlistshuffle");
};
PermissionsModule.prototype.canClearPlaylist = function (account) {
return this.hasPermission(account, "playlistclear");
};
PermissionsModule.prototype.canLockPlaylist = function (account) {
return this.hasPermission(account, "playlistlock");
};
PermissionsModule.prototype.canAssignLeader = function (account) {
return this.hasPermission(account, "leaderctl");
};
PermissionsModule.prototype.canControlPoll = function (account) {
return this.hasPermission(account, "pollctl");
};
PermissionsModule.prototype.canVote = function (account) {
return this.hasPermission(account, "pollvote");
};
PermissionsModule.prototype.canViewHiddenPoll = function (account) {
return this.hasPermission(account, "viewhiddenpoll");
};
PermissionsModule.prototype.canVoteskip = function (account) {
return this.hasPermission(account, "voteskip");
};
PermissionsModule.prototype.canSeeVoteskipResults = function (actor) {
return this.hasPermission(actor, "viewvoteskip");
};
PermissionsModule.prototype.canMute = function (actor) {
return this.hasPermission(actor, "mute");
};
PermissionsModule.prototype.canKick = function (actor) {
return this.hasPermission(actor, "kick");
};
PermissionsModule.prototype.canBan = function (actor) {
return this.hasPermission(actor, "ban");
};
PermissionsModule.prototype.canEditMotd = function (actor) {
return this.hasPermission(actor, "motdedit");
};
PermissionsModule.prototype.canEditFilters = function (actor) {
return this.hasPermission(actor, "filteredit");
};
PermissionsModule.prototype.canImportFilters = function (actor) {
return this.hasPermission(actor, "filterimport");
};
PermissionsModule.prototype.canEditEmotes = function (actor) {
return this.hasPermission(actor, "emoteedit");
};
PermissionsModule.prototype.canImportEmotes = function (actor) {
return this.hasPermission(actor, "emoteimport");
};
PermissionsModule.prototype.canCallDrink = function (actor) {
return this.hasPermission(actor, "drink");
};
PermissionsModule.prototype.canChat = function (actor) {
return this.hasPermission(actor, "chat");
};
PermissionsModule.prototype.canClearChat = function (actor) {
return this.hasPermission(actor, "chatclear");
};
PermissionsModule.prototype.canSetOptions = function (actor) {
if (actor instanceof User) {
actor = actor.account;
}
return actor.effectiveRank >= 2;
};
PermissionsModule.prototype.canSetCSS = function (actor) {
if (actor instanceof User) {
actor = actor.account;
}
return actor.effectiveRank >= 3;
};
PermissionsModule.prototype.canSetJS = function (actor) {
if (actor instanceof User) {
actor = actor.account;
}
return actor.effectiveRank >= 3;
};
PermissionsModule.prototype.canSetPermissions = function (actor) {
if (actor instanceof User) {
actor = actor.account;
}
return actor.effectiveRank >= 3;
};
PermissionsModule.prototype.canUncache = function (actor) {
if (actor instanceof User) {
actor = actor.account;
}
return actor.effectiveRank >= 2;
};
PermissionsModule.prototype.canExceedMaxItemsPerUser = function (actor) {
return this.hasPermission(actor, "exceedmaxitems");
};
PermissionsModule.prototype.loadUnregistered = function () {
var perms = {
seeplaylist: -1,
playlistadd: -1, // Add video to the playlist
playlistnext: 0,
playlistmove: 0, // Move a video on the playlist
playlistdelete: 0, // Delete a video from the playlist
playlistjump: 0, // Start a different video on the playlist
playlistaddlist: 0, // Add a list of videos to the playlist
oplaylistadd: -1, // Same as above, but for open (unlocked) playlist
oplaylistnext: 0,
oplaylistmove: 0,
oplaylistdelete: 0,
oplaylistjump: 0,
oplaylistaddlist: 0,
playlistaddcustom: 0, // Add custom embed to the playlist
playlistaddlive: 0, // Add a livestream to the playlist
exceedmaxlength: 0, // Add a video longer than the maximum length set
addnontemp: 0, // Add a permanent video to the playlist
settemp: 0, // Toggle temporary status of a playlist item
playlistshuffle: 0, // Shuffle the playlist
playlistclear: 0, // Clear the playlist
pollctl: 0, // Open/close polls
pollvote: -1, // Vote in polls
viewhiddenpoll: 1.5, // View results of hidden polls
voteskip: -1, // Vote to skip the current video
viewvoteskip: 1.5, // View voteskip results
playlistlock: 2, // Lock/unlock the playlist
leaderctl: 0, // Give/take leader
drink: 0, // Use the /d command
chat: 0, // Send chat messages
chatclear: 2, // Use the /clear command
exceedmaxitems: 2 // Exceed max items per user
};
for (var key in perms) {
this.permissions[key] = perms[key];
}
this.openPlaylist = true;
};
module.exports = PermissionsModule;

1372
src/channel/playlist.js Normal file

File diff suppressed because it is too large Load diff

186
src/channel/poll.js Normal file
View file

@ -0,0 +1,186 @@
var ChannelModule = require("./module");
var Poll = require("../poll").Poll;
const TYPE_NEW_POLL = {
title: "string",
timeout: "number,optional",
obscured: "boolean",
opts: "array"
};
const TYPE_VOTE = {
option: "number"
};
function PollModule(channel) {
ChannelModule.apply(this, arguments);
this.poll = null;
if (this.channel.modules.chat) {
this.channel.modules.chat.registerCommand("poll", this.handlePollCmd.bind(this, false));
this.channel.modules.chat.registerCommand("hpoll", this.handlePollCmd.bind(this, true));
}
}
PollModule.prototype = Object.create(ChannelModule.prototype);
PollModule.prototype.unload = function () {
if (this.poll && this.poll.timer) {
clearTimeout(this.poll.timer);
}
};
PollModule.prototype.load = function (data) {
if ("poll" in data) {
if (data.poll !== null) {
this.poll = new Poll(data.poll.initiator, "", [], data.poll.obscured);
this.poll.title = data.poll.title;
this.poll.options = data.poll.options;
this.poll.counts = data.poll.counts;
this.poll.votes = data.poll.votes;
}
}
};
PollModule.prototype.save = function (data) {
if (this.poll === null) {
data.poll = null;
return;
}
data.poll = {
title: this.poll.title,
initiator: this.poll.initiator,
options: this.poll.options,
counts: this.poll.counts,
votes: this.poll.votes,
obscured: this.poll.obscured
};
};
PollModule.prototype.onUserPostJoin = function (user) {
this.sendPoll([user]);
user.socket.typecheckedOn("newPoll", TYPE_NEW_POLL, this.handleNewPoll.bind(this, user));
user.socket.typecheckedOn("vote", TYPE_VOTE, this.handleVote.bind(this, user));
user.socket.on("closePoll", this.handleClosePoll.bind(this, user));
};
PollModule.prototype.onUserPart = function(user) {
if (this.poll) {
this.poll.unvote(user.realip);
this.sendPollUpdate(this.channel.users);
}
};
PollModule.prototype.sendPoll = function (users) {
if (!this.poll) {
return;
}
var obscured = this.poll.packUpdate(false);
var unobscured = this.poll.packUpdate(true);
var perms = this.channel.modules.permissions;
users.forEach(function (u) {
u.socket.emit("closePoll");
if (perms.canViewHiddenPoll(u)) {
u.socket.emit("newPoll", unobscured);
} else {
u.socket.emit("newPoll", obscured);
}
});
};
PollModule.prototype.sendPollUpdate = function (users) {
if (!this.poll) {
return;
}
var obscured = this.poll.packUpdate(false);
var unobscured = this.poll.packUpdate(true);
var perms = this.channel.modules.permissions;
users.forEach(function (u) {
if (perms.canViewHiddenPoll(u)) {
u.socket.emit("updatePoll", unobscured);
} else {
u.socket.emit("updatePoll", obscured);
}
});
};
PollModule.prototype.handleNewPoll = function (user, data) {
if (!this.channel.modules.permissions.canControlPoll(user)) {
return;
}
var title = data.title.substring(0, 255);
var opts = data.opts.map(function (x) { return (""+x).substring(0, 255); });
var obscured = data.obscured;
var poll = new Poll(user.getName(), title, opts, obscured);
var self = this;
if (data.hasOwnProperty("timeout") && !isNaN(data.timeout) && data.timeout > 0) {
poll.timer = setTimeout(function () {
if (self.poll === poll) {
self.handleClosePoll({
getName: function () { return "[poll timer]" },
effectiveRank: 255
});
}
}, data.timeout * 1000);
}
this.poll = poll;
this.sendPoll(this.channel.users);
this.channel.logger.log("[poll] " + user.getName() + " opened poll: '" + poll.title + "'");
};
PollModule.prototype.handleVote = function (user, data) {
if (!this.channel.modules.permissions.canVote(user)) {
return;
}
if (this.poll) {
this.poll.vote(user.realip, data.option);
this.sendPollUpdate(this.channel.users);
}
};
PollModule.prototype.handleClosePoll = function (user) {
if (!this.channel.modules.permissions.canControlPoll(user)) {
return;
}
if (this.poll) {
if (this.poll.obscured) {
this.poll.obscured = false;
this.channel.broadcastAll("updatePoll", this.poll.packUpdate(true));
}
if (this.poll.timer) {
clearTimeout(this.poll.timer);
}
this.channel.broadcastAll("closePoll");
this.channel.logger.log("[poll] " + user.getName() + " closed the active poll");
this.poll = null;
}
};
PollModule.prototype.handlePollCmd = function (obscured, user, msg, meta) {
if (!this.channel.modules.permissions.canControlPoll(user)) {
return;
}
msg = msg.replace(/^\/h?poll/, "");
var args = msg.split(",");
var title = args.shift();
var poll = new Poll(user.getName(), title, args, obscured);
this.poll = poll;
this.sendPoll(this.channel.users);
this.channel.logger.log("[poll] " + user.getName() + " opened poll: '" + poll.title + "'");
};
module.exports = PollModule;

192
src/channel/ranks.js Normal file
View file

@ -0,0 +1,192 @@
var ChannelModule = require("./module");
var Flags = require("../flags");
var Account = require("../account");
var db = require("../database");
const TYPE_SET_CHANNEL_RANK = {
name: "string",
rank: "number"
};
function RankModule(channel) {
ChannelModule.apply(this, arguments);
if (this.channel.modules.chat) {
this.channel.modules.chat.registerCommand("/rank", this.handleCmdRank.bind(this));
}
}
RankModule.prototype = Object.create(ChannelModule.prototype);
RankModule.prototype.onUserPostJoin = function (user) {
user.socket.typecheckedOn("setChannelRank", TYPE_SET_CHANNEL_RANK, this.handleRankChange.bind(this, user));
var self = this;
user.socket.on("requestChannelRanks", function () {
self.sendChannelRanks([user]);
});
};
RankModule.prototype.sendChannelRanks = function (users) {
if (!this.channel.is(Flags.C_REGISTERED)) {
return;
}
db.channels.allRanks(this.channel.name, function (err, ranks) {
if (err) {
return;
}
users.forEach(function (u) {
if (u.account.effectiveRank >= 3) {
u.socket.emit("channelRanks", ranks);
}
});
});
};
RankModule.prototype.handleCmdRank = function (user, msg, meta) {
var args = msg.split(" ");
args.shift(); /* shift off /rank */
var name = args.shift();
var rank = parseInt(args.shift());
if (!name || isNaN(rank)) {
user.socket.emit("noflood", {
action: "/rank",
msg: "Syntax: /rank <username> <rank>. <rank> must be a positive integer > 1"
});
return;
}
this.handleRankChange(user, { name: name, rank: rank });
};
RankModule.prototype.handleRankChange = function (user, data) {
if (user.account.effectiveRank < 3) {
return;
}
var rank = data.rank;
var userrank = user.account.effectiveRank;
var name = data.name.substring(0, 20).toLowerCase();
if (!name.match(/^[a-zA-Z0-9_-]{1,20}$/)) {
user.socket.emit("channelRankFail", {
msg: "Invalid target name " + data.name
});
return;
}
if (isNaN(rank) || rank < 1 || (rank >= userrank && !(userrank === 4 && rank === 4))) {
user.socket.emit("channelRankFail", {
msg: "Updating user rank failed: You can't promote someone to a rank equal " +
"or higher than yourself, or demote them to below rank 1."
});
return;
}
var receiver;
var lowerName = name.toLowerCase();
for (var i = 0; i < this.channel.users.length; i++) {
if (this.channel.users[i].getLowerName() === lowerName) {
receiver = this.channel.users[i];
break;
}
}
if (name === user.getLowerName()) {
user.socket.emit("channelRankFail", {
msg: "Updating user rank failed: You can't promote or demote yourself."
});
return;
}
if (!this.channel.is(Flags.C_REGISTERED)) {
user.socket.emit("channelRankFail", {
msg: "Updating user rank failed: in an unregistered channel, a user must " +
"be online in the channel in order to have their rank changed."
});
return;
}
if (receiver) {
var current = Math.max(receiver.account.globalRank, receiver.account.channelRank);
if (current >= userrank && !(userrank === 4 && current === 4)) {
user.socket.emit("channelRankFail", {
msg: "Updating user rank failed: You can't promote or demote "+
"someone who has equal or higher rank than yourself"
});
return;
}
receiver.account.channelRank = rank;
receiver.account.effectiveRank = Math.max(receiver.account.globalRank, rank);
receiver.socket.emit("rank", receiver.account.effectiveRank);
this.channel.logger.log("[mod] " + user.getName() + " set " + name + "'s rank " +
"to " + rank);
this.channel.broadcastAll("setUserRank", data);
if (!this.channel.is(Flags.C_REGISTERED)) {
user.socket.emit("channelRankFail", {
msg: "This channel is not registered. Any rank changes are temporary " +
"and not stored in the database."
});
return;
}
if (!receiver.is(Flags.U_REGISTERED)) {
user.socket.emit("channelRankFail", {
msg: "The user you promoted is not a registered account. " +
"Any rank changes are temporary and not stored in the database."
});
return;
}
data.userrank = userrank;
this.updateDatabase(data, function (err) {
if (err) {
user.socket.emit("channelRankFail", {
msg: "Database failure when updating rank"
});
}
});
} else {
data.userrank = userrank;
var self = this;
this.updateDatabase(data, function (err) {
if (err) {
user.socket.emit("channelRankFail", {
msg: "Updating user rank failed: " + err
});
}
self.channel.logger.log("[mod] " + user.getName() + " set " + data.name +
"'s rank to " + rank);
self.channel.broadcastAll("setUserRank", data);
if (self.channel.modules.chat) {
self.channel.modules.chat.sendModMessage(
user.getName() + " set " + data.name + "'s rank to " + rank,
3
);
}
});
}
};
RankModule.prototype.updateDatabase = function (data, cb) {
var chan = this.channel;
Account.rankForName(data.name, { channel: this.channel.name }, function (err, rank) {
if (err) {
return cb(err);
}
if (rank >= data.userrank && !(rank === 4 && data.userrank === 4)) {
cb("You can't promote or demote someone with equal or higher rank than you.");
return;
}
db.channels.setRank(chan.name, data.name, data.rank, cb);
});
};
module.exports = RankModule;

123
src/channel/voteskip.js Normal file
View file

@ -0,0 +1,123 @@
var ChannelModule = require("./module");
var Flags = require("../flags");
var Poll = require("../poll").Poll;
function VoteskipModule(channel) {
ChannelModule.apply(this, arguments);
this.poll = false;
}
VoteskipModule.prototype = Object.create(ChannelModule.prototype);
VoteskipModule.prototype.onUserPostJoin = function (user) {
user.socket.on("voteskip", this.handleVoteskip.bind(this, user));
};
VoteskipModule.prototype.onUserPart = function(user) {
if (!this.poll) {
return;
}
this.unvote(user.realip);
this.update();
};
VoteskipModule.prototype.handleVoteskip = function (user) {
if (!this.channel.modules.options.get("allow_voteskip")) {
return;
}
if (!this.channel.modules.playlist) {
return;
}
if (!this.channel.modules.permissions.canVoteskip(user)) {
return;
}
if (!this.poll) {
this.poll = new Poll("[server]", "voteskip", ["skip"], false);
}
this.poll.vote(user.realip, 0);
var title = "";
if (this.channel.modules.playlist.current) {
title = " " + this.channel.modules.playlist.current.media.title;
}
var name = user.getName() || "(anonymous)";
this.channel.logger.log("[playlist] " + name + " voteskipped " + title);
user.setAFK(false);
this.update();
};
VoteskipModule.prototype.unvote = function(ip) {
if (!this.poll) {
return;
}
this.poll.unvote(ip);
};
VoteskipModule.prototype.update = function () {
if (!this.channel.modules.options.get("allow_voteskip")) {
return;
}
if (!this.poll) {
return;
}
if (this.channel.modules.playlist.meta.count === 0) {
return;
}
var max = this.calcVoteskipMax();
var need = Math.ceil(max * this.channel.modules.options.get("voteskip_ratio"));
if (this.poll.counts[0] >= need) {
this.channel.logger.log("[playlist] Voteskip passed.");
this.channel.modules.playlist._playNext();
}
this.sendVoteskipData(this.channel.users);
};
VoteskipModule.prototype.sendVoteskipData = function (users) {
var max = this.calcVoteskipMax();
var data = {
count: this.poll ? this.poll.counts[0] : 0,
need: this.poll ? Math.ceil(max * this.channel.modules.options.get("voteskip_ratio"))
: 0
};
var perms = this.channel.modules.permissions;
users.forEach(function (u) {
if (perms.canSeeVoteskipResults(u)) {
u.socket.emit("voteskip", data);
}
});
};
VoteskipModule.prototype.calcVoteskipMax = function () {
var perms = this.channel.modules.permissions;
return this.channel.users.map(function (u) {
if (!perms.canVoteskip(u)) {
return 0;
}
return u.is(Flags.U_AFK) ? 0 : 1;
}).reduce(function (a, b) {
return a + b;
}, 0);
};
VoteskipModule.prototype.onMediaChange = function (data) {
this.poll = false;
this.sendVoteskipData(this.channel.users);
};
module.exports = VoteskipModule;