sync/lib/channel-new.js

966 lines
26 KiB
JavaScript
Raw Normal View History

2013-12-27 21:38:06 +00:00
var util = require("./utilities");
var Playlist = require("./playlist");
2013-12-27 16:08:03 +00:00
var DEFAULT_FILTERS = [
new Filter("monospace", "`(.+?)`", "g", "<code>$1</code>"),
new Filter("bold", "\\*(.+?)\\*", "g", "<strong>$1</strong>"),
new Filter("italic", "_(.+?)_", "g", "<em>$1</em>"),
new Filter("strike", "~~(.+?)~~", "g", "<s>$1</s>"),
new Filter("inline spoiler", "\\[sp\\](.*?)\\[\\/sp\\]", "ig", "<span class=\"spoiler\">$1</span>")
];
function Channel(name) {
2013-12-27 21:38:06 +00:00
var self = this; // Alias `this` to prevent scoping issues
2013-12-27 16:08:03 +00:00
Logger.syslog.log("Loading channel " + name);
// Defaults
self.ready = false;
self.name = name;
self.uniqueName = name.toLowerCase(); // To prevent casing issues
self.registered = false; // set to true if the channel exists in the database
self.users = [];
2013-12-27 21:38:06 +00:00
self.mutedUsers = new util.Set();
2013-12-27 16:08:03 +00:00
self.playlist = new Playlist(self);
self.plqueue = new AsyncQueue(); // For synchronizing playlist actions
self.drinks = 0;
self.leader = null;
self.chatbuffer = [];
self.playlistLock = true;
self.poll = null;
self.voteskip = null;
self.permissions = {
playlistadd: 1.5, // Add video to the playlist
playlistnext: 1.5, // TODO I don't think this is used
playlistmove: 1.5, // Move a video on the playlist
playlistdelete: 2, // Delete a video from the playlist
playlistjump: 1.5, // Start a different video on the playlist
playlistaddlist: 1.5, // Add a list of videos to the playlist
oplaylistadd: -1, // Same as above, but for open (unlocked) playlist
oplaylistnext: 1.5,
oplaylistmove: 1.5,
oplaylistdelete: 2,
oplaylistjump: 1.5,
oplaylistaddlist: 1.5,
playlistaddcustom: 3, // Add custom embed to the playlist
playlistaddlive: 1.5, // Add a livestream to the playlist
exceedmaxlength: 2, // Add a video longer than the maximum length set
addnontemp: 2, // Add a permanent video to the playlist
settemp: 2, // Toggle temporary status of a playlist item
playlistgeturl: 1.5, // TODO is this even used?
playlistshuffle: 2, // Shuffle the playlist
playlistclear: 2, // Clear the playlist
pollctl: 1.5, // Open/close polls
pollvote: -1, // Vote in polls
viewhiddenpoll: 1.5, // View results of hidden polls
voteskip: -1, // Vote to skip the current video
mute: 1.5, // Mute other users
kick: 1.5, // Kick other users
ban: 2, // Ban other users
motdedit: 3, // Edit the MOTD
filteredit: 3, // Control chat filters
drink: 1.5, // Use the /d command
chat: 0 // Send chat messages
};
self.opts = {
allow_voteskip: true, // Allow users to voteskip
voteskip_ratio: 0.5, // Ratio of skip votes:non-afk users needed to skip the video
afk_timeout: 600, // Number of seconds before a user is automatically marked afk
pagetitle: self.name, // Title of the browser tab
maxlength: 0, // Maximum length (in seconds) of a video queued
externalcss: "", // Link to external stylesheet
externaljs: "", // Link to external script
chat_antiflood: false, // Throttle chat messages
chat_antiflood_params: {
burst: 4, // Number of messages to allow with no throttling
sustained: 1, // Throttle rate (messages/second)
cooldown: 4 // Number of seconds with no messages before burst is reset
},
show_public: false, // List the channel on the index page
enable_link_regex: true, // Use the built-in link filter
password: false // Channel password (false -> no password required for entry)
};
self.motd = {
motd: "", // Raw MOTD text
html: "" // Filtered MOTD text (XSS removed; \n replaced by <br>)
};
self.filters = DEFAULT_FILTERS;
2013-12-27 21:38:06 +00:00
self.ipbans = {};
self.namebans = {};
2013-12-27 16:08:03 +00:00
self.logger = new Logger.Logger(path.join(__dirname, "../chanlogs",
self.uniqueName + ".log"));
self.css = ""; // Up to 20KB of inline CSS
self.js = ""; // Up to 20KB of inline Javascript
2013-12-27 21:38:06 +00:00
2013-12-27 16:08:03 +00:00
self.error = false; // Set to true if something bad happens => don't save state
self.on("ready", function () {
self.ready = true;
});
// Load from database
db.channels.load(self, function (err) {
if (err && err !== "Channel is not registered") {
return;
} else {
// Load state from JSON blob
self.tryLoadState();
}
});
};
Channel.prototype = EventEmitter;
Channel.prototype.tryLoadState = function () {
var self = this;
if (self.name === "") {
return;
}
// Don't load state if the channel isn't registered
if (!self.registered) {
self.emit("ready");
return;
}
var file = path.join(__dirname, "../chandump", self.uniqueName);
fs.stat(file, function (err, stats) {
if (!err) {
var mb = stats.size / 1048576;
mb = Math.floor(mb * 100) / 100;
if (mb > 1) {
Logger.errlog.log("Large chandump detected: " + self.uniqueName +
" (" + mb + " MiB)");
self.setMOTD("Your channel file has exceeded the maximum size of 1MB " +
"and cannot be loaded. Please ask an administrator for " +
"assistance in restoring it.");
self.error = true;
self.emit("ready");
return;
}
}
self.loadState();
});
};
/**
* Load the channel state from disk.
*
* SHOULD ONLY BE CALLED FROM tryLoadState
*/
Channel.prototype.loadState = function () {
var self = this;
if (self.error) {
return;
}
2013-12-27 21:38:06 +00:00
fs.readFile(path.join(__dirname, "../chandump", self.uniqueName),
2013-12-27 16:08:03 +00:00
function (err, data) {
if (err) {
// File didn't exist => start fresh
if (err.code === "ENOENT") {
self.emit("ready");
self.saveState();
} else {
Logger.errlog.log("Failed to open channel dump " + self.uniqueName);
Logger.errlog.log(err);
2013-12-27 21:38:06 +00:00
self.setMOTD("Channel state load failed. Contact an administrator.");
2013-12-27 16:08:03 +00:00
self.error = true;
self.emit("ready");
}
return;
}
try {
self.logger.log("*** Loading channel state");
data = JSON.parse(data);
// Load the playlist
if ("playlist" in data) {
2013-12-27 21:38:06 +00:00
self.playlist.load(data.playlist, function () {
self.sendPlaylist(self.users);
self.sendPlaylistMeta(self.users);
self.playlist.startPlayback(data.playlist.time);
});
2013-12-27 16:08:03 +00:00
}
// Playlist lock
self.setLock(data.playlistLock || false);
2013-12-27 21:38:06 +00:00
2013-12-27 16:08:03 +00:00
// Configurables
if ("opts" in data) {
for (var key in data.opts) {
self.opts[key] = data.opts;
}
}
// Permissions
if ("permissions" in data) {
for (var key in data.permissions) {
self.permissions[key] = data.permissions[key];
}
}
// Chat filters
if ("filters" in data) {
for (var i = 0; i < data.filters.length; i++) {
var f = data.filters[i];
var filt = new Filter(f.name, f.source, f.flags, f.replace);
filt.active = f.active;
filt.filterlinks = f.filterlinks;
self.updateFilter(filt, false);
}
}
2013-12-27 21:38:06 +00:00
// MOTD
if ("motd" in data) {
self.motd = {
motd: data.motd.motd,
html: data.motd.html
};
}
// Chat history
if ("chatbuffer" in data) {
data.chatbuffer.forEach(function (msg) {
self.chatbuffer.push(msg);
});
}
// Inline CSS/JS
self.css = data.css || "";
self.js = data.js || "";
self.emit("ready");
2013-12-27 16:08:03 +00:00
} catch (e) {
2013-12-27 21:38:06 +00:00
self.error = true;
Logger.errlog.log("Channel dump load failed (" + self.uniqueName + "): " + e);
self.setMOTD("Channel state load failed. Contact an administrator.");
self.emit("ready");
}
});
};
Channel.prototype.saveState = function () {
var self = this;
if (self.error) {
return;
}
if (!self.registered || self.uniqueName === "") {
return;
}
var filters = self.filters.map(function (f) {
return f.pack();
});
var data = {
playlist: self.playlist.dump(),
opts: self.opts,
permissions: self.permissions,
filters: filters,
motd: self.motd,
playlistLock: self.playlistLock,
chatbuffer: self.chatbuffer,
css: self.css,
js: self.js
};
var text = JSON.stringify(data);
fs.writeFileSync(path.join(__dirname, "../chandump", self.uniqueName), text);
};
/**
* Checks whether a user has the given permission node
*/
Channel.prototype.hasPermission = function (user, key) {
// Special case: you can have separate permissions for when playlist is unlocked
if (key.indexOf("playlist") === 0 && !this.playlistLock) {
var key2 = "o" + key;
var v = this.permissions[key2];
if (typeof v === "number" && user.rank >= v) {
return true;
}
}
var v = this.permissions[key];
if (typeof v !== "number") {
return false;
}
return user.rank >= v;
};
/**
* Defer a callback to complete when the channel is ready to accept users.
* Called immediately if the ready flag is already set
*/
Channel.prototype.whenReady = function (fn) {
var self = this;
if (self.ready) {
setImmediate(fn);
} else {
self.on("ready", fn);
}
};
/**
* Looks up a user's rank in the channel. Computed as max(global_rank, channel rank)
*/
Channel.prototype.getRank = function (name, callback) {
var self = this;
db.users.getGlobalRank(name, function (err, global) {
if (self.dead) {
return;
}
if (err) {
callback(err, null);
return;
}
if (!self.registered) {
callback(null, global);
return;
}
db.channels.getRank(self.name, name, function (err, rank) {
if (self.dead) {
return;
}
if (err) {
callback(err, null);
return;
}
callback(null, Math.max(rank, global));
});
});
};
2013-12-28 02:06:10 +00:00
/**
* Looks up the highest rank of any alias of an IP address
*/
Channel.prototype.getIPRank = function (ip, callback) {
var self = this;
db.getAliases(ip, function (err, names) {
if (self.dead) {
return;
}
db.users.getGlobalRanks(names, function (err, res) {
if (self.dead) {
return;
}
if (err) {
callback(err, null);
return;
}
var rank = res.reduce(function (a, b) {
return Math.max(a, b);
}, 0);
if (!self.registered) {
callback(null, rank);
return;
}
db.channels.getRanks(self.name, names,
function (err, res) {
if (self.dead) {
return;
}
if (err) {
callback(err, null);
return;
}
var rank = res.reduce(function (a, b) {
return Math.max(a, b);
}, rank);
callback(null, rank);
});
});
});
};
2013-12-27 21:38:06 +00:00
/**
* Called when a user joins a channel
*/
Channel.prototype.join = function (user, password) {
var self = this;
self.whenReady(function () {
if (self.opts.password !== false && user.rank < 2) {
if (password !== self.opts.password) {
user.socket.emit("needPassword", typeof password === "undefined");
return;
}
}
2013-12-27 16:08:03 +00:00
2013-12-27 21:38:06 +00:00
user.socket.emit("cancelNeedPassword");
var range = user.ip.replace(/(\d+)\.(\d+)\.(\d+)\.(\d+)/, "$1.$2.$3");
if (user.ip in self.ipbans || range in self.ipbans ||
user.name.toLowerCase() in self.namebans) {
user.kick("You're banned!");
return;
2013-12-27 16:08:03 +00:00
}
2013-12-27 21:38:06 +00:00
user.autoAFK();
user.socket.join(self.uniqueName);
user.channel = self;
self.users.push(user);
self.sendVoteskipUpdate(self.users);
self.sendUsercount(self.users);
user.whenLoggedIn(function () {
var lname = user.name.toLowerCase();
for (var i = 0; i < self.users.length; i++) {
if (self.users[i] === user) {
Logger.errlog.log("Wat: join() called on user already in channel");
break;
}
self.users[i].kick("Duplicate login");
}
self.getRank(user.name, function (err, rank) {
if (self.dead) {
return;
}
if (err) {
user.rank = user.global_rank;
} else {
user.rank = Math.max(rank, user.global_rank);
}
user.socket.emit("rank", user.rank);
self.sendUserJoin(self.users);
});
});
self.sendPlaylist([user]);
self.sendMediaUpdate([user]);
self.sendPlaylistLock([user]);
self.sendUserlist([user]);
self.sendRecentchat([user]);
self.sendCSSJS([user]);
self.sendPoll([user]);
self.sendOpts([user]);
self.sendPermissions([user]);
self.sendMotd([user]);
self.sendDrinkCount([user]);
self.logger.log("+++ " + user.ip + " joined");
Logger.syslog.log(user.ip + " joined channel " + self.name);
});
};
/**
* Called when a user leaves the channel.
* Cleans up and sends appropriate updates to other users
*/
Channel.prototype.part = function (user) {
user.channel = null;
// Clear poll vote
if (self.poll) {
self.poll.unvote(user.ip);
self.sendPoll(self.users);
}
// Clear voteskip vote
if (self.voteskip) {
self.voteskip.unvote(user.ip);
self.sendVoteskipUpdate(self.users);
}
// Return video lead to server if necessary
if (self.leader === user) {
self.changeLeader("");
}
// Remove from users array
var idx = self.users.indexOf(user);
if (idx >= 0 && idx < self.users.length) {
self.users.splice(idx, 1);
}
// A change in usercount might cause a voteskip result to change
self.checkVoteskipPass();
self.sendUsercount(self.users);
if (user.loggedIn) {
self.sendUserLeave(self.users, user);
}
self.logger.log("--- " + user.ip + " (" + user.name + ") left");
if (self.users.length === 0) {
self.emit("empty");
return;
}
};
/**
* Set the MOTD and broadcast it to connected users
*/
Channel.prototype.setMOTD = function (message) {
var self = this;
self.motd.motd = message;
// TODO XSS filter
self.motd.html = message.replace(/\n/g, "<br>");
self.sendMOTD(self.users);
};
/**
* Send the MOTD to the given users
*/
Channel.prototype.sendMOTD = function (users) {
var self = this;
users.forEach(function (u) {
u.socket.emit("setMotd", self.motd);
});
};
2013-12-28 02:06:10 +00:00
/**
* Sends a message to channel moderators
*/
Channel.prototype.sendModMessage = function (msg, minrank) {
if (isNaN(minrank)) {
minrank = 2;
}
var notice = {
username: "[server]",
msg: msg
meta: {
addClass: "server-whisper" ,
addClassToNameAndTimestamp: true
},
time: Date.now()
};
self.users.forEach(function(u) {
if (u.rank > minrank) {
u.socket.emit("chatMsg", notice);
}
});
};
/**
* Stores a video in the channel's library
*/
Channel.prototype.cacheMedia = function (media) {
// Don't cache Google Drive videos because of their time limit
if (media.type === "gd") {
return false;
}
if (self.registered) {
db.channels.addToLibrary(self.name, media);
}
};
/**
* Attempts to ban a user by name
*/
Channel.prototype.tryNameBan = function (actor, name, reason) {
var self = this;
if (!self.hasPermission(actor, "ban")) {
return false;
}
name = name.toLowerCase();
if (name == actor.name.toLowerCase()) {
actor.socket.emit("costanza", {
msg: "Trying to ban yourself?"
});
return;
}
// Look up the name's rank so people can't ban others with higher rank than themselves
self.getRank(name, function (err, rank) {
if (self.dead) {
return;
}
if (err) {
actor.socket.emit("errorMsg", {
msg: "Internal error " + err
});
return;
}
if (rank >= actor.rank) {
actor.socket.emit("errorMsg", {
msg: "You don't have permission to ban " + name
});
return;
}
if (typeof reason !== "string") {
reason = "";
}
reason = reason.substring(0, 255);
self.namebans[name] = {
ip: "*",
name: name,
bannedby: actor.name,
reason: reason
};
// If in the channel already, kick the banned user
for (var i = 0; i < self.users.length; i++) {
if (self.users[i].name.toLowerCase() == name) {
self.kick(self.users[i], "You're banned!");
break;
}
}
self.logger.log("*** " + actor.name + " namebanned " + name);
self.sendModMessage(actor.name + " banned " + name, self.permissions.ban);
if (!self.registered) {
return;
}
// channel, ip, name, reason, actor
db.channels.ban(self.name, "*", name, reason, actor.name);
// TODO send banlist?
});
};
/**
* Removes a name ban
*/
Channel.prototype.tryUnbanName = function (actor, name) {
var self = this;
if (!self.hasPermission(actor, "ban")) {
return;
}
delete self.namebans[name];
self.logger.log("*** " + actor.name + " un-namebanned " + name);
self.sendModMessage(actor.name + " unbanned " + name, self.permissions.ban);
if (!self.registered) {
return;
}
db.channels.unbanName(self.name, name);
// TODO send banlist?
};
/**
* Bans all IP addresses associated with a username
*/
Channel.prototype.tryBanAllIP = function (actor, name, reason, range) {
var self = this;
if (!self.hasPermission(actor, "ban")) {
return;
}
if (typeof name !== "string") {
return;
}
name = name.toLowerCase();
if (name === actor.name.toLowerCase()) {
actor.socket.emit("costanza", {
msg: "Trying to ban yourself?"
});
return;
}
db.getIPs(name, function (err, ips) {
if (self.dead) {
return;
}
if (err) {
actor.socket.emit("errorMsg", {
msg: "Internal error: " + err
});
return;
}
ips.forEach(function (ip) {
self.tryBanIP(actor, ip, name, range);
});
});
};
/**
* Bans an individual IP
*/
Channel.prototype.tryBanIP = function (actor, ip, name, reason, range) {
if (range) {
ip = ip.replace(/(\d+)\.(\d+)\.(\d+)\.(\d+)/, "$1.$2.$3");
}
if (typeof reason !== "string") {
reason = "";
}
reason = reason.substring(0, 255);
self.getIPRank(ip, function (err, rank) {
if (self.dead) {
return;
}
if (err) {
actor.socket.emit("errorMsg", {
msg: "Internal error: " + err
});
return;
}
if (rank >= actor.rank) {
actor.socket.emit("errorMsg", {
msg: "You don't have permission to ban IP: " + util.maskIP(ip)
});
return;
}
self.ipbans[ip] = {
ip: ip,
name: name,
bannedby: actor.name,
reason: reason
};
self.logger.log("*** " + actor.name + " banned " + ip + " (" + name + ")");
self.sendModMessage(actor.name + " banned " + ip + " (" + name + ")", self.permissions.ban);
// If in the channel already, kick the banned user
for (var i = 0; i < self.users.length; i++) {
if (self.users[i].ip === ip) {
self.kick(self.users[i], "You're banned!");
break;
}
}
if (!self.registered) {
return;
}
// channel, ip, name, reason, ban actor
db.channels.ban(self.name, ip, name, reason, actor.name);
});
};
/**
* Removes an IP ban
*/
Channel.prototype.unbanIP = function (actor, ip) {
var self = this;
if (!self.hasPermission(actor, "ban")) {
return;
}
var record = self.ipbans[ip];
delete self.ipbans[ip];
self.logger.log("*** " + actor.name + " unbanned " + ip + " (" + record.name + ")");
self.sendModMessage(actor.name + " unbanned " + util.maskIP(ip) + " (" + record.name + ")", self.permissions.ban);
if (!self.registered) {
return;
}
db.channels.unbanIP(self.name, ip);
};
/**
* Sends the banlist
*/
Channel.prototype.sendBanlist = function (users) {
var self = this;
var bans = [];
var unmaskedbans = [];
for (var ip in self.ipbans) {
bans.push({
ip: util.maskIP(ip),
name: self.ipbans[ip].name,
reason: self.ipbans[ip].reason,
bannedby: self.ipbans[ip].bannedby
});
unmaskedbans.push({
ip: ip,
name: self.ipbans[ip].name,
reason: self.ipbans[ip].reason,
bannedby: self.ipbans[ip].bannedby
});
}
users.forEach(function (u) {
if (!self.hasPermission(u, "ban")) {
return;
}
if (u.rank >= 255) {
u.socket.emit("banlist", unmaskedbans);
} else {
u.socket.emit("banlist", bans);
}
});
};
/**
* Sends the chat filter list
*/
Channel.prototype.sendChatFilters = function (users) {
var self = this;
var pkt = self.filters.map(function (f) {
return f.pack();
});
users.forEach(function (u) {
if (!self.hasPermission(u, "filteredit")) {
return;
}
u.socket.emit("chatFilters", f);
});
};
/**
* Sends the playlist
*/
Channel.prototype.sendPlaylist = function (users) {
var self = this;
var pl = self.playlist.items.toArray();
var current = null;
if (self.playlist.current) {
current = self.playlist.current.uid;
}
users.forEach(function (u) {
u.socket.emit("playlist", pl);
u.socket.emit("setPlaylistMeta", self.plmeta);
if (current !== null) {
u.socket.emit("setCurrent", current);
}
});
};
/**
* Searches channel library
*/
Channel.prototype.search = function (query, callback) {
var self = this;
if (!self.registered) {
callback([]);
return;
}
if (typeof query !== "string") {
query = "";
}
query = query.substring(0, 100);
db.channels.searchLibrary(self.name, query, function (err, res) {
if (err) {
res = [];
}
res.sort(function(a, b) {
var x = a.title.toLowerCase();
var y = b.title.toLowerCase();
return (x == y) ? 0 : (x < y ? -1 : 1);
});
res.forEach(function (r) {
r.duration = util.formatTime(r.seconds);
});
callback(res);
});
};
/**
* Sends the result of readLog() to a user if the user has sufficient permission
*/
2013-12-27 21:38:06 +00:00
Channel.prototype.tryReadLog = function (user) {
if (user.rank < 3) {
user.kick("Attempted readChanLog with insufficient permission");
return;
}
2013-12-28 02:06:10 +00:00
if (!self.registered) {
user.socket.emit("readChanLog", {
success: false,
data: "Channel log is only available to registered channels."
});
return;
}
2013-12-27 21:38:06 +00:00
var filterIp = user.global_rank < 255;
this.readLog(filterIp, function (err, data) {
if (err) {
user.socket.emit("readChanLog", {
success: false,
data: "Reading channel log failed."
});
} else {
user.socket.emit("readChanLog", {
success: true,
data: data
});
}
});
};
2013-12-28 02:06:10 +00:00
/**
* Reads the last 100KiB of the channel's log file, masking IP addresses if desired
*/
2013-12-27 21:38:06 +00:00
Channel.prototype.readLog = function (filterIp, callback) {
var maxLen = 102400; // Limit to last 100KiB
var file = this.logger.filename;
fs.stat(file, function (err, data) {
if (err) {
callback(err, null);
return;
}
var start = Math.max(data.size - maxLen, 0);
var end = data.size - 1;
var rs = fs.createReadStream(file, {
start: start,
end: end
});
var buffer = "";
rs.on("data", function (data) {
buffer += data;
});
rs.on("end", function () {
if (filterIp) {
buffer = buffer.replace(
/\d+\.\d+\.(\d+\.\d+)/g,
"x.x.$1"
).replace(
/\d+\.\d+\.(\d+)/g,
"x.x.$.*"
);
}
callback(null, buffer);
});
2013-12-27 16:08:03 +00:00
});
};