Merge refactoring into 3.0

This commit is contained in:
Calvin Montgomery 2014-05-20 19:30:14 -07:00
parent 91bf6a5062
commit 9ea48f58cf
39 changed files with 5555 additions and 6262 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;

623
lib/channel/channel.js Normal file
View file

@ -0,0 +1,623 @@
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("fs");
var path = require("path");
var sio = require("socket.io");
var db = require("../database");
/**
* 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++;
//console.log('dbg: lock/count: ', this.count);
//console.trace();
},
release: function () {
this.count--;
//console.log('dbg: release/count: ', this.count);
//console.trace();
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));
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 () {
if (self.is(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",
"./chat" : "chat",
"./filters" : "filters",
"./emotes" : "emotes",
"./customization" : "customization",
"./opts" : "options",
"./library" : "library",
"./playlist" : "playlist",
"./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.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: {
motd: msg,
html: msg
}
});
}
self.setFlag(Flags.C_ERROR);
};
fs.stat(file, function (err, stats) {
if (!err) {
var mb = stats.size / 1048576;
mb = Math.floor(mb * 100) / 100;
if (mb > 1) {
Logger.errlog.log("Large chandump detected: " + self.uniqueName +
" (" + mb + " MiB)");
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);
};
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.waitFlag(Flags.C_READY, function () {
/* User closed the connection before the channel finished loading */
if (user.socket.disconnected) {
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);
return;
}
afterAccount();
});
} else {
afterAccount();
}
function afterAccount() {
if (self.dead || user.socket.disconnected) {
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.activeLock.lock();
self.acceptUser(user);
} else {
user.account.channelRank = 0;
user.account.effectiveRank = user.account.globalRank;
}
});
}
});
};
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.ip + " joined " + this.name);
this.logger.log("[login] Accepted connection from " + user.longip);
if (user.is(Flags.U_LOGGED_IN)) {
this.logger.log("[login] " + user.longip + " authenticated as " + user.getName());
}
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");
}
}
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) {
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) {
this.logger.log("[login] " + user.longip + " (" + 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.users.forEach(function (u) {
u.socket.emit("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.sendUserLeave(this.users, 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: util.maskIP(user.longip)
}
};
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.ip
}
};
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.sendUserLeave = function (users, user) {
var data = {
name: user.getName()
};
users.forEach(function (u) {
u.socket.emit("userLeave", data);
});
};
Channel.prototype.readLog = function (shouldMaskIP, 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 () {
if (shouldMaskIP) {
buffer = buffer.replace(
/^(\d+\.\d+\.\d+)\.\d+/g,
"$1.x"
).replace(
/^((?:[0-9a-f]+:){3}[0-9a-f]+):(?:[0-9a-f]+:){3}[0-9a-f]+$/,
"$1:x:x:x:x"
);
}
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(shouldMaskIP, 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.ioServers.forEach(function (io) {
io.sockets.in(ns).emit(msg, data);
});
};
Channel.prototype.broadcastAll = function (msg, data) {
this._broadcast(msg, data, this.name);
};
module.exports = Channel;

527
lib/channel/chat.js Normal file
View file

@ -0,0 +1,527 @@
var User = require("../user");
var XSS = require("../xss");
var ChannelModule = require("./module");
var util = require("../utilities");
var Flags = require("../flags");
var url = require("url");
const SHADOW_TAG = "[shadow]";
const LINK = /(\w+:\/\/(?:[^:\/\[\]\s]+|\[[0-9a-f:]+\])(?::\d+)?(?:\/[^\/\s]*)*)/ig;
const TYPE_CHAT = {
msg: "string",
meta: "object,optional"
};
const TYPE_PM = {
msg: "string",
to: "string",
meta: "object,optional"
};
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("/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.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;
if (!this.channel.modules.permissions.canChat(user)) {
return;
}
data.msg = data.msg.substring(0, 240);
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("onUserChat", [user, data], function (err, result) {
if (result === ChannelModule.PASSTHROUGH) {
self.processChatMsg(user, data);
}
});
};
ChatModule.prototype.handlePm = function (user, data) {
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;
}
var 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 (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.indexOf("/afk") !== 0) {
user.setAFK(false);
}
var msgobj = this.formatMessage(user.getName(), data);
if (this.channel.modules.options &&
this.channel.modules.options.get("chat_antiflood") &&
user.account.effectiveRank < 2) {
var antiflood = this.channel.modules.options.get("chat_antiflood_params");
if (user.chatLimiter.throttle(antiflood)) {
user.socket.emit("cooldown", 1000 / antiflood.sustained);
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;
}
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);
} else {
this.sendMessage(msgobj);
}
} else {
if (data.msg.indexOf(">") === 0) {
msgobj.meta.addClass = "greentext";
}
this.sendMessage(msgobj);
}
};
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;
};
const link = /(\w+:\/\/(?:[^:\/\[\]\s]+|\[[0-9a-f:]+\])(?::\d+)?(?:\/[^\/\s]*)*)/ig;
ChatModule.prototype.filterMessage = function (msg) {
var filters = this.channel.modules.filters.filters;
var chan = this.channel;
var parts = msg.split(link);
var convertLinks = this.channel.modules.options.get("enable_link_regex");
for (var j = 0; j < parts.length; j++) {
/* substring is a URL */
if (convertLinks && parts[j].match(link)) {
var original = parts[j];
parts[j] = filters.exec(parts[j], { filterlinks: true });
/* no filters changed the URL, apply link filter */
if (parts[j] === original) {
parts[j] = url.format(url.parse(parts[j]));
parts[j] = parts[j].replace(link, "<a href=\"$1\" target=\"_blank\">$1</a>");
}
} else {
/* substring is not a URL */
parts[j] = filters.exec(parts[j], { filterlinks: false });
}
}
msg = parts.join("");
/* Anti-XSS */
return XSS.sanitizeHTML(msg);
};
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 (user.account.effectiveRank < 2) {
return;
}
this.buffer = [];
this.channel.broadcastAll("clearchat");
};
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().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) {
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().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) {
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().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,122 @@
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 = {
motd: "",
html: ""
};
}
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) {
this.motd = {
motd: data.motd.motd || "",
html: data.motd.html || ""
};
}
};
CustomizationModule.prototype.save = function (data) {
data.css = this.css;
data.js = this.js;
data.motd = this.motd;
};
CustomizationModule.prototype.setMotd = function (motd) {
motd = XSS.sanitizeHTML(motd);
var html = motd.replace(/\n/g, "<br>");
this.motd = {
motd: motd,
html: html
};
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.name + " 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.name + " 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.name + " updated the MOTD");
};
module.exports = CustomizationModule;

199
lib/channel/emotes.js Normal file
View file

@ -0,0 +1,199 @@
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.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;

276
lib/channel/filters.js Normal file
View file

@ -0,0 +1,276 @@
var ChannelModule = require("./module");
var XSS = require("../xss");
function ChatFilter(name, regex, flags, replace, active, filterlinks) {
this.name = name;
this.source = regex;
this.flags = flags;
this.regex = new RegExp(this.source, flags);
this.replace = replace;
this.active = active === false ? false : true;
this.filterlinks = filterlinks || false;
}
ChatFilter.prototype = {
pack: function () {
return {
name: this.name,
source: this.source,
flags: this.flags,
replace: this.replace,
active: this.active,
filterlinks: this.filterlinks
};
},
exec: function (str) {
return str.replace(this.regex, this.replace);
}
};
function FilterList(defaults) {
if (!defaults) {
defaults = [];
}
this.filters = defaults.map(function (f) {
return new ChatFilter(f.name, f.source, f.flags, f.replace, f.active, f.filterlinks);
});
}
FilterList.prototype = {
pack: function () {
return this.filters.map(function (f) { return f.pack(); });
},
importList: function (filters) {
this.filters = Array.prototype.slice.call(filters);
},
updateFilter: function (filter) {
if (!filter.name) {
filter.name = filter.source;
}
var found = false;
for (var i = 0; i < this.filters.length; i++) {
if (this.filters[i].name === filter.name) {
found = true;
this.filters[i] = filter;
break;
}
}
/* If no filter was updated, add a new one */
if (!found) {
this.filters.push(filter);
}
},
removeFilter: function (filter) {
var found = false;
for (var i = 0; i < this.filters.length; i++) {
if (this.filters[i].name === filter.name) {
this.filters.splice(i, 1);
break;
}
}
},
moveFilter: function (from, to) {
if (from < 0 || to < 0 ||
from >= this.filters.length || to >= this.filters.length) {
return false;
}
var f = this.filters[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.filters.splice(to, 0, f);
this.filters.splice(from, 1);
return true;
},
exec: function (str, opts) {
if (!opts) {
opts = {};
}
this.filters.forEach(function (f) {
if (opts.filterlinks && !f.filterlinks) {
return;
}
if (f.active) {
str = f.exec(str);
}
});
return str;
}
};
function validateFilter(f) {
if (typeof f.source !== "string" || typeof f.flags !== "string" ||
typeof f.replace !== "string") {
return false;
}
if (typeof f.name !== "string") {
f.name = f.source;
}
f.replace = f.replace.substring(0, 1000);
f.replace = XSS.sanitizeHTML(f.replace);
f.flags = f.flags.substring(0, 4);
try {
new RegExp(f.source, f.flags);
} catch (e) {
return false;
}
var filter = new ChatFilter(f.name, f.source, f.flags, f.replace,
Boolean(f.active), Boolean(f.filterlinks));
return filter;
}
const DEFAULT_FILTERS = [
new ChatFilter("monospace", "`(.+?)`", "g", "<code>$1</code>"),
new ChatFilter("bold", "\\*(.+?)\\*", "g", "<strong>$1</strong>"),
new ChatFilter("italic", "_(.+?)_", "g", "<em>$1</em>"),
new ChatFilter("strike", "~~(.+?)~~", "g", "<s>$1</s>"),
new ChatFilter("inline spoiler", "\\[sp\\](.*?)\\[\\/sp\\]", "ig", "<span class=\"spoiler\">$1</span>")
];
function ChatFilterModule(channel) {
ChannelModule.apply(this, arguments);
this.filters = new FilterList(DEFAULT_FILTERS);
}
ChatFilterModule.prototype = Object.create(ChannelModule.prototype);
ChatFilterModule.prototype.load = function (data) {
if ("filters" in data) {
for (var i = 0; i < data.filters.length; i++) {
var f = validateFilter(data.filters[i]);
if (f) {
this.filters.updateFilter(f);
}
}
}
};
ChatFilterModule.prototype.save = function (data) {
data.filters = this.filters.pack();
};
ChatFilterModule.prototype.onUserPostJoin = function (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.handleUpdateFilter = function (user, data) {
if (typeof data !== "object") {
return;
}
if (!this.channel.modules.permissions.canEditFilters(user)) {
return;
}
var f = validateFilter(data);
if (!f) {
return;
}
data = f.pack();
this.filters.updateFilter(f);
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: " + f.name + " -> " +
"s/" + f.source + "/" + f.replace + "/" + f.flags + " active: " +
f.active + ", filterlinks: " + f.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;
}
this.filters.importList(data.map(validateFilter).filter(function (f) {
return f !== false;
}));
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;
}
this.filters.removeFilter(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;
}
this.filters.moveFilter(data.from, data.to);
};
ChatFilterModule.prototype.handleRequestChatFilters = function (user) {
this.sendChatFilters([user]);
};
module.exports = ChatFilterModule;

379
lib/channel/kickban.js Normal file
View file

@ -0,0 +1,379 @@
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("/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);
KickBanModule.prototype.onUserPreJoin = function (user, data, cb) {
if (!this.channel.is(Flags.C_REGISTERED)) {
return cb(null, ChannelModule.PASSTHROUGH);
}
var cname = this.channel.name;
db.channels.isIPBanned(cname, user.longip, function (err, banned) {
if (err) {
cb(null, ChannelModule.PASSTHROUGH);
} else if (!banned) {
if (user.is(Flags.U_LOGGED_IN)) {
checkNameBan();
} else {
cb(null, ChannelModule.PASSTHROUGH);
}
} else {
cb(null, ChannelModule.DENY);
user.kick("Your IP address is banned from this channel.");
}
});
function checkNameBan() {
db.channels.isNameBanned(cname, user.getName(), function (err, banned) {
if (err) {
cb(null, ChannelModule.PASSTHROUGH);
} else {
cb(null, banned ? ChannelModule.DENY : 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.maskIP(banlist[i].ip),
name: banlist[i].name,
reason: banlist[i].reason,
bannedby: banlist[i].bannedby
});
unmaskedbans.push({
id: banlist[i].id,
ip: banlist[i].ip,
name: banlist[i].name,
reason: banlist[i].reason,
bannedby: banlist[i].bannedby
});
}
users.forEach(function (u) {
if (!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 */
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) {
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());
}
};
/* /ban - name bans */
KickBanModule.prototype.handleCmdBan = function (user, msg, meta) {
var args = msg.split(" ");
args.shift(); /* shift off /ban */
var name = args.shift();
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 */
var name = args.shift();
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.maskIP(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).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 () {
chan.logger.log("[mod] " + actor.getName() + " banned " + ip + " (" + name + ")");
if (chan.modules.chat) {
chan.modules.chat.sendModMessage(actor.getName() + " banned " +
util.maskIP(ip) + " (" + 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].longip === 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;

109
lib/channel/library.js Normal file
View file

@ -0,0 +1,109 @@
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 {
cb(null, new Media(row.id, row.title, row.seconds, row.type, {}));
}
});
};
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.split(" "), function (e, vids) {
if (!e) {
user.socket.emit("searchResults", {
source: "yt",
results: vids
});
}
});
};
if (data.source === "yt" || !this.channel.is(Flags.C_REGISTERED)) {
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;

67
lib/channel/module.js Normal file
View file

@ -0,0 +1,67 @@
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 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
*/
onUserChat: function (user, 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;

190
lib/channel/opts.js Normal file
View file

@ -0,0 +1,190 @@
var ChannelModule = require("./module");
var Config = require("../config");
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
};
}
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];
}
}
}
};
OptionsModule.prototype.save = function (data) {
data.opts = this.opts;
};
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 = parseInt(data.maxlength);
if (isNaN(ml) || ml < 0) {
ml = 0;
}
this.opts.maxlength = ml;
}
if ("externalcss" in data && user.account.effectiveRank >= 3) {
this.opts.externalcss = (""+data.externalcss).substring(0, 255);
}
if ("externaljs" in data && user.account.effectiveRank >= 3) {
this.opts.externaljs = (""+data.externaljs).substring(0, 255);
}
if ("chat_antiflood" in data) {
this.opts.chat_antiflood = Boolean(data.chat_antiflood);
}
if ("chat_antiflood_params" in data) {
if (typeof data.chat_antiflood_params !== "object") {
data.chat_antiflood_params = {
burst: 4,
sustained: 1
};
}
var b = parseInt(data.chat_antiflood_params.burst);
if (isNaN(b) || b < 0) {
b = 1;
}
var s = parseInt(data.chat_antiflood_params.sustained);
if (isNaN(s) || s <= 0) {
s = 1;
}
var c = b / s;
this.opts.chat_antiflood_params = {
burst: b,
sustained: s,
cooldown: c
};
}
if ("show_public" in data && user.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);
}
this.channel.logger.log("[mod] " + user.getName() + " updated channel options");
this.sendOpts(this.channel.users);
};
module.exports = OptionsModule;

369
lib/channel/permissions.js Normal file
View file

@ -0,0 +1,369 @@
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
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
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
};
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.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.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.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.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
playlistlock: 2, // Lock/unlock the playlist
leaderctl: 0, // Give/take leader
drink: 0, // Use the /d command
chat: 0 // Send chat messages
};
for (var key in perms) {
this.permissions[key] = perms[key];
}
this.openPlaylist = true;
};
module.exports = PermissionsModule;

1220
lib/channel/playlist.js Normal file

File diff suppressed because it is too large Load diff

173
lib/channel/poll.js Normal file
View file

@ -0,0 +1,173 @@
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.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.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]" },
account: { 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.ip, 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;

184
lib/channel/ranks.js Normal file
View file

@ -0,0 +1,184 @@
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 (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 = rank;
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;

103
lib/channel/voteskip.js Normal file
View file

@ -0,0 +1,103 @@
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.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.ip, 0);
var title = "";
if (this.channel.modules.playlist.current) {
title = " " + this.channel.modules.playlist.current;
}
var name = user.getName() || "(anonymous)"
this.channel.logger.log("[playlist] " + name + " voteskipped " + title);
this.update();
};
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
};
users.forEach(function (u) {
if (u.account.effectiveRank >= 1.5) {
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;