diff --git a/lib/account.js b/lib/account.js new file mode 100644 index 00000000..0d9e01f6 --- /dev/null +++ b/lib/account.js @@ -0,0 +1,155 @@ +var db = require("./database"); +var Q = require("q"); + +function Account(opts) { + var defaults = { + name: "", + ip: "", + aliases: [], + globalRank: -1, + channelRank: -1, + guest: true, + profile: { + image: "", + text: "" + } + }; + + this.name = opts.name || defaults.name; + this.lowername = this.name.toLowerCase(); + this.ip = opts.ip || defaults.ip; + this.aliases = opts.aliases || defaults.aliases; + this.globalRank = "globalRank" in opts ? opts.globalRank : defaults.globalRank; + this.channelRank = "channelRank" in opts ? opts.channelRank : defaults.channelRank; + this.effectiveRank = Math.max(this.globalRank, this.channelRank); + this.guest = this.globalRank === 0; + this.profile = opts.profile || defaults.profile; +} + +module.exports.default = function (ip) { + return new Account({ ip: ip }); +}; + +module.exports.getAccount = function (name, ip, opts, cb) { + if (!cb) { + cb = opts; + opts = {}; + } + opts.channel = opts.channel || false; + + var data = {}; + Q.nfcall(db.getAliases, ip) + .then(function (aliases) { + data.aliases = aliases; + if (name && opts.registered) { + return Q.nfcall(db.users.getGlobalRank, name); + } else if (name) { + return 0; + } else { + return -1; + } + }).then(function (globalRank) { + data.globalRank = globalRank; + if (opts.channel && opts.registered) { + return Q.nfcall(db.channels.getRank, opts.channel, name); + } else { + if (opts.registered) { + return 1; + } else if (name) { + return 0; + } else { + return -1; + } + } + }).then(function (chanRank) { + data.channelRank = chanRank; + /* Look up profile for registered user */ + if (data.globalRank >= 1) { + return Q.nfcall(db.users.getProfile, name); + } else { + return { text: "", image: "" }; + } + }).then(function (profile) { + setImmediate(function () { + cb(null, new Account({ + name: name, + ip: ip, + aliases: data.aliases, + globalRank: data.globalRank, + channelRank: data.channelRank, + profile: profile + })); + }); + }).catch(function (err) { + cb(err, null); + }).done(); +}; + +module.exports.rankForName = function (name, opts, cb) { + if (!cb) { + cb = opts; + opts = {}; + } + + var rank = 0; + Q.fcall(function () { + return Q.nfcall(db.users.getGlobalRank, name); + }).then(function (globalRank) { + rank = globalRank; + if (opts.channel) { + return Q.nfcall(db.channels.getRank, opts.channel, name); + } else { + return globalRank > 0 ? 1 : 0; + } + }).then(function (chanRank) { + setImmediate(function () { + cb(null, Math.max(rank, chanRank)); + }); + }).catch(function (err) { + cb(err, 0); + }).done(); +}; + +module.exports.rankForIP = function (ip, opts, cb) { + if (!cb) { + cb = opts; + opts = {}; + } + + var globalRank, rank, names; + + var promise = Q.nfcall(db.getAliases, ip) + .then(function (_names) { + names = _names; + return Q.nfcall(db.users.getGlobalRanks, names); + }).then(function (ranks) { + ranks.push(0); + globalRank = Math.max.apply(Math, ranks); + rank = globalRank; + }); + + if (!opts.channel) { + promise.then(function () { + setImmediate(function () { + cb(null, globalRank); + }); + }).catch(function (err) { + cb(err, null); + }).done(); + } else { + promise.then(function () { + return Q.nfcall(db.channels.getRanks, opts.channel, names); + }).then(function (ranks) { + ranks.push(globalRank); + rank = Math.max.apply(Math, ranks); + }).then(function () { + setImmediate(function () { + cb(null, rank); + }); + }).catch(function (err) { + setImmediate(function () { + cb(err, null); + }); + }).done(); + } +}; diff --git a/lib/acp.js b/lib/acp.js index d2af8b7c..f7f23d0f 100644 --- a/lib/acp.js +++ b/lib/acp.js @@ -17,7 +17,7 @@ var Config = require("./config"); var Server = require("./server"); function eventUsername(user) { - return user.name + "@" + user.ip; + return user.getName() + "@" + user.ip; } function handleAnnounce(user, data) { @@ -26,7 +26,7 @@ function handleAnnounce(user, data) { sv.announce({ title: data.title, text: data.content, - from: user.name + from: user.getName() }); Logger.eventlog.log("[acp] " + eventUsername(user) + " opened announcement `" + diff --git a/lib/actionlog.js b/lib/actionlog.js deleted file mode 100644 index f033df89..00000000 --- a/lib/actionlog.js +++ /dev/null @@ -1,64 +0,0 @@ -/* -The MIT License (MIT) -Copyright (c) 2013 Calvin Montgomery - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -*/ - -var Logger = require("./logger"); -var Server = require("./server"); - -module.exports = { - record: function (ip, name, action, args) { - var db = Server.getServer().db; - if(!args) - args = ""; - else { - try { - args = JSON.stringify(args); - } catch(e) { - args = ""; - } - } - - //db.recordAction(ip, name, action, args); - }, - - clear: function (actions) { - var db = Server.getServer().db; - //db.clearActions(actions); - }, - - clearOne: function (item) { - var db = Server.getServer().db; - //db.clearSingleAction(item); - }, - - throttleRegistrations: function (ip, callback) { - var db = Server.getServer().db; - /* - db.recentRegistrationCount(ip, function (err, count) { - if(err) { - callback(err, null); - return; - } - - callback(null, count > 4); - }); - */ - }, - - listActionTypes: function (callback) { - var db = Server.getServer().db; - //db.listActionTypes(callback); - }, - - listActions: function (types, callback) { - var db = Server.getServer().db; - //db.listActions(types, callback); - } -}; diff --git a/lib/api.js b/lib/api.js deleted file mode 100644 index 42ba1c27..00000000 --- a/lib/api.js +++ /dev/null @@ -1,848 +0,0 @@ -/* -The MIT License (MIT) -Copyright (c) 2013 Calvin Montgomery - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -*/ - -var Logger = require("./logger"); -var fs = require("fs"); -var path = require("path"); -var $util = require("./utilities"); -var ActionLog = require("./actionlog"); - -module.exports = function (Server) { - function getIP(req) { - var raw = req.connection.remoteAddress; - var forward = req.header("x-forwarded-for"); - if((Server.cfg["trust-x-forward"] || raw === "127.0.0.1") && forward) { - var ip = forward.split(",")[0]; - Logger.syslog.log("REVPROXY " + raw + " => " + ip); - return ip; - } - return raw; - } - - function getChannelData(channel) { - var data = { - name: channel.name, - loaded: true - }; - - data.pagetitle = channel.opts.pagetitle; - data.media = channel.playlist.current ? - channel.playlist.current.media.pack() : - {}; - data.usercount = channel.users.length; - data.voteskip_eligible = channel.calcVoteskipMax(); - data.users = []; - for(var i in channel.users) - if(channel.users[i].name !== "") - data.users.push(channel.users[i].name); - - data.chat = []; - for(var i in channel.chatbuffer) - data.chat.push(channel.chatbuffer[i]); - - return data; - } - - var app = Server.express; - var db = Server.db; - - /* */ - app.get("/api/coffee", function (req, res) { - res.send(418); // 418 I'm a teapot - }); - - /* REGION channels */ - - /* data about a specific channel */ - app.get("/api/channels/:channel", function (req, res) { - var name = req.params.channel; - if(!$util.isValidChannelName(name)) { - res.send(404); - return; - } - - var data = { - name: name, - loaded: false - }; - - var needPassword = false; - var chan = null; - if (Server.isChannelLoaded(name)) { - chan = Server.getChannel(name); - data = getChannelData(chan); - needPassword = chan.opts.password; - } - - if (needPassword !== false) { - var pw = req.query.password; - if (pw !== needPassword) { - var uname = req.cookies.cytube_uname; - var session = req.cookies.cytube_session; - Server.db.users.verifyAuth(uname + ":" + session, function (err, row) { - if (err) { - res.status(403); - res.type("application/json"); - res.jsonp({ - error: "Password required to view this channel" - }); - return; - } - - if (chan !== null) { - chan.getRank(uname, function (err, rank) { - if (err || rank < 2) { - res.status(403); - res.type("application/json"); - res.jsonp({ - error: "Password required to view this channel" - }); - return; - } - - res.type("application/json"); - res.jsonp(data); - }); - } - }); - return; - } - } - - res.type("application/json"); - res.jsonp(data); - }); - - /* data about all channels (filter= public or all) */ - app.get("/api/allchannels/:filter", function (req, res) { - var filter = req.params.filter; - if(filter !== "public" && filter !== "all") { - res.send(400); - return; - } - - var query = req.query; - - // Listing non-public channels requires authenticating as an admin - if(filter !== "public") { - var name = query.name || ""; - var session = query.session || ""; - db.users.verifyAuth(name + ":" + session, function (err, row) { - if(err) { - if(err !== "Invalid session" && - err !== "Session expired") { - res.send(500); - } else { - res.send(403); - } - return; - } - - if(row.global_rank < 255) { - res.send(403); - return; - } - - var channels = []; - for(var key in Server.channels) { - var channel = Server.channels[key]; - channels.push(getChannelData(channel)); - } - - res.type("application/jsonp"); - res.jsonp(channels); - }); - } - - // If we get here, the filter is public channels - - var channels = []; - for(var key in Server.channels) { - var channel = Server.channels[key]; - if(channel.opts.show_public && channel.opts.password === false) - channels.push(getChannelData(channel)); - } - - res.type("application/jsonp"); - res.jsonp(channels); - }); - - /* ENDREGION channels */ - - /* REGION authentication, account management */ - - /* login */ - app.post("/api/login", function (req, res) { - res.type("application/jsonp"); - res.setHeader("Access-Control-Allow-Origin", "*"); - var name = req.body.name || ""; - var pw = req.body.pw || ""; - var session = req.body.session || ""; - - // for some reason CyTube previously allowed guest logins - // over the API...wat - if(!pw && !session) { - res.jsonp({ - success: false, - error: "You must provide a password" - }); - return; - } - - var callback = function (err, row) { - if(err) { - if(err !== "Session expired") - ActionLog.record(getIP(req), name, "login-failure", err); - res.jsonp({ - success: false, - error: err - }); - return; - } - - // Only record login-success for admins - if(row.global_rank >= 255) - ActionLog.record(getIP(req), name, "login-success"); - - res.jsonp({ - success: true, - name: name, - session: row.hash - }); - }; - - if (session) { - db.users.verifyAuth(name + ":" + session, callback); - } else { - db.users.verifyLogin(name, pw, callback); - } - }); - - /* register an account */ - app.post("/api/register", function (req, res) { - res.type("application/jsonp"); - res.setHeader("Access-Control-Allow-Origin", "*"); - var name = req.body.name; - var pw = req.body.pw; - if (typeof name !== "string" || - typeof pw !== "string") { - res.status(400); - res.jsonp({ - success: false, - error: "Invalid request" - }); - return; - } - var ip = getIP(req); - - // Limit registrations per IP within a certain time period - ActionLog.throttleRegistrations(ip, function (err, toomany) { - if(err) { - res.jsonp({ - success: false, - error: err - }); - return; - } - - if(toomany) { - ActionLog.record(ip, name, "register-failure", - "Too many recent registrations"); - res.jsonp({ - success: false, - error: "Your IP address has registered too many " + - "accounts in the past 48 hours. Please wait " + - "a while before registering another." - }); - return; - } - - if(!pw) { - // costanza.jpg - res.jsonp({ - success: false, - error: "You must provide a password" - }); - return; - } - - // db.registerUser checks if the name is taken already - db.users.register(name, pw, "", req.ip, function (err, session) { - if(err) { - res.jsonp({ - success: false, - error: err - }); - return; - } - - ActionLog.record(ip, name, "register-success"); - res.jsonp({ - success: true, - session: session - }); - }); - }); - }); - - /* password change */ - app.post("/api/account/passwordchange", function (req, res) { - res.type("application/jsonp"); - res.setHeader("Access-Control-Allow-Origin", "*"); - - var name = req.body.name; - var oldpw = req.body.oldpw; - var newpw = req.body.newpw; - - if (typeof name !== "string" || - typeof oldpw !== "string" || - typeof newpw !== "string") { - res.status(400); - res.jsonp({ - success: false, - error: "Invalid request" - }); - return; - } - - if(!oldpw || !newpw) { - res.jsonp({ - success: false, - error: "Password cannot be empty" - }); - return; - } - - db.users.verifyLogin(name, oldpw, function (err, row) { - if(err) { - res.jsonp({ - success: false, - error: err - }); - return; - } - - db.users.setPassword(name, newpw, function (err, row) { - if(err) { - res.jsonp({ - success: false, - error: err - }); - return; - } - - ActionLog.record(getIP(req), name, "password-change"); - res.jsonp({ - success: true - }); - }); - }); - }); - - /* password reset */ - app.post("/api/account/passwordreset", function (req, res) { - res.type("application/jsonp"); - res.setHeader("Access-Control-Allow-Origin", "*"); - var name = req.body.name; - var email = req.body.email; - if (typeof name !== "string" || - typeof email !== "string") { - res.status(400); - res.jsonp({ - success: false, - error: "Invalid request" - }); - return; - } - var ip = getIP(req); - var hash = false; - - db.genPasswordReset(ip, name, email, function (err, hash) { - if(err) { - res.jsonp({ - success: false, - error: err - }); - return; - } - ActionLog.record(ip, name, "password-reset-generate", email); - if(!Server.cfg["enable-mail"]) { - res.jsonp({ - success: false, - error: "This server does not have email recovery " + - "enabled. Contact an administrator for " + - "assistance." - }); - return; - } - - if(!email) { - res.jsonp({ - success: false, - error: "You don't have a recovery email address set. "+ - "Contact an administrator for assistance." - }); - return; - } - - var msg = "A password reset request was issued for your " + - "account '"+ name + "' on " + Server.cfg["domain"] + - ". This request is valid for 24 hours. If you did "+ - "not initiate this, there is no need to take action."+ - " To reset your password, copy and paste the " + - "following link into your browser: " + - Server.cfg["domain"] + "/reset.html?"+hash; - - var mail = { - from: "CyTube Services <" + Server.cfg["mail-from"] + ">", - to: email, - subject: "Password reset request", - text: msg - }; - - Server.cfg["nodemailer"].sendMail(mail, function (err, response) { - if(err) { - Logger.errlog.log("mail fail: " + err); - res.jsonp({ - success: false, - error: "Email send failed. Contact an administrator "+ - "if this persists" - }); - } else { - res.jsonp({ - success: true - }); - } - }); - }); - }); - - /* password recovery */ - app.get("/api/account/passwordrecover", function (req, res) { - res.type("application/jsonp"); - var hash = req.query.hash; - if (typeof hash !== "string") { - res.status(400); - res.jsonp({ - success: false, - error: "Invalid request" - }); - return; - } - var ip = getIP(req); - - db.recoverUserPassword(hash, function (err, auth) { - if(err) { - ActionLog.record(ip, "", "password-recover-failure", hash); - res.jsonp({ - success: false, - error: err - }); - return; - } - ActionLog.record(ip, auth.name, "password-recover-success"); - res.jsonp({ - success: true, - name: auth.name, - pw: auth.pw - }); - }); - }); - - /* profile retrieval */ - app.get("/api/users/:user/profile", function (req, res) { - res.type("application/jsonp"); - var name = req.params.user; - - db.getUserProfile(name, function (err, profile) { - if(err) { - res.jsonp({ - success: false, - error: err - }); - return; - } - - res.jsonp({ - success: true, - profile_image: profile.profile_image, - profile_text: profile.profile_text - }); - }); - }); - - /* profile change */ - app.post("/api/account/profile", function (req, res) { - res.type("application/jsonp"); - res.setHeader("Access-Control-Allow-Origin", "*"); - var name = req.body.name; - var session = req.body.session; - var img = req.body.profile_image; - var text = req.body.profile_text; - if (typeof name !== "string" || - typeof session !== "string" || - typeof img !== "string" || - typeof text !== "string") { - res.status(400); - res.jsonp({ - success: false, - error: "Invalid request" - }); - return; - } - - if (img.length > 255) { - img = img.substring(0, 255); - } - - if (text.length > 255) { - text = text.substring(0, 255); - } - - db.verifyAuth(name + ":" + session, function (err, row) { - if(err) { - res.jsonp({ - success: false, - error: err - }); - return; - } - - db.setUserProfile(name, { image: img, text: text }, - function (err, dbres) { - if(err) { - res.jsonp({ - success: false, - error: err - }); - return; - } - - res.jsonp({ success: true }); - name = name.toLowerCase(); - for(var i in Server.channels) { - var chan = Server.channels[i]; - for(var j in chan.users) { - var user = chan.users[j]; - if(user.name.toLowerCase() == name) { - user.profile = { - image: img, - text: text - }; - chan.sendAll("setUserProfile", { - name: user.name, - profile: user.profile - }); - } - } - } - }); - }); - }); - - /* set email */ - app.post("/api/account/email", function (req, res) { - res.type("application/jsonp"); - res.setHeader("Access-Control-Allow-Origin", "*"); - var name = req.body.name; - var pw = req.body.pw; - var email = req.body.email; - - if (typeof name !== "string" || - typeof pw !== "string" || - typeof email !== "string") { - res.status(400); - res.jsonp({ - success: false, - error: "Invalid request" - }); - return; - } - - - if(!email.match(/^[\w_\.]+@[\w_\.]+[a-z]+$/i)) { - res.jsonp({ - success: false, - error: "Invalid email address" - }); - return; - } - - if(email.match(/.*@(localhost|127\.0\.0\.1)/i)) { - res.jsonp({ success: false, - error: "Nice try, but no" - }); - return; - } - - db.users.verifyLogin(name, pw, function (err, row) { - if(err) { - res.jsonp({ - success: false, - error: err - }); - return; - } - - db.users.setEmail(name, email, function (err, dbres) { - if(err) { - res.jsonp({ - success: false, - error: err - }); - return; - } - - ActionLog.record(getIP(req), name, "email-update", email); - res.jsonp({ - success: true, - session: row.hash - }); - }); - }); - }); - - /* my channels */ - app.get("/api/account/mychannels", function (req, res) { - res.type("application/jsonp"); - var name = req.query.name; - var session = req.query.session; - - if (typeof name !== "string" || - typeof session !== "string") { - res.status(400); - res.jsonp({ - success: false, - error: "Invalid request" - }); - return; - } - - db.users.verifyAuth(name + ":" + session, function (err, row) { - if(err) { - res.jsonp({ - success: false, - error: err - }); - return; - } - - db.listUserChannels(name, function (err, dbres) { - if(err) { - res.jsonp({ - success: false, - channels: [] - }); - return; - } - - res.jsonp({ - success: true, - channels: dbres - }); - }); - }); - - }); - - /* END REGION */ - - /* REGION log reading */ - - /* action log */ - app.get("/api/logging/actionlog", function (req, res) { - res.type("application/jsonp"); - var name = req.query.name; - var session = req.query.session; - var types = req.query.actions; - - if (typeof name !== "string" || - typeof session !== "string" || - typeof types !== "string") { - res.status(400); - res.jsonp({ - success: false, - error: "Invalid request" - }); - return; - } - - db.verifyAuth(name + ":" + session, function (err, row) { - if(err) { - if(err !== "Invalid session" && - err !== "Session expired") { - res.send(500); - } else { - res.send(403); - } - return; - } - - if(row.global_rank < 255) { - res.send(403); - return; - } - - types = types.split(","); - ActionLog.listActions(types, function (err, actions) { - if(err) - actions = []; - - res.jsonp(actions); - }); - }); - }); - - /* helper function to pipe the last N bytes of a file */ - function pipeLast(res, file, len) { - fs.stat(file, function (err, data) { - if(err) { - res.send(500); - return; - } - var start = data.size - len; - if(start < 0) - start = 0; - var end = data.size - 1; - if(end < 0) - end = 0; - fs.createReadStream(file, { start: start, end: end }) - .pipe(res); - }); - } - - app.get("/api/logging/syslog", function (req, res) { - res.type("text/plain"); - res.setHeader("Access-Control-Allow-Origin", "*"); - - var name = req.query.name; - var session = req.query.session; - - if (typeof name !== "string" || - typeof session !== "string") { - res.status(400); - res.jsonp({ - success: false, - error: "Invalid request" - }); - return; - } - - db.users.verifyAuth(name + ":" + session, function (err, row) { - if(err) { - if(err !== "Invalid session" && - err !== "Session expired") { - res.send(500); - } else { - res.send(403); - } - return; - } - - if(row.global_rank < 255) { - res.send(403); - return; - } - - pipeLast(res, path.join(__dirname, "../sys.log"), 1048576); - }); - }); - - app.get("/api/logging/errorlog", function (req, res) { - res.type("text/plain"); - res.setHeader("Access-Control-Allow-Origin", "*"); - - var name = req.query.name; - var session = req.query.session; - - if (typeof name !== "string" || - typeof session !== "string") { - res.status(400); - res.jsonp({ - success: false, - error: "Invalid request" - }); - return; - } - - db.users.verifyAuth(name + ":" + session, function (err, row) { - if(err) { - if(err !== "Invalid session" && - err !== "Session expired") { - res.send(500); - } else { - res.send(403); - } - return; - } - - if(row.global_rank < 255) { - res.send(403); - return; - } - - pipeLast(res, path.join(__dirname, "../error.log"), 1048576); - }); - }); - - app.get("/api/logging/channels/:channel", function (req, res) { - res.type("text/plain"); - res.setHeader("Access-Control-Allow-Origin", "*"); - - var name = req.query.name; - var session = req.query.session; - - if (typeof name !== "string" || - typeof session !== "string") { - res.status(400); - res.jsonp({ - success: false, - error: "Invalid request" - }); - return; - } - - db.users.verifyAuth(name + ":" + session, function (err, row) { - if(err) { - if(err !== "Invalid session" && - err !== "Session expired") { - res.send(500); - } else { - res.send(403); - } - return; - } - - if(row.global_rank < 255) { - res.send(403); - return; - } - - var chan = req.params.channel || ""; - if(!$util.isValidChannelName(chan)) { - res.send(400); - return; - } - - fs.exists(path.join(__dirname, "../chanlogs", chan + ".log"), - function(exists) { - if(exists) { - pipeLast(res, path.join(__dirname, "../chanlogs", - chan + ".log"), 1048576); - } else { - res.send(404); - } - }); - }); - }); - - return null; -} diff --git a/lib/channel.js b/lib/channel.js deleted file mode 100644 index 9a6ae3d1..00000000 --- a/lib/channel.js +++ /dev/null @@ -1,3405 +0,0 @@ -var util = require("./utilities"); -var db = require("./database"); -var Playlist = require("./playlist"); -var Poll = require("./poll").Poll; -var Filter = require("./filter").Filter; -var Logger = require("./logger"); -var AsyncQueue = require("./asyncqueue"); -var MakeEmitter = require("./emitter"); -var InfoGetter = require("./get-info"); -var ChatCommand = require("./chatcommand"); -var XSS = require("./xss"); -var Media = require("./media").Media; -var Config = require("./config"); - -var fs = require("fs"); -var path = require("path"); -var url = require("url"); - -var DEFAULT_FILTERS = [ - new Filter("monospace", "`(.+?)`", "g", "$1"), - new Filter("bold", "\\*(.+?)\\*", "g", "$1"), - new Filter("italic", "_(.+?)_", "g", "$1"), - new Filter("strike", "~~(.+?)~~", "g", "$1"), - new Filter("inline spoiler", "\\[sp\\](.*?)\\[\\/sp\\]", "ig", "$1") -]; - -function Channel(name) { - MakeEmitter(this); - var self = this; // Alias `this` to prevent scoping issues - 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 = []; - self.mutedUsers = new util.Set(); - self.playlist = new Playlist(self); - self.plmeta = { count: 0, time: "00:00:00" }; - 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 = { - seeplaylist: -1, // See the playlist - playlistadd: 1.5, // Add video to the playlist - playlistnext: 1.5, - playlistmove: 1.5, // Move a video on the playlist - playlistdelete: 2, // Delete a video from the playlist - playlistjump: 1.5, // Start a different video on the playlist - 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 - }; - 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
) - }; - self.filters = []; - DEFAULT_FILTERS.forEach(function (f) { - var filt = new Filter(f.name, f.source, f.flags, f.replace); - self.updateFilter(filt); - }); - self.emotes = []; - 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 - - 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.isMuted = function (name) { - return this.mutedUsers.contains(name.toLowerCase()) || - this.mutedUsers.contains("[shadow]" + name.toLowerCase()); -}; - -Channel.prototype.isShadowMuted = function (name) { - return this.mutedUsers.contains("[shadow]" + name.toLowerCase()); -}; - -Channel.prototype.mutedUsers = function () { - var self = this; - return self.users.filter(function (u) { - return self.mutedUsers.contains(u.name); - }); -}; - -Channel.prototype.shadowMutedUsers = function () { - var self = this; - return self.users.filter(function (u) { - return self.mutedUsers.contains("[shadow]" + u.name); - }); -}; - -Channel.prototype.channelModerators = function () { - return this.users.filter(function (u) { - return u.rank >= 2; - }); -}; - -Channel.prototype.channelAdmins = function () { - return this.users.filter(function (u) { - return u.rank >= 3; - }); -}; - -Channel.prototype.tryLoadState = function () { - var self = this; - if (self.name === "") { - return; - } - - // Don't load state if the channel isn't registered - if (!self.registered) { - self.setUnregisteredPermissions(); - 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 || self.dead) { - return; - } - - fs.readFile(path.join(__dirname, "../chandump", self.uniqueName), - 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); - self.setMotd("Channel state load failed. Contact an administrator."); - self.error = true; - self.emit("ready"); - } - return; - } - - try { - self.logger.log("[init] Loading channel state from disk"); - data = JSON.parse(data); - - // Load the playlist - if ("playlist" in data) { - self.playlist.load(data.playlist, function () { - self.sendPlaylist(self.users); - self.updatePlaylistMeta(); - self.sendPlaylistMeta(self.users); - self.playlist.startPlayback(data.playlist.time); - }); - } - - // Playlist lock - self.setLock(data.playlistLock || false); - - // Configurables - if ("opts" in data) { - for (var key in data.opts) { - self.opts[key] = data.opts[key]; - } - } - - // 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); - } - } - - // Emotes - if ("emotes" in data) { - data.emotes.forEach(function (e) { - self.updateEmote(e); - }); - } - - // 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"); - - } catch (e) { - 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; - } - - self.logger.log("[init] Saving channel state to disk"); - - var filters = self.filters.map(function (f) { - return f.pack(); - }); - - var data = { - playlist: self.playlist.dump(), - opts: self.opts, - permissions: self.permissions, - filters: filters, - emotes: self.emotes, - 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) { - if (this.ready) { - setImmediate(fn); - } else { - this.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)); - }); - }); -}; - -/** - * 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 globalRank = res.reduce(function (a, b) { - return Math.max(a, b); - }, 0); - - if (!self.registered) { - callback(null, globalRank); - 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); - }, globalRank); - - callback(null, rank); - }); - }); - }); -}; - -/** - * Called when a user attempts to join a channel. - * Handles password check - */ -Channel.prototype.preJoin = function (user, password) { - var self = this; - - // Allow channel to unload if the only user disconnects without finishing a join - user.socket.on("disconnect", function () { - if (!user.inChannel() && !self.dead && self.users.length === 0) { - self.emit("empty"); - } - }); - - self.whenReady(function () { - if (self.dead) { - return; - } - - user.whenLoggedIn(function () { - 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); - user.hasChannelRank = true; - user.emit("channelRank", user.rank); - - if (self.permissions.seeplaylist > -1) { - self.sendPlaylist([user]); - } - }); - }); - - if (self.opts.password !== false && user.rank < 2) { - if (password !== self.opts.password) { - var checkPassword = function (pw) { - if (self.dead || user.inChannel()) { - return; - } - - if (pw !== self.opts.password) { - user.socket.emit("needPassword", true); - return; - } - - user.socket.emit("cancelNeedPassword"); - self.join(user); - }; - - - user.socket.on("channelPassword", checkPassword); - user.socket.emit("needPassword", typeof password !== "undefined"); - user.once("channelRank", function (r) { - if (!user.inChannel() && !self.dead && r >= 2) { - user.socket.emit("cancelNeedPassword"); - self.join(user); - } - }); - return; - } - } - - self.join(user); - }); -}; - -/** - * Called when a user joins a channel - */ -Channel.prototype.join = function (user) { - var self = this; - - var afterLogin = function () { - if (self.dead) { - return; - } - - var lname = user.name.toLowerCase(); - for (var i = 0; i < self.users.length; i++) { - if (self.users[i].name.toLowerCase() === lname && self.users[i] !== user) { - self.users[i].kick("Duplicate login"); - } - } - - self.sendUserJoin(self.users, user); - self.sendUserlist([user]); - self.logger.log("[login] " + user.ip + " logged in as " + user.name); - }; - - var afterIPBan = function () { - user.autoAFK(); - user.socket.join(self.uniqueName); - user.channel = self; - - user.autoAFK(); - - if (!self.registered) { - user.socket.emit("channelNotRegistered"); - } - - self.users.push(user); - self.sendVoteskipUpdate(self.users); - self.sendUsercount(self.users); - - user.whenChannelRank(function () { - if (!self.registered) { - afterLogin(); - return; - } - - db.channels.isNameBanned(self.name, user.name, function (err, banned) { - if (!err && banned) { - user.kick("You're banned!"); - } else { - afterLogin(); - } - }); - }); - - if (self.hasPermission(user, "seeplaylist")) { - self.sendPlaylist([user]); - } - self.sendMediaUpdate([user]); - self.sendPlaylistLock([user]); - self.sendUserlist([user]); - self.sendEmoteList([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("[login] " + user.ip + " joined"); - Logger.syslog.log(user.ip + " joined channel " + self.name); - }; - - if (!self.registered) { - afterIPBan(); - return; - } - - db.channels.isIPBanned(self.name, user.ip, function (err, banned) { - if (!err && banned) { - user.kick("You're banned!"); - return; - } else { - afterIPBan(); - } - }); -}; - -/** - * Called when a user leaves the channel. - * Cleans up and sends appropriate updates to other users - */ -Channel.prototype.part = function (user) { - var self = this; - user.channel = null; - - // Clear poll vote - if (self.poll) { - self.poll.unvote(user.ip); - self.sendPollUpdate(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("[login] " + user.ip + " (" + user.name + ") left"); - if (self.users.length === 0) { - self.emit("empty"); - return; - } -}; - -/** - * Send the MOTD to the given users - */ -Channel.prototype.sendMOTD = function (users) { - var motd = this.motd; - users.forEach(function (u) { - u.socket.emit("setMotd", motd); - }); -}; - -/** - * 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() - }; - - this.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 (this.registered) { - db.channels.addToLibrary(this.name, media); - } -}; - -/** - * Attempts to ban a user by name - */ -Channel.prototype.handleNameBan = function (actor, name, reason) { - var self = this; - if (!self.hasPermission(actor, "ban")) { - return false; - } - - if (!self.registered) { - actor.socket.emit("errorMsg", { - msg: "Banning is only supported in registered channels" - }); - return; - } - - 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 && err !== "User does not exist") { - actor.socket.emit("errorMsg", { - msg: "Internal error: " + err - }); - return; - } else if (err === "User does not exist") { - rank = 0; - } - - 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); - - // 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.users[i].kick("You're banned!"); - break; - } - } - self.logger.log("[mod] " + actor.name + " namebanned " + name); - self.sendModMessage(actor.name + " banned " + name, self.permissions.ban); - - db.channels.isNameBanned(self.name, name, function (err, banned) { - if (!err && banned) { - actor.socket.emit("errorMsg", { - msg: name + " is already banned" - }); - return; - } - - if (self.dead) { - return; - } - - // channel, ip, name, reason, actor - db.channels.ban(self.name, "*", name, reason, actor.name); - // TODO send banlist? - }); - }); -}; - -/** - * Removes a ban by ID - */ -Channel.prototype.handleUnban = function (actor, data) { - var self = this; - if (!this.hasPermission(actor, "ban")) { - return; - } - - if (typeof data.id !== "number") { - data.id = parseInt(data.id); - if (isNaN(data.id)) { - return; - } - } - - data.actor = actor.name; - - if (!self.registered) { - return; - } - - db.channels.unbanId(self.name, data.id, function (err, res) { - if (err) { - actor.socket.emit("errorMsg", { - msg: err - }); - return; - } - - self.sendUnban(self.users, data); - }); -}; - -/** - * Sends an unban packet - */ -Channel.prototype.sendUnban = function (users, data) { - var self = this; - users.forEach(function (u) { - if (self.hasPermission(u, "ban")) { - u.socket.emit("banlistRemove", data); - } - }); - self.logger.log("[mod] " + data.actor + " unbanned " + data.name); - self.sendModMessage(data.actor + " unbanned " + data.name, self.permissions.ban); -}; - -/** - * Bans all IP addresses associated with a username - */ -Channel.prototype.handleBanAllIP = function (actor, name, reason, range) { - var self = this; - if (!self.hasPermission(actor, "ban")) { - return; - } - - if (typeof name !== "string") { - return; - } - - if (!self.registered) { - actor.socket.emit("errorMsg", { - msg: "Banning is not supported for unregistered rooms" - }); - 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.banIP(actor, ip, name, reason, range); - }); - }); -}; - -/** - * Bans an individual IP - */ -Channel.prototype.banIP = function (actor, ip, name, reason, range) { - var self = this; - - 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.logger.log("[mod] " + actor.name + " banned " + ip + " (" + name + ")"); - self.sendModMessage(actor.name + " banned " + util.maskIP(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.users[i].kick("You're banned!"); - break; - } - } - - if (!self.registered) { - return; - } - - db.channels.isIPBanned(self.name, ip, function (err, banned) { - if (!err && banned) { - var disp = actor.global_rank >= 255 ? ip : util.maskIP(ip); - actor.socket.emit("errorMsg", { - msg: disp + " is alraedy banned" - }); - return; - } - - if (self.dead) { - return; - } - - // channel, ip, name, reason, ban actor - db.channels.ban(self.name, ip, name, reason, actor.name, function (err) { - if (err) { - actor.socket.emit("errorMsg", { - msg: "Ban failed: " + err - }); - } - }); - }); - }); -}; - - -/** - * Sends the banlist - */ -Channel.prototype.sendBanlist = function (users) { - var self = this; - - if (!self.registered) { - return; - } - - var bans = []; - var unmaskedbans = []; - db.channels.listBans(self.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 (!self.hasPermission(u, "ban")) { - return; - } - - if (u.rank >= 255) { - u.socket.emit("banlist", unmaskedbans); - } else { - u.socket.emit("banlist", bans); - } - }); - }); -}; - -/** - * Sends the channel ranks list - */ -Channel.prototype.sendChannelRanks = function (users) { - var self = this; - - if (!self.registered) { - return; - } - - db.channels.allRanks(self.name, function (err, ranks) { - if (err) { - return; - } - - users.forEach(function (u) { - if (u.rank >= 3) { - u.socket.emit("channelRanks", ranks); - } - }); - }); -}; - -/** - * 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", pkt); - }); -}; - -/** - * Sends the emote list - */ -Channel.prototype.sendEmoteList = function (users) { - var self = this; - users.forEach(function (u) { - u.socket.emit("emoteList", self.emotes); - }); -}; - -/** - * Sends the channel permissions - */ -Channel.prototype.sendPermissions = function (users) { - var perms = this.permissions; - users.forEach(function (u) { - u.socket.emit("setPermissions", perms); - }); -}; - -/** - * 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) { - if (self.hasPermission(u, "seeplaylist")) { - u.socket.emit("playlist", pl); - u.socket.emit("setPlaylistMeta", self.plmeta); - if (current !== null) { - u.socket.emit("setCurrent", current); - } - } - }); -}; - -/** - * Updates the playlist count/time - */ -Channel.prototype.updatePlaylistMeta = function () { - var total = 0; - var iter = this.playlist.items.first; - while (iter !== null) { - if (iter.media !== null) { - total += iter.media.seconds; - } - iter = iter.next; - } - - var timestr = util.formatTime(total); - this.plmeta = { - count: this.playlist.items.length, - time: timestr - }; -}; - -/** - * Send the playlist count/time - */ -Channel.prototype.sendPlaylistMeta = function (users) { - var self = this; - users.forEach(function (u) { - if (self.hasPermission(u, "seeplaylist")) { - u.socket.emit("setPlaylistMeta", self.plmeta); - } - }); -}; - -/** - * Sends the playlist lock - */ -Channel.prototype.sendPlaylistLock = function (users) { - var lock = this.playlistLock; - users.forEach(function (u) { - u.socket.emit("setPlaylistLocked", lock); - }); -}; - -/** - * Sends a changeMedia packet - */ -Channel.prototype.sendMediaUpdate = function (users) { - var update = this.playlist.getFullUpdate(); - if (update) { - users.forEach(function (u) { - u.socket.emit("changeMedia", update); - }); - } -}; - -/** - * Sends the drink count - */ -Channel.prototype.sendDrinkCount = function (users) { - var drinks = this.drinks; - users.forEach(function (u) { - u.socket.emit("drinkCount", drinks); - }); -}; - -/** - * Send the userlist - */ -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.name === "") { - 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.global_rank >= 255) { - u.socket.emit("userlist", sadmin); - } else if (u.rank >= 2) { - u.socket.emit("userlist", mod); - } else { - u.socket.emit("userlist", base); - } - - if (self.leader != null) { - u.socket.emit("setLeader", self.leader.name); - } - }); -}; - -/** - * Send the user count - */ -Channel.prototype.sendUsercount = function (users) { - var self = this; - users.forEach(function (u) { - u.socket.emit("usercount", self.users.length); - }); -}; - -/** - * Send the chat buffer - */ -Channel.prototype.sendRecentChat = function (users) { - var self = this; - users.forEach(function (u) { - for (var i = 0; i < self.chatbuffer.length; i++) { - u.socket.emit("chatMsg", self.chatbuffer[i]); - } - }); -}; - -/** - * Sends a user profile - */ -Channel.prototype.sendUserProfile = function (users, user) { - var packet = { - name: user.name, - profile: user.profile - }; - - users.forEach(function (u) { - u.socket.emit("setUserProfile", packet); - }); -}; - -/** - * Packs userdata for addUser or userlist - */ -Channel.prototype.packUserData = function (user) { - var base = { - name: user.name, - rank: user.rank, - profile: user.profile, - meta: { - afk: user.meta.afk, - muted: user.meta.muted && !user.meta.smuted - } - }; - - var mod = { - name: user.name, - rank: user.rank, - profile: user.profile, - meta: { - afk: user.meta.afk, - muted: user.meta.muted, - smuted: user.meta.smuted, - aliases: user.meta.aliases, - ip: util.maskIP(user.ip) - } - }; - - var sadmin = { - name: user.name, - rank: user.rank, - profile: user.profile, - meta: { - afk: user.meta.afk, - muted: user.meta.muted, - smuted: user.meta.smuted, - aliases: user.meta.aliases, - ip: user.ip - } - }; - - return { - base: base, - mod: mod, - sadmin: sadmin - }; -}; - -/** - * Sends a user.meta update, optionally filtering by minimum rank - */ -Channel.prototype.sendUserMeta = function (users, user, minrank) { - var self = this; - var userdata = self.packUserData(user); - self.users.filter(function (u) { - return typeof minrank !== "number" || u.rank > minrank - }).forEach(function (u) { - if (u.rank >= 255) { - u.socket.emit("setUserMeta", { - name: user.name, - meta: userdata.sadmin.meta - }); - } else if (u.rank >= 2) { - u.socket.emit("setUserMeta", { - name: user.name, - meta: userdata.mod.meta - }); - } else { - u.socket.emit("setUserMeta", { - name: user.name, - meta: userdata.base.meta - }); - } - }); -}; - -/** - * Send a user join notification - */ -Channel.prototype.sendUserJoin = function (users, user) { - var self = this; - db.getAliases(user.ip, function (err, aliases) { - if (self.dead) { - return; - } - - if (err || aliases.length === 0) { - aliases = [user.name]; - } - - user.meta.aliases = aliases; - - if (self.isShadowMuted(user.name)) { - user.meta.muted = true; - user.meta.shadowmuted = true; - } else if (self.isMuted(user.name)) { - user.meta.muted = true; - user.meta.shadowmuted = false; - } - - var data = self.packUserData(user); - - users.forEach(function (u) { - if (u.global_rank >= 255) { - u.socket.emit("addUser", data.sadmin); - } else if (u.rank >= 2) { - u.socket.emit("addUser", data.mod); - } else { - u.socket.emit("addUser", data.base); - } - }); - - self.sendModMessage(user.name + " joined (aliases: " + aliases.join(",") + ")", 2); - }); -}; - -/** - * Sends a notification that a user left - */ -Channel.prototype.sendUserLeave = function (users, user) { - var data = { - name: user.name - }; - - users.forEach(function (u) { - u.socket.emit("userLeave", data); - }); -}; - -/** - * Sends the current poll - */ -Channel.prototype.sendPoll = function (users) { - var self = this; - if (!self.poll) { - return; - } - - var obscured = self.poll.packUpdate(false); - var unobscured = self.poll.packUpdate(true); - - users.forEach(function (u) { - if (self.hasPermission(u, "viewhiddenpoll")) { - u.socket.emit("newPoll", unobscured); - } else { - u.socket.emit("newPoll", obscured); - } - }); -}; - -/** - * Sends a poll notification - */ -Channel.prototype.sendPollUpdate = function (users) { - var self = this; - var unhidden = self.poll.packUpdate(true); - var hidden = self.poll.packUpdate(false); - - users.forEach(function (u) { - if (self.hasPermission(u, "viewhiddenpoll")) { - u.socket.emit("updatePoll", unhidden); - } else { - u.socket.emit("updatePoll", hidden); - } - }); -}; - -/** - * Sends a "poll closed" notification - */ -Channel.prototype.sendPollClose = function (users) { - users.forEach(function (u) { - u.socket.emit("closePoll"); - }); -}; - -/** - * Broadcasts the channel options - */ -Channel.prototype.sendOpts = function (users) { - var opts = this.opts; - users.forEach(function (u) { - u.socket.emit("channelOpts", opts); - }); -}; - -/** - * Calculates the number of eligible users to voteskip - */ -Channel.prototype.calcVoteskipMax = function () { - var self = this; - return self.users.map(function (u) { - if (!self.hasPermission(u, "voteskip")) { - return 0; - } - - return u.meta.afk ? 0 : 1; - }).reduce(function (a, b) { - return a + b; - }, 0); -}; - -/** - * Creates a voteskip update packet - */ -Channel.prototype.getVoteskipPacket = function () { - var have = this.voteskip ? this.voteskip.counts[0] : 0; - var max = this.calcVoteskipMax(); - var need = this.voteskip ? Math.ceil(max * this.opts.voteskip_ratio) : 0; - return { - count: have, - need: need - }; -}; - -/** - * Sends a voteskip update packet - */ -Channel.prototype.sendVoteskipUpdate = function (users) { - var update = this.getVoteskipPacket(); - users.forEach(function (u) { - if (u.rank >= 1.5) { - u.socket.emit("voteskip", update); - } - }); -}; - -/** - * Sends the inline CSS and JS - */ -Channel.prototype.sendCSSJS = function (users) { - var data = { - css: this.css, - js: this.js - }; - - users.forEach(function (u) { - u.socket.emit("channelCSSJS", data); - }); -}; - -/** - * Sends the MOTD - */ -Channel.prototype.sendMotd = function (users) { - var motd = this.motd; - users.forEach(function (u) { - u.socket.emit("setMotd", motd); - }); -}; - -/** - * Sends the drink count - */ -Channel.prototype.sendDrinks = function (users) { - var drinks = this.drinks; - users.forEach(function (u) { - u.socket.emit("drinkCount", drinks); - }); -}; - -/** - * Resets video-related variables - */ -Channel.prototype.resetVideo = function () { - this.voteskip = false; - this.sendVoteskipUpdate(this.users); - this.drinks = 0; - this.sendDrinks(this.users); -}; - -/** - * Handles a queue message from a client - */ -Channel.prototype.handleQueue = function (user, data) { - // Verify the user has permission to add - if (!this.hasPermission(user, "playlistadd")) { - return; - } - - // Verify data types - if (typeof data.id !== "string" && data.id !== false) { - return; - } - var id = data.id || false; - - if (typeof data.type !== "string") { - return; - } - var type = data.type; - var link = util.formatLink(id, type); - - /* Kick for this because there's no legitimate way to do this with the - UI. Can only be accomplished by manually sending a packet and people - abuse it to bypass the addnext permission - */ - if (data.pos !== "next" && data.pos !== "end") { - user.kick("Illegal queue packet: pos must be 'next' or 'end'"); - return; - } - - // Verify user has the permission to add at the position given - if (data.pos === "next" && !this.hasPermission(user, "playlistnext")) { - return; - } - var pos = data.pos; - - // Verify user has permission to add a YouTube playlist, if relevant - if (data.type === "yp" && !this.hasPermission(user, "playlistaddlist")) { - user.socket.emit("queueFail", { - msg: "You don't have permission to add playlists", - link: link - }); - return; - } - - // Verify the user has permission to add livestreams, if relevant - if (util.isLive(type) && !this.hasPermission(user, "playlistaddlive")) { - user.socket.emit("queueFail", { - msg: "You don't have permission to add livestreams", - link: link - }); - return; - } - - // Verify the user has permission to add a Custom Embed, if relevant - if (data.type === "cu" && !this.hasPermission(user, "playlistaddcustom")) { - user.socket.emit("queueFail", { - msg: "You don't have permission to add custom embeds", - link: null - }); - return; - } - - /** - * Always reset any user-provided title if it's not a custom embed. - * Additionally reset if it is a custom embed but a title is not provided - */ - if (typeof data.title !== "string" || data.type !== "cu") { - data.title = false; - } - var title = data.title || false; - - var queueby = user != null ? user.name : ""; - var temp = data.temp || !this.hasPermission(user, "addnontemp"); - - // Allow override of duration for live content - var duration = undefined; - if (util.isLive(data.type) && typeof data.duration === "number") { - duration = !isNaN(data.duration) ? data.duration : undefined; - } - - // Throttle video adds - var limit = { - burst: 3, - sustained: 1 - }; - - if (user.rank >= 2 || this.leader === user) { - limit = { - burst: 10, - sustained: 2 - }; - } - - if (user.queueLimiter.throttle(limit)) { - user.socket.emit("queueFail", { - msg: "You are adding videos too quickly", - link: null - }); - return; - } - - // Actually add the video - this.addMedia({ - id: id, - title: title, - pos: pos, - queueby: queueby, - temp: temp, - type: type, - duration: duration, - maxlength: this.hasPermission(user, "exceedmaxlength") ? 0 : this.opts.maxlength - }, function (err, media) { - if (err) { - user.socket.emit("queueFail", { - msg: err, - link: link - }); - return; - } - - if (media.restricted) { - user.socket.emit("queueWarn", { - msg: "This video is blocked in the following countries: " + - media.restricted, - link: link - }); - return; - } - }); -}; - -/** - * Add a video to the playlist - */ -Channel.prototype.addMedia = function (data, callback) { - var self = this; - - if (data.type === "cu" && typeof data.title === "string") { - var t = data.title; - if (t.length > 100) { - t = t.substring(0, 97) + "..."; - } - data.title = t; - } - - if (data.pos === "end") { - data.pos = "append"; - } - - var afterLookup = function (lock, shouldCache, media) { - if (data.maxlength && media.seconds > data.maxlength) { - callback("Maximum length exceeded: " + data.maxlength + " seconds", null); - lock.release(); - return; - } - - media.pos = data.pos; - media.queueby = data.queueby; - media.temp = data.temp; - if (data.title && media.type === "cu") { - media.title = data.title; - } - - var res = self.playlist.addMedia(media); - if (res.error) { - callback(res.error, null); - lock.release(); - return; - } - - self.logger.log("[playlist] " + data.queueby + " queued " + media.title + " (" + - media.type + ":" + media.id + ")"); - - var item = res.item; - var packet = { - item: item.pack(), - after: item.prev ? item.prev.uid : "prepend" - }; - self.users.forEach(function (u) { - u.socket.emit("queue", packet); - }); - - self.updatePlaylistMeta(); - self.sendPlaylistMeta(self.users); - - if (shouldCache) { - self.cacheMedia(media); - } - - lock.release(); - callback(null, media); - }; - - // Cached video data - if (data.type !== "cu" && typeof data.title === "string") { - self.plqueue.queue(function (lock) { - var m = new Media(data.id, data.title, data.seconds, data.type); - afterLookup(lock, false, m); - }); - return; - } - - // YouTube playlists - if (data.type === "yp") { - self.plqueue.queue(function (lock) { - InfoGetter.getMedia(data.id, data.type, function (e, vids) { - if (e) { - callback(e, null); - lock.release(); - return; - } - - // If queueing next, reverse queue order so the videos end up - // in the correct order - if (data.pos === "next") { - vids.reverse(); - // Special case to ensure correct playlist order - if (self.playlist.length === 0) { - vids.unshift(vids.pop()); - } - } - - // We only want to release the lock after the entire playlist - // is processed. Set up a dummy so the same code will work. - var dummy = { - release: function () { } - }; - - for (var i = 0; i < vids.length; i++) { - afterLookup(dummy, true, vids[i]); - } - - lock.release(); - }); - }); - return; - } - - // Cases where there is no cached data in the database - if (!self.registered || util.isLive(data.type)) { - self.plqueue.queue(function (lock) { - InfoGetter.getMedia(data.id, data.type, function (e, media) { - if (e) { - callback(e, null); - lock.release(); - return; - } - - if (data.duration) { - media.seconds = data.duration; - } - afterLookup(lock, false, media); - }); - }); - return; - } - - // Finally, the "normal" case - self.plqueue.queue(function (lock) { - if (self.dead) { - return; - } - - var lookupNewMedia = function () { - InfoGetter.getMedia(data.id, data.type, function (e, media) { - if (self.dead) { - return; - } - - if (e) { - callback(e, null); - lock.release(); - return; - } - - afterLookup(lock, true, media); - }); - }; - - db.channels.getLibraryItem(self.name, data.id, function (err, item) { - if (self.dead) { - return; - } - - if (err && err !== "Item not in library") { - callback(err, null); - lock.release(); - return; - } - - if (item !== null) { - afterLookup(lock, true, item); - } else { - lookupNewMedia(); - } - }); - }); -}; - -/** - * Handles a user queueing a user playlist - */ -Channel.prototype.handleQueuePlaylist = function (user, data) { - var self = this; - if (!self.hasPermission(user, "playlistaddlist")) { - return; - } - - if (typeof data.name !== "string") { - return; - } - var name = data.name; - - /* Kick for this because there's no legitimate way to do this with the - UI. Can only be accomplished by manually sending a packet and people - abuse it to bypass the addnext permission - */ - if (data.pos !== "next" && data.pos !== "end") { - user.kick("Illegal queue packet: pos must be 'next' or 'end'"); - return; - } - - if (data.pos === "next" && !self.hasPermission(user, "playlistnext")) { - return; - } - var pos = data.pos; - - var temp = data.temp || !self.hasPermission(user, "addnontemp"); - - db.getUserPlaylist(user.name, name, function (err, pl) { - if (self.dead) { - return; - } - - if (err) { - user.socket.emit("errorMsg", { - msg: "Playlist load failed: " + err - }); - return; - } - - try { - // Ensure correct order when queueing next - if (pos === "next") { - pl.reverse(); - if (pl.length > 0 && self.playlist.items.length === 0) { - pl.unshift(pl.pop()); - } - } - - pl.forEach(function (pli) { - pli.pos = pos; - pli.temp = temp; - pli.queueby = user.name; - self.addMedia(pli, function (err, media) { - if (err) { - user.socket.emit("queueFail", { - msg: err, - link: util.formatLink(pli.id, pli.type) - }); - } - }); - }); - } catch (e) { - Logger.errlog.log("Loading user playlist failed!"); - Logger.errlog.log("PL: " + user.name + "-" + name); - Logger.errlog.log(e.stack); - user.socket.emit("queueFail", { - msg: "Internal error occurred when loading playlist. The administrator has been notified.", - link: null - }); - } - }); -}; - -/** - * Handles a user message to delete a playlist item - */ -Channel.prototype.handleDelete = function (user, data) { - var self = this; - - if (!self.hasPermission(user, "playlistdelete")) { - return; - } - - if (typeof data !== "number") { - return; - } - - var plitem = self.playlist.items.find(data); - - self.deleteMedia(data, function (err) { - if (!err && plitem && plitem.media) { - self.logger.log("[playlist] " + user.name + " deleted " + plitem.media.title); - } - }); -}; - -/** - * Deletes a playlist item - */ -Channel.prototype.deleteMedia = function (uid, callback) { - var self = this; - self.plqueue.queue(function (lock) { - if (self.dead) { - return; - } - - if (self.playlist.remove(uid)) { - self.sendAll("delete", { - uid: uid - }); - self.updatePlaylistMeta(); - self.sendPlaylistMeta(self.users); - callback(null); - } else { - callback("Delete failed"); - } - - lock.release(); - }); -}; - -/** - * Sets the temporary status of a playlist item - */ -Channel.prototype.setTemp = function (uid, temp) { - var item = this.playlist.items.find(uid); - if (item === false) { - return; - } - - item.temp = temp; - this.sendAll("setTemp", { - uid: uid, - temp: temp - }); - - // TODO might change the way this works - if (!temp) { - this.cacheMedia(item.media); - } -}; - -/** - * Handles a user message to set a playlist item as temporary/not - */ -Channel.prototype.handleSetTemp = function (user, data) { - if (!this.hasPermission(user, "settemp")) { - return; - } - - if (typeof data.uid !== "number" || typeof data.temp !== "boolean") { - return; - } - - this.setTemp(data.uid, data.temp); - // TODO log? -}; - -/** - * Moves a playlist item in the playlist - */ -Channel.prototype.move = function (from, after, callback) { - callback = typeof callback === "function" ? callback : function () { }; - var self = this; - - if (from === after) { - callback("Cannot move playlist item after itself!", null); - return; - } - - self.plqueue.queue(function (lock) { - if (self.dead) { - return; - } - - if (self.playlist.move(from, after)) { - self.sendAll("moveVideo", { - from: from, - after: after - }); - callback(null, true); - } else { - callback(true, null); - } - - lock.release(); - }); -}; - -/** - * Handles a user message to move a playlist item - */ -Channel.prototype.handleMove = function (user, data) { - var self = this; - - if (!self.hasPermission(user, "playlistmove")) { - return; - } - - if (typeof data.from !== "number" || (typeof data.after !== "number" && typeof data.after !== "string")) { - return; - } - - self.move(data.from, data.after, function (err) { - if (!err) { - var fromit = self.playlist.items.find(data.from); - var afterit = self.playlist.items.find(data.after); - var aftertitle = (afterit && afterit.media) ? afterit.media.title : ""; - if (fromit) { - self.logger.log("[playlist] " + user.name + " moved " + fromit.media.title + - (aftertitle ? " after " + aftertitle : "")); - } - } - }); -}; - -/** - * Handles a user message to remove a video from the library - */ -Channel.prototype.handleUncache = function (user, data) { - var self = this; - if (!self.registered) { - return; - } - - if (user.rank < 2) { - return; - } - - if (typeof data.id !== "string") { - return; - } - - db.channels.deleteFromLibrary(self.name, data.id, function (err, res) { - if (self.dead) { - return; - } - - if (err) { - return; - } - - self.logger.log("[library] " + user.name + " deleted " + data.id + " from library"); - }); -}; - -/** - * Handles a user message to skip to the next video in the playlist - */ -Channel.prototype.handlePlayNext = function (user) { - if (!this.hasPermission(user, "playlistjump")) { - return; - } - - var title = ""; - if (this.playlist.current && this.playlist.current.title) { - title = " " + this.playlist.current.title; - } - this.logger.log("[playlist] " + user.name + " skipped" + title); - this.playlist.next(); -}; - -/** - * Handles a user message to jump to a video in the playlist - */ -Channel.prototype.handleJumpTo = function (user, data) { - if (!this.hasPermission(user, "playlistjump")) { - return; - } - - if (typeof data !== "string" && typeof data !== "number") { - return; - } - - var to = this.playlist.items.find(data); - var title = ""; - if (to !== false) { - title = " to " + to.media.title; - this.logger.log("[playlist] " + user.name + " skipped" + title); - this.playlist.jump(data); - } -}; - -/** - * Clears the playlist - */ -Channel.prototype.clear = function () { - this.playlist.clear(); - this.plqueue.reset(); - this.updatePlaylistMeta(); - this.sendPlaylist(this.users); -}; - -/** - * Handles a user message to clear the playlist - */ -Channel.prototype.handleClear = function (user) { - if (!this.hasPermission(user, "playlistclear")) { - return; - } - - this.logger.log("[playlist] " + user.name + " cleared the playlist"); - this.clear(); -}; - -/** - * Shuffles the playlist - */ -Channel.prototype.shuffle = function () { - var pl = this.playlist.items.toArray(false); - this.playlist.clear(); - this.plqueue.reset(); - while (pl.length > 0) { - var i = Math.floor(Math.random() * pl.length); - var item = this.playlist.makeItem(pl[i].media); - item.temp = pl[i].temp; - item.queueby = pl[i].queueby; - this.playlist.items.append(item); - pl.splice(i, 1); - } - - this.playlist.current = this.playlist.items.first; - this.sendPlaylist(this.users); - this.playlist.startPlayback(); -}; - -/** - * Handles a user message to shuffle the playlist - */ -Channel.prototype.handleShuffle = function (user) { - if (!this.hasPermission(user, "playlistshuffle")) { - return; - } - - this.logger.log("[playlist] " + user.name + " shuffle the playlist"); - this.shuffle(); -}; - -/** - * Handles a video update from a leader - */ -Channel.prototype.handleUpdate = function (user, data) { - if (this.leader !== user) { - return; - } - - if (typeof data.id !== "string" || typeof data.currentTime !== "number") { - return; - } - - if (this.playlist.current === null) { - return; - } - - var media = this.playlist.current.media; - - if (util.isLive(media.type) && media.type !== "jw") { - return; - } - - if (media.id !== data.id || isNaN(data.currentTime)) { - return; - } - - media.currentTime = data.currentTime; - media.paused = Boolean(data.paused); - this.sendAll("mediaUpdate", media.timeupdate()); -}; - -/** - * Handles a user message to open a poll - */ -Channel.prototype.handleOpenPoll = function (user, data) { - if (!this.hasPermission(user, "pollctl")) { - return; - } - - if (typeof data.title !== "string" || !(data.opts instanceof Array)) { - return; - } - var title = data.title.substring(0, 255); - var opts = []; - - for (var i = 0; i < data.opts.length; i++) { - opts[i] = (""+data.opts[i]).substring(0, 255); - } - - var obscured = (data.obscured === true); - var poll = new Poll(user.name, title, opts, obscured); - var self = this; - if (typeof data.timeout === "number" && !isNaN(data.timeout) && data.timeout > 0) { - poll.timer = setTimeout(function () { - if (self.poll === poll) { - self.handleClosePoll({ name: "[poll timer]", rank: 255 }); - } - }, data.timeout * 1000); - } - this.poll = poll; - this.sendPoll(this.users, true); - this.logger.log("[poll] " + user.name + " Opened Poll: '" + poll.title + "'"); -}; - -/** - * Handles a user message to close the active poll - */ -Channel.prototype.handleClosePoll = function (user) { - if (!this.hasPermission(user, "pollctl")) { - return; - } - - if (this.poll) { - if (this.poll.obscured) { - this.poll.obscured = false; - this.sendPollUpdate(this.users); - } - - if (this.poll.timer) { - clearTimeout(this.poll.timer); - } - - this.logger.log("[poll] " + user.name + " closed the active poll"); - this.poll = false; - this.sendAll("closePoll"); - } -}; - -/** - * Handles a user message to vote in a poll - */ -Channel.prototype.handlePollVote = function (user, data) { - if (!this.hasPermission(user, "pollvote")) { - return; - } - - if (typeof data.option !== "number") { - return; - } - - if (this.poll) { - this.poll.vote(user.ip, data.option); - this.sendPollUpdate(this.users); - } -}; - -/** - * Handles a user message to voteskip the current video - */ -Channel.prototype.handleVoteskip = function (user) { - if (!this.opts.allow_voteskip) { - return; - } - - if (!this.hasPermission(user, "voteskip")) { - return; - } - - user.setAFK(false); - user.autoAFK(); - if (!this.voteskip) { - this.voteskip = new Poll("voteskip", "voteskip", ["yes"]); - } - this.voteskip.vote(user.ip, 0); - - var title = ""; - if (this.playlist.current && this.playlist.current.title) { - title = " " + this.playlist.current.title; - } - - this.logger.log("[playlist] " + (user.name ? user.name : "anonymous") + - " voteskipped" + title); - this.checkVoteskipPass(); -}; - -/** - * Checks if the voteskip requirement is met - */ -Channel.prototype.checkVoteskipPass = function () { - if (!this.opts.allow_voteskip) { - return false; - } - - if (!this.voteskip) { - return false; - } - - if (this.playlist.length === 0) { - return false; - } - - var max = this.calcVoteskipMax(); - var need = Math.ceil(max * this.opts.voteskip_ratio); - if (this.voteskip.counts[0] >= need) { - var title = ""; - if (this.playlist.current && this.playlist.current.title) { - title = " " + this.playlist.current.title; - } - - this.logger.log("[playlist] Voteskip passed, skipping" + title); - this.playlist.next(); - } - - this.sendVoteskipUpdate(this.users); - return true; -}; - -/** - * Sets the locked state of the playlist - */ -Channel.prototype.setLock = function (locked) { - this.playlistLock = locked; - this.sendPlaylistLock(this.users); -}; - -/** - * Handles a user message to change the locked state of the playlist - */ -Channel.prototype.handleSetLock = function (user, data) { - if (!this.hasPermission(user, "playlistlock")) { - return; - } - - - data.locked = Boolean(data.locked); - this.logger.log("[playlist] " + user.name + " set playlist lock to " + data.locked); - this.setLock(data.locked); -}; - -/** - * Handles a user message to toggle the locked state of the playlist - */ -Channel.prototype.handleToggleLock = function (user) { - this.handleSetLock(user, { locked: !this.playlistLock }); -}; - -/** - * Imports a list of chat filters, replacing the current list - */ -Channel.prototype.importFilters = function (filters) { - this.filters = filters; - this.sendChatFilters(this.users); -}; - -/** - * Handles a user message to import a list of chat filters - */ -Channel.prototype.handleImportFilters = function (user, data) { - if (!this.hasPermission(user, "filterimport")) { - return; - } - - if (!(data instanceof Array)) { - return; - } - - this.filters = data.map(this.validateChatFilter.bind(this)) - .filter(function (f) { return f !== false; }); - - this.sendChatFilters(this.users); -}; - -/** - * Validates data for a chat filter - */ -Channel.prototype.validateChatFilter = function (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 Filter(f.name, f.source, f.flags, f.replace); - filter.active = Boolean(f.active); - filter.filterlinks = Boolean(f.filterlinks); - return filter; -}; - -/** - * Updates a chat filter, or adds a new one if the filter does not exist - */ -Channel.prototype.updateFilter = function (filter) { - var self = this; - - if (!filter.name) { - filter.name = filter.source; - } - - var found = false; - for (var i = 0; i < self.filters.length; i++) { - if (self.filters[i].name === filter.name) { - found = true; - self.filters[i] = filter; - break; - } - } - - if (!found) { - self.filters.push(filter); - } - - self.users.forEach(function (u) { - if (self.hasPermission(u, "filteredit")) { - u.socket.emit("updateChatFilter", filter); - } - }); -}; - -/** - * Handles a user message to update a filter - */ -Channel.prototype.handleUpdateFilter = function (user, f) { - if (!this.hasPermission(user, "filteredit")) { - user.kick("Attempted updateFilter with insufficient permission"); - return; - } - - filter = this.validateChatFilter(f); - if (!filter) { - return; - } - - this.logger.log("[mod] " + user.name + " updated filter: " + f.name + " -> " + - "s/" + f.source + "/" + f.replace + "/" + f.flags + " active: " + - f.active); - this.updateFilter(filter); -}; - -/** - * Removes a chat filter - */ -Channel.prototype.removeFilter = function (filter) { - var self = this; - - for (var i = 0; i < self.filters.length; i++) { - if (self.filters[i].name === filter.name) { - self.filters.splice(i, 1); - self.users.forEach(function (u) { - if (self.hasPermission(u, "filteredit")) { - u.socket.emit("deleteChatFilter", filter); - } - }); - break; - } - } -}; - -/** - * Handles a user message to delete a chat filter - */ -Channel.prototype.handleRemoveFilter = function (user, f) { - if (!this.hasPermission(user, "filteredit")) { - user.kick("Attempted removeFilter with insufficient permission"); - return; - } - - if (typeof f.name !== "string") { - return; - } - - this.logger.log("[mod] " + user.name + " removed filter: " + f.name); - this.removeFilter(f); -}; - -/** - * Changes the order of chat filters - */ -Channel.prototype.moveFilter = function (from, to) { - if (from < 0 || to < 0 || from >= this.filters.length || to >= this.filters.length) { - return; - } - - var f = this.filters[from]; - to = to > from ? to + 1 : to; - from = to > from ? from : from + 1; - - this.filters.splice(to, 0, f); - this.filters.splice(from, 1); - // TODO broadcast -}; - -/** - * Handles a user message to change the chat filter order - */ -Channel.prototype.handleMoveFilter = function (user, data) { - if (!this.hasPermission(user, "filteredit")) { - user.kick("Attempted moveFilter with insufficient permission"); - return; - } - - if (typeof data.to !== "number" || typeof data.from !== "number") { - return; - } - - this.moveFilter(data.from, data.to); -}; - -/** - * Imports a list of emotes, replacing the current list - */ -Channel.prototype.importEmotes = function (emotes) { - this.emotes = emotes; - this.sendEmoteList(this.users); -}; - -/** - * Handles a user message to import a list of emotes - */ -Channel.prototype.handleImportEmotes = function (user, data) { - if (!this.hasPermission(user, "emoteimport")) { - return; - } - - if (!(data instanceof Array)) { - return; - } - - this.emotes = data.map(this.validateEmote.bind(this)) - .filter(function (f) { return f !== false; }); - - this.sendEmoteList(this.users); -}; - -/** - * Validates data for an emote - */ -Channel.prototype.validateEmote = function (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; -}; - -/** - * Updates an emote, or adds a new one if the emote does not exist - */ -Channel.prototype.updateEmote = function (emote) { - var self = this; - - emote = this.validateEmote(emote); - if (!emote) { - return; - } - - var found = false; - for (var i = 0; i < self.emotes.length; i++) { - if (self.emotes[i].name === emote.name) { - found = true; - self.emotes[i] = emote; - break; - } - } - - if (!found) { - self.emotes.push(emote); - } - - self.users.forEach(function (u) { - u.socket.emit("updateEmote", emote); - }); -}; - -/** - * Handles a user message to update an emote - */ -Channel.prototype.handleUpdateEmote = function (user, f) { - if (!this.hasPermission(user, "emoteedit")) { - user.kick("Attempted updateEmote with insufficient permission"); - return; - } - - var emote = this.validateEmote(f); - if (!emote) { - return; - } - - this.logger.log("[mod] " + user.name + " updated emote: " + f.name + " -> " + - f.image); - this.updateEmote(emote); -}; - -/** - * Removes an emote - */ -Channel.prototype.removeEmote = function (emote) { - var self = this; - - for (var i = 0; i < self.emotes.length; i++) { - if (self.emotes[i].name === emote.name) { - self.emotes.splice(i, 1); - self.users.forEach(function (u) { - u.socket.emit("removeEmote", emote); - }); - break; - } - } -}; - -/** - * Handles a user message to delete an emote - */ -Channel.prototype.handleRemoveEmote = function (user, f) { - if (!this.hasPermission(user, "emoteedit")) { - user.kick("Attempted removeEmote with insufficient permission"); - return; - } - - if (typeof f.name !== "string") { - return; - } - - this.logger.log("[mod] " + user.name + " removed emote: " + f.name); - this.removeEmote(f); -}; - - -/** - * Handles a user message to change the channel permissions - */ -Channel.prototype.handleSetPermissions = function (user, perms) { - if (user.rank < 3) { - user.kick("Attempted setPermissions as a non-admin"); - return; - } - - for (var key in perms) { - if (key in this.permissions) { - this.permissions[key] = perms[key]; - } - } - - if ("seeplaylist" in perms) { - this.sendPlaylist(this.users); - } - - this.logger.log("[mod] " + user.name + " updated permissions"); - this.sendAll("setPermissions", this.permissions); -}; - -/** - * Handles a user message to change the channel settings - */ -Channel.prototype.handleUpdateOptions = function (user, data) { - if (user.rank < 2) { - user.kick("Attempted setOptions as a non-moderator"); - return; - } - - if ("allow_voteskip" in data) { - this.opts.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.users.forEach(function (u) { - u.autoAFK(); - }); - } - } - - if ("pagetitle" in data && user.rank >= 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 = util.parseTime(data.maxlength); - if (isNaN(ml) || ml < 0) { - ml = 0; - } - this.opts.maxlength = ml; - } - - if ("externalcss" in data && user.rank >= 3) { - this.opts.externalcss = (""+data.externalcss).substring(0, 255); - } - - if ("externaljs" in data && user.rank >= 3) { - this.opts.externaljs = (""+data.externaljs).substring(0, 255); - } - - if ("chat_antiflood" in data) { - this.opts.chat_antiflood = Boolean(data.chat_antiflood); - } - - if ("chat_antiflood_params" in data) { - if (typeof data.chat_antiflood_params !== "object") { - data.chat_antiflood_params = { - burst: 4, - sustained: 1 - }; - } - - var b = parseInt(data.chat_antiflood_params.burst); - if (isNaN(b) || b < 0) { - b = 1; - } - - var s = parseFloat(data.chat_antiflood_params.sustained); - if (isNaN(s) || s <= 0) { - s = 1; - } - - var c = b / s; - this.opts.chat_antiflood_params = { - burst: b, - sustained: s, - cooldown: c - }; - } - - if ("show_public" in data && user.rank >= 3) { - this.opts.show_public = Boolean(data.show_public); - } - - if ("enable_link_regex" in data) { - this.opts.enable_link_regex = Boolean(data.enable_link_regex); - } - - if ("password" in data && user.rank >= 3) { - var pw = data.password + ""; - pw = pw === "" ? false : pw.substring(0, 100); - this.opts.password = pw; - } - - this.logger.log("[mod] " + user.name + " updated channel options"); - this.sendOpts(this.users); -}; - -/** - * Handles a user message to set the inline channel CSS - */ -Channel.prototype.handleSetCSS = function (user, data) { - if (user.rank < 3) { - user.kick("Attempted setChannelCSS as non-admin"); - return; - } - - if (typeof data.css !== "string") { - return; - } - var css = data.css.substring(0, 20000); - - this.css = css; - this.sendCSSJS(this.users); - - this.logger.log("[mod] " + user.name + " updated the channel CSS"); -}; - -/** - * Handles a user message to set the inline channel CSS - */ -Channel.prototype.handleSetJS = function (user, data) { - if (user.rank < 3) { - user.kick("Attempted setChannelJS as non-admin"); - return; - } - - if (typeof data.js !== "string") { - return; - } - var js = data.js.substring(0, 20000); - - this.js = js; - this.sendCSSJS(this.users); - - this.logger.log("[mod] " + user.name + " updated the channel JS"); -}; - -/** - * Sets the MOTD - */ -Channel.prototype.setMotd = function (motd) { - motd = XSS.sanitizeHTML(motd); - var html = motd.replace(/\n/g, "
"); - this.motd = { - motd: motd, - html: html - }; - this.sendMotd(this.users); -}; - -/** - * Handles a user message to update the MOTD - */ -Channel.prototype.handleSetMotd = function (user, data) { - if (!this.hasPermission(user, "motdedit")) { - user.kick("Attempted setMotd with insufficient permission"); - return; - } - - if (typeof data.motd !== "string") { - return; - } - var motd = data.motd.substring(0, 20000); - - this.setMotd(motd); - this.logger.log("[mod] " + user.name + " updated the MOTD"); -}; - -/** - * Handles a user chat message - */ -Channel.prototype.handleChat = function (user, data) { - if (!this.hasPermission(user, "chat")) { - return; - } - - if (typeof data.meta !== "object") { - data.meta = {}; - } - - if (!user.name) { - return; - } - - if (typeof data.msg !== "string") { - return; - } - var msg = data.msg.substring(0, 240); - - var muted = this.isMuted(user.name); - var smuted = this.isShadowMuted(user.name); - - var meta = {}; - if (user.rank >= 2) { - if ("modflair" in data.meta && data.meta.modflair === user.rank) { - meta.modflair = data.meta.modflair; - } - } - - if (user.rank < 2 && this.opts.chat_antiflood && - user.chatLimiter.throttle(this.opts.chat_antiflood_params)) { - user.socket.emit("chatCooldown", 1000 / this.opts.chat_antiflood_params.sustained); - return; - } - - if (smuted) { - msg = XSS.sanitizeText(msg); - msg = this.filterMessage(msg); - var msgobj = { - username: user.name, - msg: msg, - meta: meta, - time: Date.now() - }; - this.shadowMutedUsers().forEach(function (u) { - u.socket.emit("chatMsg", msgobj); - }); - - msgobj.meta.shadow = true; - this.channelModerators().forEach(function (u) { - u.socket.emit("chatMsg", msgobj); - }); - return; - } else if (muted) { - user.socket.emit("noflood", { - action: "chat", - msg: "You have been muted on this channel." - }); - return; - } - - if (msg.indexOf("/") === 0) { - if (!ChatCommand.handle(this, user, msg, meta)) { - this.sendMessage(user, msg, meta); - } - } else { - if (msg.indexOf(">") === 0) { - meta.addClass = "greentext"; - } - this.sendMessage(user, msg, meta); - } -}; - -Channel.prototype.handlePm = function (user, data) { - if (typeof data.meta !== "object") { - data.meta = {}; - } - - if (!user.name) { - return; - } - - if (typeof data.msg !== "string" || typeof data.to !== "string") { - return; - } - var reallyTo = data.to; - data.to = data.to.toLowerCase(); - - if (data.to === user.name) { - user.socket.emit("errorMsg", { - msg: "You can't PM yourself!" - }); - return; - } - - if (!util.isValidUserName(data.to)) { - user.socket.emit("errorMsg", { - msg: data.to + " isn't a valid username." - }); - return; - } - - var msg = data.msg.substring(0, 240); - var to = null; - for (var i = 0; i < this.users.length; i++) { - if (this.users[i].name.toLowerCase() === data.to) { - to = this.users[i]; - break; - } - } - - if (!to) { - user.socket.emit("errorMsg", { - msg: data.to + " is not on this channel." - }); - return; - } - - var 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"; - } - - msg = XSS.sanitizeText(msg); - msg = this.filterMessage(msg); - var msgobj = { - username: user.name, - to: reallyTo, - msg: msg, - meta: meta, - time: Date.now() - }; - - to.socket.emit("pm", msgobj); - user.socket.emit("pm", msgobj); -}; - -/** - * Filters a chat message - */ -Channel.prototype.filterMessage = function (msg) { - const link = /(\w+:\/\/(?:[^:\/\[\]\s]+|\[[0-9a-f:]+\])(?::\d+)?(?:\/[^\/\s]*)*)/ig; - var parts = msg.split(link); - - for (var j = 0; j < parts.length; j++) { - // Case 1: The substring is a URL - if (this.opts.enable_link_regex && parts[j].match(link)) { - var original = parts[j]; - // Apply chat filters that are active and filter links - for (var i = 0; i < this.filters.length; i++) { - if (!this.filters[i].filterlinks || !this.filters[i].active) { - continue; - } - parts[j] = this.filters[i].filter(parts[j]); - } - - // Unchanged, apply link filter - if (parts[j] === original) { - parts[j] = url.format(url.parse(parts[j])); - parts[j] = parts[j].replace(link, "$1"); - } - - continue; - } else { - // Substring is not a URL - for (var i = 0; i < this.filters.length; i++) { - if (!this.filters[i].active) { - continue; - } - - parts[j] = this.filters[i].filter(parts[j]); - } - } - } - - // Recombine the message - msg = parts.join(""); - return XSS.sanitizeHTML(msg); -}; - -/** - * Sends a chat message - */ -Channel.prototype.sendMessage = function (user, msg, meta) { - msg = XSS.sanitizeText(msg); - msg = this.filterMessage(msg); - var msgobj = { - username: user.name, - msg: msg, - meta: meta, - time: Date.now() - }; - - this.sendAll("chatMsg", msgobj); - this.chatbuffer.push(msgobj); - if (this.chatbuffer.length > 15) { - this.chatbuffer.shift(); - } - - this.logger.log("<" + user.name + (meta.addClass ? "." + meta.addClass : "") + "> " + - XSS.decodeText(msg)); -}; - -/** - * Handles a user message to change another user's rank - */ -Channel.prototype.handleSetRank = function (user, data) { - var self = this; - if (user.rank < 2) { - user.kick("Attempted setChannelRank as a non-moderator"); - return; - } - - if (typeof data.user !== "string" || typeof data.rank !== "number") { - return; - } - var name = data.user.substring(0, 20); - var rank = data.rank; - - if (isNaN(rank) || rank < 1 || (rank >= user.rank && !(user.rank === 4 && - rank === 4))) { - user.socket.emit("channelRankFail", { - msg: "Updating user rank failed: You can't promote someone to equal or " + - "higher rank than yourself, or demote them below rank 1." - }); - return; - } - - var receiver; - var lowerName = name.toLowerCase(); - for (var i = 0; i < self.users.length; i++) { - if (self.users[i].name.toLowerCase() === lowerName) { - receiver = self.users[i]; - break; - } - } - - var updateDB = function () { - self.getRank(name, function (err, oldrank) { - if (self.dead) { - return; - } - - if (err) { - user.socket.emit("channelRankFail", { - msg: "Updating user rank failed: " + err - }); - return; - } - - if (oldrank >= user.rank && !(oldrank === 4 && user.rank === 4)) { - user.socket.emit("channelRankFail", { - msg: "Updating user rank failed: " + name + " has equal or higher " + - "rank than you" - }); - return; - } - - db.channels.setRank(self.name, name, rank, function (err, res) { - if (self.dead) { - return; - } - - if (err) { - user.socket.emit("channelRankFail", { - msg: "Updating user rank failed: " + err - }); - return; - } - - self.logger.log("[mod] " + user.name + " set " + name + "'s rank to " + rank); - - if (receiver) { - receiver.rank = rank; - receiver.socket.emit("rank", rank); - } - - self.sendAll("setUserRank", { - name: name, - rank: rank - }); - }); - }); - }; - - if (receiver) { - var receiverrank = Math.max(receiver.rank, receiver.global_rank); - if (receiverrank > user.rank && !(receiverrank === 4 && user.rank === 4)) { - user.socket.emit("channelRankFail", { - msg: "Updating user rank failed: " + receiver.name + " has higher rank "+ - "than you." - }); - return; - } - - if (receiver.loggedIn) { - updateDB(); - } else { - self.logger.log("[mod] " + user.name + " set " + name + "'s rank to " + rank); - receiver.rank = rank; - receiver.socket.emit("rank", rank); - - self.sendAll("setUserRank", { - name: name, - rank: rank - }); - } - } else if (self.registered) { - updateDB(); - } -}; - -/** - * Assigns a leader for video playback - */ -Channel.prototype.changeLeader = function (name) { - if (this.leader != null) { - var old = this.leader; - this.leader = null; - if (old.rank === 1.5) { - old.rank = old.oldrank; - old.socket.emit("rank", old.rank); - this.sendAll("setUserRank", { - name: old.name, - rank: old.rank - }); - } - } - - if (!name) { - this.sendAll("setLeader", ""); - this.logger.log("[playlist] Resuming autolead"); - this.playlist.lead(true); - return; - } - - for (var i = 0; i < this.users.length; i++) { - if (this.users[i].name === name) { - this.sendAll("setLeader", name); - this.logger.log("[playlist] Assigned leader: " + name); - this.playlist.lead(false); - this.leader = this.users[i]; - if (this.users[i].rank < 1.5) { - this.users[i].oldrank = this.users[i].rank; - this.users[i].rank = 1.5; - this.users[i].socket.emit("rank", 1.5); - this.sendAll("setUserRank", { - name: name, - rank: this.users[i].rank - }); - } - break; - } - } -}; - -/** - * Handles a user message to assign a new leader - */ -Channel.prototype.handleChangeLeader = function (user, data) { - if (!this.hasPermission(user, "leaderctl")) { - user.kick("Attempted assignLeader with insufficient permission"); - return; - } - - if (typeof data.name !== "string") { - return; - } - - this.changeLeader(data.name); - this.logger.log("[mod] " + user.name + " assigned leader to " + data.name); -}; - -/** - * 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 - */ -Channel.prototype.handleReadLog = function (user) { - var self = this; - - if (user.rank < 3) { - user.kick("Attempted readChanLog with insufficient permission"); - return; - } - - if (!self.registered) { - user.socket.emit("readChanLog", { - success: false, - data: "Channel log is only available to registered channels." - }); - return; - } - - var filterIp = user.global_rank < 255; - self.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 - }); - } - }); -}; - -/** - * Reads the last 100KiB of the channel's log file, masking IP addresses if desired - */ -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); - }); - }); -}; - -/** - * Broadcasts a message to the entire channel - */ -Channel.prototype.sendAll = function (msg, data) { - this.users.forEach(function (u) { - u.socket.emit(msg, data); - }); -}; - -/** - * Loads a special set of permissions for unregistered channels - */ -Channel.prototype.setUnregisteredPermissions = 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.sendAll("setPermissions", this.permissions); - this.setLock(false); -} - -module.exports = Channel; diff --git a/lib/channel/accesscontrol.js b/lib/channel/accesscontrol.js new file mode 100644 index 00000000..8938db0c --- /dev/null +++ b/lib/channel/accesscontrol.js @@ -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; diff --git a/lib/channel/channel.js b/lib/channel/channel.js new file mode 100644 index 00000000..425983cc --- /dev/null +++ b/lib/channel/channel.js @@ -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; diff --git a/lib/channel/chat.js b/lib/channel/chat.js new file mode 100644 index 00000000..52eca4b8 --- /dev/null +++ b/lib/channel/chat.js @@ -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, "$1"); + } + + } 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; diff --git a/lib/channel/customization.js b/lib/channel/customization.js new file mode 100644 index 00000000..2a67125c --- /dev/null +++ b/lib/channel/customization.js @@ -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, "
"); + 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; diff --git a/lib/channel/emotes.js b/lib/channel/emotes.js new file mode 100644 index 00000000..6a870565 --- /dev/null +++ b/lib/channel/emotes.js @@ -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; diff --git a/lib/channel/filters.js b/lib/channel/filters.js new file mode 100644 index 00000000..38f15964 --- /dev/null +++ b/lib/channel/filters.js @@ -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", "$1"), + new ChatFilter("bold", "\\*(.+?)\\*", "g", "$1"), + new ChatFilter("italic", "_(.+?)_", "g", "$1"), + new ChatFilter("strike", "~~(.+?)~~", "g", "$1"), + new ChatFilter("inline spoiler", "\\[sp\\](.*?)\\[\\/sp\\]", "ig", "$1") +]; + +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; diff --git a/lib/channel/kickban.js b/lib/channel/kickban.js new file mode 100644 index 00000000..51047126 --- /dev/null +++ b/lib/channel/kickban.js @@ -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; diff --git a/lib/channel/library.js b/lib/channel/library.js new file mode 100644 index 00000000..e286e608 --- /dev/null +++ b/lib/channel/library.js @@ -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; diff --git a/lib/channel/module.js b/lib/channel/module.js new file mode 100644 index 00000000..0926e9e6 --- /dev/null +++ b/lib/channel/module.js @@ -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; diff --git a/lib/channel/opts.js b/lib/channel/opts.js new file mode 100644 index 00000000..6fe0e8e5 --- /dev/null +++ b/lib/channel/opts.js @@ -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; diff --git a/lib/channel/permissions.js b/lib/channel/permissions.js new file mode 100644 index 00000000..3820725f --- /dev/null +++ b/lib/channel/permissions.js @@ -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; diff --git a/lib/channel/playlist.js b/lib/channel/playlist.js new file mode 100644 index 00000000..e87f016e --- /dev/null +++ b/lib/channel/playlist.js @@ -0,0 +1,1220 @@ +var ChannelModule = require("./module"); var ULList = require("../ullist"); +var AsyncQueue = require("../asyncqueue"); +var Media = require("../media"); +var util = require("../utilities"); +var InfoGetter = require("../get-info"); +var vimeoWorkaround = InfoGetter.vimeoWorkaround; +var Config = require("../config"); +var Flags = require("../flags"); +var db = require("../database"); +var Logger = require("../logger"); + +const MAX_ITEMS = Config.get("playlist.max-items"); + +const TYPE_QUEUE = { + id: "string,boolean", + type: "string", + pos: "string", + title: "string,optional", + duration: "number,optional", + temp: "boolean,optional" +}; + +const TYPE_SET_TEMP = { + uid: "number", + temp: "boolean" +}; + +const TYPE_MOVE_MEDIA = { + from: "number", + after: "string,number" +}; + +const TYPE_ASSIGN_LEADER = { + name: "string" +}; + +const TYPE_MEDIA_UPDATE = { + id: "string", + currentTime: "number", + paused: "boolean,optional", + type: "string,optional" +}; + +const TYPE_CLONE_PLAYLIST = { + name: "string" +}; + +const TYPE_QUEUE_PLAYLIST = { + name: "string", + pos: "string", + temp: "boolean,optional" +}; + +function PlaylistItem(media, opts) { + if (typeof opts !== "object") { + opts = {}; + } + this.media = media; + this.uid = opts.uid; + this.temp = Boolean(opts.temp); + this.queueby = (typeof opts.queueby === "string") ? opts.queueby : ""; + this.next = null; + this.prev = null; +} + +PlaylistItem.prototype = { + pack: function () { + return { + media: this.media.pack(), + uid: this.uid, + temp: this.temp, + queueby: this.queueby + }; + } +}; + +function PlaylistModule(channel) { + ChannelModule.apply(this, arguments); + this.items = new ULList(); + this.meta = { + count: 0, + rawTime: 0, + time: util.formatTime(0) + }; + this.current = null; + this._nextuid = 0; + this.semaphore = new AsyncQueue(); + + this.leader = null; + this._leadInterval = false; + this._lastUpdate = 0; + this._counter = 0; + this._gdRefreshTimer = false; + + if (this.channel.modules.chat) { + this.channel.modules.chat.registerCommand("/clean", this.handleClean.bind(this)); + this.channel.modules.chat.registerCommand("/cleantitle", this.handleClean.bind(this)); + } +} + +PlaylistModule.prototype = Object.create(ChannelModule.prototype); + +PlaylistModule.prototype.load = function (data) { + var self = this; + var playlist = data.playlist; + if (typeof playlist !== "object" || !("pl" in playlist)) { + return; + } + + var i = 0; + playlist.pos = parseInt(playlist.pos); + playlist.pl.forEach(function (item) { + /* Backwards compatibility */ + var m = new Media(item.media.id, item.media.title, item.media.seconds, + item.media.type); + var newitem = new PlaylistItem(m, { + uid: self._nextuid++, + temp: item.temp, + queueby: item.queueby + }); + + self.items.append(newitem); + self.meta.count++; + self.meta.rawTime += m.seconds; + if (playlist.pos === i) { + self.current = newitem; + } + i++; + }); + + self.meta.time = util.formatTime(self.meta.rawTime); + self.startPlayback(playlist.time); +}; + +PlaylistModule.prototype.save = function (data) { + var arr = this.items.toArray().map(function (m) { + delete m.meta; + return m; + }); + var pos = 0; + for (var i = 0; i < arr.length; i++) { + if (this.current && arr[i].uid == this.current.uid) { + pos = i; + break; + } + } + + var time = 0; + if (this.current) { + time = this.current.media.currentTime; + } + + data.playlist = { + pl: arr, + pos: pos, + time: time + }; +}; + +PlaylistModule.prototype.unload = function () { + if (this._leadInterval) { + clearInterval(this._leadInterval); + this._leadInterval = false; + } + + if (this._gdRefreshTimer) { + clearInterval(this._gdRefreshTimer); + this._gdRefreshTimer = false; + } + + this.channel = null; +}; + +PlaylistModule.prototype.onUserPostJoin = function (user) { + this.sendPlaylist([user]); + this.sendChangeMedia([user]); + user.socket.typecheckedOn("queue", TYPE_QUEUE, this.handleQueue.bind(this, user)); + user.socket.typecheckedOn("setTemp", TYPE_SET_TEMP, this.handleSetTemp.bind(this, user)); + user.socket.typecheckedOn("moveMedia", TYPE_MOVE_MEDIA, this.handleMoveMedia.bind(this, user)); + user.socket.on("delete", this.handleDelete.bind(this, user)); + user.socket.on("jumpTo", this.handleJumpTo.bind(this, user)); + user.socket.on("playNext", this.handlePlayNext.bind(this, user)); + user.socket.typecheckedOn("assignLeader", TYPE_ASSIGN_LEADER, this.handleAssignLeader.bind(this, user)); + user.socket.typecheckedOn("mediaUpdate", TYPE_MEDIA_UPDATE, this.handleUpdate.bind(this, user)); + var self = this; + user.socket.on("playerReady", function () { + self.sendChangeMedia([user]); + }); + user.socket.on("requestPlaylist", function () { + self.sendPlaylist([user]); + }); + user.socket.on("clearPlaylist", this.handleClear.bind(this, user)); + user.socket.on("shufflePlaylist", this.handleShuffle.bind(this, user)); + /* User playlists */ + user.socket.on("listPlaylists", this.handleListPlaylists.bind(this, user)); + user.socket.typecheckedOn("clonePlaylist", TYPE_CLONE_PLAYLIST, this.handleClonePlaylist.bind(this, user)); + user.socket.typecheckedOn("deletePlaylist", TYPE_CLONE_PLAYLIST, this.handleDeletePlaylist.bind(this, user)); + user.socket.typecheckedOn("queuePlaylist", TYPE_QUEUE_PLAYLIST, this.handleQueuePlaylist.bind(this, user)); +}; + +/** + * == Functions for sending various playlist data to users == + */ + +PlaylistModule.prototype.sendPlaylist = function (users) { + var pl = this.items.toArray(true); + var perms = this.channel.modules.permissions; + var self = this; + users.forEach(function (u) { + u.socket.emit("setPlaylistMeta", self.meta); + if (!perms.canSeePlaylist(u)) { + return; + } + u.socket.emit("playlist", pl); + if (self.current) { + u.socket.emit("setCurrent", self.current.uid); + } + }); +}; + +PlaylistModule.prototype.sendChangeMedia = function (users) { + if (!this.current || !this.current.media) { + return; + } + + var update = this.current.media.getFullUpdate(); + var uid = this.current.uid; + if (users === this.channel.users) { + this.channel.broadcastAll("setCurrent", uid); + this.channel.broadcastAll("changeMedia", update); + } else { + users.forEach(function (u) { + u.socket.emit("setCurrent", uid); + u.socket.emit("changeMedia", update); + }); + } + + var m = this.current.media; + this.channel.logger.log("[playlist] Now playing: " + m.title + + " (" + m.type + ":" + m.id + ")"); +}; + +PlaylistModule.prototype.sendMediaUpdate = function (users) { + if (!this.current || !this.current.media) { + return; + } + + var update = this.current.media.getTimeUpdate(); + if (users === this.channel.users) { + this.channel.broadcastAll("mediaUpdate", update); + } else { + users.forEach(function (u) { + u.socket.emit("mediaUpdate", update); + }); + } +}; + +/** + * == Handlers for playlist manipulation == + */ + +PlaylistModule.prototype.handleQueue = function (user, data) { + if (typeof data.id === "boolean" && data.id !== false) { + return; + } + + var id = data.id; + var type = data.type; + + if (data.pos !== "next" && data.pos !== "end") { + return; + } + + /* Specifying a custom title is currently only allowed for custom media */ + if (typeof data.title !== "string" || data.type !== "cu") { + data.title = false; + } + + var link = util.formatLink(id, type); + var perms = this.channel.modules.permissions; + + if (!perms.canAddVideo(user, data)) { + return; + } + + if (data.pos === "next" && !perms.canAddNext(user)) { + return; + } + + /* Certain media types require special permission to add */ + if (data.type === "yp" && !perms.canAddList(user)) { + user.socket.emit("queueFail", { + msg: "You don't have permission to add playlists", + link: link + }); + return; + } else if (util.isLive(type) && !perms.canAddLive(user)) { + user.socket.emit("queueFail", { + msg: "You don't have permission to add live media", + link: link + }); + return; + } else if (type === "cu" && !perms.canAddCustom(user)) { + user.socket.emit("queueFail", { + msg: "You don't have permission to add custom embeds", + link: link + }); + return; + } + + var temp = data.temp || !perms.canAddNonTemp(user); + var queueby = user.getName(); + + var duration = undefined; + /** + * Duration can optionally be specified for a livestream. + * The UI for it only shows up for jw: queues, but it is + * accepted for any live media + */ + if (util.isLive(type) && typeof data.duration === "number") { + duration = !isNaN(data.duration) ? data.duration : undefined; + } + + var limit = { + burst: 3, + sustained: 1 + }; + + if (user.account.effectiveRank >= 2) { + limit = { + burst: 10, + sustained: 2 + }; + } + + if (user.queueLimiter.throttle(limit)) { + user.socket.emit("queueFail", { + msg: "You are adding videos too quickly", + link: link + }); + return; + } + + var maxlength = 0; + if (!perms.canExceedMaxLength(user)) { + if (this.channel.modules.opts) { + maxlength = this.channel.modules.opts.get("maxlength"); + } + } + + data = { + id: data.id, + type: data.type, + pos: data.pos, + title: data.title, + link: link, + temp: temp, + queueby: queueby, + duration: duration, + maxlength: maxlength + }; + + if (data.type === "yp") { + this.queueYouTubePlaylist(user, data); + } else { + this.queueStandard(user, data); + } +}; + +PlaylistModule.prototype.queueStandard = function (user, data) { + var error = function (what) { + user.socket.emit("queueFail", { + msg: what, + link: data.link + }); + }; + + var self = this; + this.channel.activeLock.lock(); + this.semaphore.queue(function (lock) { + var lib = self.channel.modules.library; + if (lib && self.is(Flags.C_REGISTERED) && !util.isLive(data.type)) { + lib.getItem(data.id, function (err, item) { + if (err && err !== "Item not in library") { + error(err+""); + self.channel.activeLock.release(); + return lock.release(); + } + + if (item !== null) { + self._addItem(item, data, user, function () { + lock.release(); + self.channel.activeLock.release(); + }); + } else { + handleLookup(); + } + }); + } + + var handleLookup = function () { + InfoGetter.getMedia(data.id, data.type, function (err, media) { + if (err) { + error(err+""); + self.channel.activeLock.release(); + return lock.release(); + } + + self._addItem(media, data, user, function () { + lock.release(); + self.channel.activeLock.release(); + }); + }); + }; + + if (!lib || util.isLive(data.type)) { + handleLookup(); + } + }); +}; + +PlaylistModule.prototype.queueYouTubePlaylist = function (user, data) { + var error = function (what) { + user.socket.emit("queueFail", { + msg: what, + link: data.link + }); + }; + + var self = this; + this.semaphore.queue(function (lock) { + InfoGetter.getMedia(data.id, data.type, function (err, vids) { + if (err) { + error(err+""); + return lock.release(); + } + + if (self.dead) { + return lock.release(); + } + + /** + * Add videos in reverse order if queueing a playlist next. + * This is because each video gets added after the currently playing video + */ + if (data.pos === "next") { + vids = vids.reverse(); + /* Special case: when the playlist is empty, add the real first video */ + if (self.items.length === 0) { + vids.unshift(vids.pop()); + } + } + + self.channel.activeLock.lock(); + vids.forEach(function (media) { + self._addItem(media, data, user); + }); + self.channel.activeLock.release(); + + lock.release(); + }); + }); +}; + +PlaylistModule.prototype.handleDelete = function (user, data) { + var self = this; + var perms = this.channel.modules.permissions; + if (!perms.canDeleteVideo(user)) { + return; + } + + if (typeof data !== "number") { + return; + } + + var plitem = this.items.find(data); + self.channel.activeLock.lock(); + this.semaphore.queue(function (lock) { + if (self._delete(data)) { + self.channel.logger.log("[playlist] " + user.name + " deleted " + + plitem.media.title); + } + + lock.release(); + self.channel.activeLock.release(); + }); +}; + +PlaylistModule.prototype.handleSetTemp = function (user, data) { + if (!this.channel.modules.permissions.canSetTemp(user)) { + return; + } + + var item = this.items.find(data.uid); + if (!item) { + return; + } + + item.temp = data.temp; + this.channel.broadcastAll("setTemp", data); + + if (!data.temp && this.channel.modules.library) { + this.channel.modules.library.cacheMedia(item.media); + } +}; + +PlaylistModule.prototype.handleMoveMedia = function (user, data) { + if (!this.channel.modules.permissions.canMoveVideo(user)) { + return; + } + + var from = this.items.find(data.from); + var after = this.items.find(data.after); + + if (!from || from === after) { + return; + } + + var self = this; + self.channel.activeLock.lock(); + self.semaphore.queue(function (lock) { + if (!self.items.remove(data.from)) { + self.channel.activeLock.release(); + return lock.release(); + } + + if (data.after === "prepend") { + if (!self.items.prepend(from)) { + self.channel.activeLock.release(); + return lock.release(); + } + } else if (data.after === "append") { + if (!self.items.append(from)) { + self.channel.activeLock.release(); + return lock.release(); + } + } else { + if (!self.items.insertAfter(from, data.after)) { + self.channel.activeLock.release(); + return lock.release(); + } + } + + self.channel.broadcastAll("moveVideo", data); + + self.channel.logger.log("[playlist] " + user.getName() + " moved " + + from.media.title + + (after ? " after " + after.media.title : "")); + lock.release(); + self.channel.activeLock.release(); + }); +}; + +PlaylistModule.prototype.handleJumpTo = function (user, data) { + if (typeof data !== "string" && typeof data !== "number") { + return; + } + + if (!this.channel.modules.permissions.canSkipVideo(user)) { + return; + } + + var to = this.items.find(data); + var title = ""; + if (this.current) { + title = " from " + this.current.media.title; + } + + if (to) { + title += " to " + to.media.title; + var old = this.current; + this.current = to; + this.startPlayback(); + this.channel.logger.log("[playlist] " + user.getName() + " skipped" + title); + + if (old.temp) { + this._delete(old.uid); + } + } +}; + +PlaylistModule.prototype.handlePlayNext = function (user) { + if (!this.channel.modules.permissions.canSkipVideo(user)) { + return; + } + + var title = ""; + if (this.current) { + title = this.current.media.title; + } + + this.channel.logger.log("[playlist] " + user.getName() + " skipped" + title); + this._playNext(); +}; + +PlaylistModule.prototype.handleClear = function (user) { + if (!this.channel.modules.permissions.canClearPlaylist(user)) { + return; + } + + this.channel.logger.log("[playlist] " + user.getName() + " cleared the playlist"); + this.current = null; + this.items.clear(); + this.semaphore.reset(); + + this.meta = { + count: 0, + rawTime: 0, + time: util.formatTime(0) + }; + + this.channel.broadcastAll("playlist", []); + this.channel.broadcastAll("setPlaylistMeta", this.meta); +}; + +PlaylistModule.prototype.handleShuffle = function (user) { + if (!this.channel.modules.permissions.canShufflePlaylist(user)) { + return; + } + + this.channel.logger.log("[playlist] " + user.getName() + " shuffled the playlist"); + + var pl = this.items.toArray(false); + this.items.clear(); + this.semaphore.reset(); + while (pl.length > 0) { + var i = Math.floor(Math.random() * pl.length); + var item = new PlaylistItem(pl[i].media, { + uid: this._nextuid++, + temp: pl[i].temp, + queueby: pl[i].queueby + }); + + this.items.append(item); + pl.splice(i, 1); + } + + this.current = this.items.first; + pl = this.items.toArray(true); + var perms = this.channel.modules.permissions; + this.channel.users.forEach(function (u) { + if (perms.canSeePlaylist(u)) { + u.socket.emit("playlist", pl); + }; + }); + this.startPlayback(); +}; + +/** + * == Leader stuff == + */ +PlaylistModule.prototype.handleAssignLeader = function (user, data) { + if (!this.channel.modules.permissions.canAssignLeader(user)) { + return user.kick("Attempted assignLeader without sufficient permission"); + } + + var name = data.name; + + if (this.leader) { + var old = this.leader; + this.leader = null; + if (old.account.effectiveRank === 1.5) { + old.account.effectiveRank = old.account.oldRank; + old.socket.emit("rank", old.account.effectiveRank); + } + + this.channel.broadcastAll("setUserRank", { + name: old.getName(), + rank: old.account.effectiveRank + }); + } + + if (!name) { + this.channel.broadcastAll("setLeader", ""); + + this.channel.logger.log("[playlist] Resuming autolead"); + if (!this._leadInterval) { + this._lastUpdate = Date.now(); + this._leadInterval = setInterval(this._leadLoop.bind(this), 1000); + } + + return; + } + + for (var i = 0; i < this.channel.users.length; i++) { + if (this.channel.users[i].getName() === name) { + this.channel.logger.log("[playlist] Assigned leader: " + name); + this.leader = this.channel.users[i]; + if (this._leadInterval) { + clearInterval(this._leadInterval); + this._leadInterval = false; + } + if (this.leader.account.effectiveRank < 1.5) { + this.leader.account.oldRank = this.leader.account.effectiveRank; + this.leader.account.effectiveRank = 1.5; + this.leader.socket.emit("rank", 1.5); + } + + this.channel.broadcastAll("setLeader", name); + if (this.leader.account.effectiveRank === 1.5) { + this.channel.broadcastAll("setUserRank", { + name: name, + rank: 1.5 + }); + } + break; + } + } + + if (this.leader === null) { + user.socket.emit("errorMsg", { + msg: "Unable to assign leader: could not find user " + name + }); + return; + } + + this.channel.logger.log("[mod] " + user.getName() + " assigned leader to " + data.name); +}; + +PlaylistModule.prototype.handleUpdate = function (user, data) { + if (this.leader !== user) { + return; + } + + if (!this.current) { + return; + } + + var media = this.current.media; + if (util.isLive(media.type) && media.type !== "jw") { + return; + } + + if (media.id !== data.id || isNaN(data.currentTime)) { + return; + } + + media.currentTime = data.currentTime; + media.paused = Boolean(data.paused); + var update = media.getTimeUpdate(); + + this.channel.broadcastAll("mediaUpdate", update); +}; + +/** + * == Internal playlist manipulation == + */ + +PlaylistModule.prototype._delete = function (uid) { + var self = this; + var perms = this.channel.modules.permissions; + + var item = self.items.find(uid); + if (!item) { + return false; + } + var next = item.next; + + var success = self.items.remove(uid); + + if (success) { + self.meta.count--; + self.meta.rawTime -= item.media.seconds; + self.meta.time = util.formatTime(self.meta.rawTime); + self.channel.users.forEach(function (u) { + if (perms.canSeePlaylist(u)) { + u.socket.emit("delete", { uid: uid }); + } + u.socket.emit("setPlaylistMeta", self.meta); + }); + } + + if (self.current === item) { + self.current = next; + self.startPlayback(); + } + + return success; +}; + +PlaylistModule.prototype._addItem = function (media, data, user, cb) { + var self = this; + var allowDuplicates = false; + if (this.channel.modules.options && this.channel.modules.options.get("allow_dupes")) { + allowDuplicates = true; + } + + var qfail = function (msg) { + user.socket.emit("queueFail", { + msg: msg, + link: data.link + }); + if (cb) { + cb(); + } + }; + + if (data.maxlength > 0 && media.seconds > data.maxlength) { + return qfail("Video exceeds the maximum length set by the channel admin: " + + data.maxlength + " seconds"); + } + + if (this.items.length >= MAX_ITEMS) { + return qfail("Playlist limit reached (" + MAX_ITEMS + ")"); + } + + var existing = this.items.findVideoId(media.id); + if (existing && !allowDuplicates && (data.pos === "end" || existing === this.current)) { + return qfail("This item is already on the playlist"); + } + + /* Warn about blocked countries */ + if (media.meta.restricted) { + user.socket.emit("queueWarn", { + msg: "Video is blocked in the following countries: " + media.meta.restricted, + link: data.link + }); + } + + var item = new PlaylistItem(media, { + uid: self._nextuid++, + temp: data.temp, + queueby: data.queueby + }); + + if (data.title && media.type === "cu") { + media.title = data.title; + } + + var success = function () { + var packet = { + item: item.pack(), + after: item.prev ? item.prev.uid : "prepend" + }; + + self.meta.count++; + self.meta.rawTime += media.seconds; + self.meta.time = util.formatTime(self.meta.rawTime); + + var perms = self.channel.modules.permissions; + self.channel.users.forEach(function (u) { + if (perms.canSeePlaylist(u)) { + u.socket.emit("queue", packet); + } + + u.socket.emit("setPlaylistMeta", self.meta); + }); + + if (!data.temp && !util.isLive(media.type)) { + if (self.channel.modules.library) { + self.channel.modules.library.cacheMedia(media); + } + } + + if (self.items.length === 1) { + self.current = item; + self.startPlayback(); + } + + if (cb) { + cb(); + } + }; + + if (data.pos === "end" || this.current == null) { + this.items.append(item); + return success(); + } else { + if (this.items.insertAfter(item, this.current.uid)) { + if (existing && !allowDuplicates) { + item.temp = existing.temp; + this._delete(existing.uid); + } + return success(); + } else { + return qfail("Playlist failure"); + } + } +}; + +PlaylistModule.prototype.startPlayback = function (time) { + var self = this; + + if (!self.current || !self.current.media) { + return false; + } + + var media = self.current.media; + media.reset(); + + if (media.type === "vi" && !media.direct && Config.get("vimeo-workaround")) { + self.channel.activeLock.lock(); + vimeoWorkaround(media.id, function (direct) { + self.channel.activeLock.release(); + if (self.current && self.current.media === media) { + self.current.media.direct = direct; + self.startPlayback(time); + } + }); + return; + } + + if (media.type === "gd" && !media.meta.object) { + self.channel.activeLock.lock(); + this.refreshGoogleDocs(function () { + self.channel.activeLock.release(); + if (self.current && self.current.media === media) { + self.startPlayback(time); + } + }); + return; + } + + if (self.leader != null) { + media.paused = false; + media.currentTime = time || 0; + self.sendChangeMedia(self.channel.users); + self.channel.notifyModules("onMediaChange", this.current.media); + return; + } + + /* Lead-in time of 3 seconds to allow clients to buffer */ + time = time || -3; + media.paused = time < 0; + media.currentTime = time; + + /* Module was already leading, stop the previous timer */ + if (self._leadInterval) { + clearInterval(self._leadInterval); + self._leadInterval = false; + } + + self.sendChangeMedia(self.channel.users); + self.channel.notifyModules("onMediaChange", this.current.media); + + /* Only start the timer if the media item is not live, i.e. has a duration */ + if (media.seconds > 0) { + self._lastUpdate = Date.now(); + self._leadInterval = setInterval(function() { + self._leadLoop(); + }, 1000); + } + + /* Google Docs autorefresh */ + if (this._gdRefreshTimer) { + clearInterval(this._gdRefreshTimer); + this._gdRefreshTimer = false; + } + + if (media.type === "gd") { + this._gdRefreshTimer = setInterval(this.refreshGoogleDocs.bind(this), 3600000); + } +} + +const UPDATE_INTERVAL = Config.get("playlist.update-interval"); + +PlaylistModule.prototype._leadLoop = function() { + if (this.current == null) { + return; + } + + if (this.channel.dead) { + this.die(); + return; + } + + var dt = (Date.now() - this._lastUpdate) / 1000.0; + var t = this.current.media.currentTime; + + /* Transition from lead-in to playback */ + if (t < 0 && (t + dt) >= 0) { + this.current.media.currentTime = 0; + this.current.media.paused = false; + this._counter = 0; + this._lastUpdate = Date.now(); + this.sendMediaUpdate(this.channel.users); + return; + } + + this.current.media.currentTime += dt; + this._lastUpdate = Date.now(); + this._counter++; + + /** + * Don't transition until 2 seconds after the end, to allow slightly + * off-sync clients to catch up + */ + if (this.current.media.currentTime >= this.current.media.seconds + 2) { + this._playNext(); + } else if(this._counter % UPDATE_INTERVAL == 0) { + this.sendMediaUpdate(this.channel.users); + } +}; + +PlaylistModule.prototype.refreshGoogleDocs = function (cb) { + var self = this; + var abort = function () { + clearInterval(self._gdRefreshTimer); + self._gdRefreshTimer = false; + if (self.current) { + self.current.media.meta.object = self.current.media.meta.object || null; + } + cb(); + }; + + if (!this.current || this.current.media.type !== "gd") { + return abort(); + } + + self.channel.activeLock.lock(); + InfoGetter.getMedia(this.current.media.id, "gd", function (err, media) { + if (err) { + Logger.errlog.log("Google Docs autorefresh failed: " + err); + self.current.media.meta.object = self.current.media.meta.object || null; + cb && cb(); + self.channel.activeLock.release(); + } else { + if (!self.current || self.current.media.type !== "gd") { + self.channel.activeLock.release(); + return abort(); + } + + self.current.media.meta = media.meta; + self.channel.logger.log("[playlist] Auto-refreshed Google Doc video"); + cb && cb(); + self.channel.activeLock.release(); + } + }); +}; + +PlaylistModule.prototype._playNext = function () { + if (!this.current) { + return; + } + + var next = this.current.next || this.items.first; + + if (this.current.temp) { + if (next === this.current) { + next = null; + } + this._delete(this.current.uid); + } + + if (next) { + this.current = next; + this.startPlayback(); + } +}; + +PlaylistModule.prototype.clean = function (test) { + var self = this; + var matches = self.items.findAll(test); + matches.forEach(function (m) { + self._delete(m.uid); + }); +}; + +/** + * == Command Handlers == + */ + +function generateTargetRegex(target) { + const flagsre = /^(-[img]+\s+)/i + var m = target.match(flagsre); + var flags = ""; + if (m) { + flags = m[0].slice(1,-1); + target = target.replace(flagsre, ""); + } + return new RegExp(target, flags); +} + +PlaylistModule.prototype.handleClean = function (user, msg, meta) { + if (!this.channel.modules.permissions.canDeleteVideo(user)) { + return; + } + + var args = msg.split(" "); + var cmd = args.shift(); + var target = generateTargetRegex(args.join(" ")); + + var cleanfn; + if (cmd === "/clean") { + cleanfn = function (item) { return target.test(item.queueby); }; + } else if (cmd === "/cleantitle") { + cleanfn = function (item) { return target.exec(item.media.title) !== null; }; + } + + this.clean(cleanfn); +}; + +/** + * == User playlist stuff == + */ +PlaylistModule.prototype.handleListPlaylists = function (user) { + if (!user.is(Flags.U_REGISTERED)) { + return user.socket.emit("errorMsg", { + msg: "Only registered users can use the user playlist function." + }); + } + + db.listUserPlaylists(user.getName(), function (err, rows) { + if (err) { + user.socket.emit("errorMsg", { + msg: "Database error when attempting to fetch list of playlists" + }); + return; + } + + user.socket.emit("listPlaylists", rows); + }); +}; + +PlaylistModule.prototype.handleClonePlaylist = function (user, data) { + if (!user.is(Flags.U_REGISTERED)) { + return user.socket.emit("errorMsg", { + msg: "Only registered users can use the user playlist function." + }); + } + + var pl = this.items.toArray(); + var self = this; + db.saveUserPlaylist(pl, user.getName(), data.name, function (err) { + if (err) { + user.socket.emit("errorMsg", { + msg: "Database error when saving playlist" + }); + return; + } + + self.handleListPlaylists(user); + }); +}; + +PlaylistModule.prototype.handleDeletePlaylist = function (user, data) { + if (!user.is(Flags.U_REGISTERED)) { + return user.socket.emit("errorMsg", { + msg: "Only registered users can use the user playlist function." + }); + } + + var self = this; + db.deleteUserPlaylist(user.getName(), data.name, function (err) { + if (err) { + user.socket.emit("errorMsg", { + msg: "Database error when deleting playlist" + }); + return; + } + + self.handleListPlaylists(user); + }); +}; + +PlaylistModule.prototype.handleQueuePlaylist = function (user, data) { + var perms = this.channel.modules.permissions; + + if (!perms.canAddList(user)) { + return; + } + + if (data.pos !== "next" && data.pos !== "end") { + return; + } + + if (data.pos === "next" && !perms.canAddNext(user)) { + return; + } + + var temp = data.temp || !perms.canAddNonTemp(user); + var maxlength = 0; + if (!perms.canExceedMaxLength(user)) { + if (this.channel.modules.opts) { + maxlength = this.channel.modules.opts.get("maxlength"); + } + } + var qdata = { + temp: temp, + queueby: user.getName(), + maxlength: maxlength, + pos: data.pos + }; + + var self = this; + self.channel.activeLock.lock(); + db.getUserPlaylist(user.getName(), data.name, function (err, pl) { + if (err) { + return user.socket.emit("errorMsg", { + msg: "Playlist load failed: " + err + }); + } + + try { + if (data.pos === "next") { + pl.reverse(); + if (pl.length > 0 && self.meta.count === 0) { + pl.unshift(pl.pop()); + } + } + + pl.forEach(function (item) { + var m = new Media(item.id, item.title, item.seconds, item.type); + self._addItem(m, qdata, user); + }); + self.channel.activeLock.release(); + } catch (e) { + Logger.errlog.log("Loading user playlist failed!"); + Logger.errlog.log("PL: " + user.getName() + "-" + data.name); + Logger.errlog.log(e.stack); + user.socket.emit("queueFail", { + msg: "Internal error occurred when loading playlist.", + link: null + }); + self.channel.activeLock.release(); + } + }); +}; + +module.exports = PlaylistModule; diff --git a/lib/channel/poll.js b/lib/channel/poll.js new file mode 100644 index 00000000..d199a1b5 --- /dev/null +++ b/lib/channel/poll.js @@ -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; diff --git a/lib/channel/ranks.js b/lib/channel/ranks.js new file mode 100644 index 00000000..ca0ccf05 --- /dev/null +++ b/lib/channel/ranks.js @@ -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 . 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; diff --git a/lib/channel/voteskip.js b/lib/channel/voteskip.js new file mode 100644 index 00000000..3102d32a --- /dev/null +++ b/lib/channel/voteskip.js @@ -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; diff --git a/lib/chatcommand.js b/lib/chatcommand.js deleted file mode 100644 index 20ad2160..00000000 --- a/lib/chatcommand.js +++ /dev/null @@ -1,387 +0,0 @@ -/* -The MIT License (MIT) -Copyright (c) 2013 Calvin Montgomery - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -*/ - -var Logger = require("./logger.js"); -var Poll = require("./poll").Poll; - -var handlers = { - /* commands that send chat messages */ - "me": function (chan, user, msg, meta) { - meta.addClass = "action"; - meta.action = true; - chan.sendMessage(user, msg, meta); - return true; - }, - "sp": function (chan, user, msg, meta) { - meta.addClass = "spoiler"; - chan.sendMessage(user, msg, meta); - return true; - }, - "say": function (chan, user, msg, meta) { - if (user.rank >= 1.5) { - meta.addClass = "shout"; - meta.addClassToNameAndTimestamp = true; - meta.forceShowName = true; - chan.sendMessage(user, msg, meta); - return true; - } - }, - "a": function (chan, user, msg, meta) { - if (user.global_rank < 255) { - return false; - } - - var superadminflair = { - labelclass: "label-danger", - icon: "glyphicon-globe" - }; - - var args = msg.split(" "); - var cargs = []; - for (var i = 0; i < args.length; i++) { - var a = args[i]; - 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; - chan.sendMessage(user, cargs.join(" "), meta); - return true; - }, - "poll": function (chan, user, msg, meta) { - handlePoll(chan, user, msg, false); - return true; - }, - "hpoll": function (chan, user, msg, meta) { - handlePoll(chan, user, msg, true); - return true; - }, - - /* commands that do not send chat messages */ - "afk": function (chan, user, msg, meta) { - user.setAFK(!user.meta.afk); - return true; - }, - "mute": function (chan, user, msg, meta) { - handleMute(chan, user, msg.split(" ")); - return true; - }, - "smute": function (chan, user, msg, meta) { - handleShadowMute(chan, user, msg.split(" ")); - return true; - }, - "unmute": function (chan, user, msg, meta) { - handleUnmute(chan, user, msg.split(" ")); - return true; - }, - "kick": function (chan, user, msg, meta) { - handleKick(chan, user, msg.split(" ")); - return true; - }, - "ban": function (chan, user, msg, meta) { - handleBan(chan, user, msg.split(" ")); - return true; - }, - "ipban": function (chan, user, msg, meta) { - handleIPBan(chan, user, msg.split(" ")); - return true; - }, - "clear": function (chan, user, msg, meta) { - handleClear(chan, user); - return true; - }, - "clean": function (chan, user, msg, meta) { - handleClean(chan, user, msg); - return true; - }, - "cleantitle": function (chan, user, msg, meta) { - handleCleanTitle(chan, user, msg); - return true; - } -}; - -var handlerList = []; -for (var key in handlers) { - handlerList.push({ - // match /command followed by a space or end of string - re: new RegExp("^\\/" + key + "(?:\\s|$)"), - fn: handlers[key] - }); -} - -function handle(chan, user, msg, meta) { - // Special case because the drink command can vary - var m = msg.match(/^\/d(-?[0-9]*)(?:\s|$)(.*)/); - if (m) { - handleDrink(chan, user, m[1], m[2], meta); - return true; - } - for (var i = 0; i < handlerList.length; i++) { - var h = handlerList[i]; - if (msg.match(h.re)) { - var rest; - if (msg.indexOf(" ") >= 0) { - rest = msg.substring(msg.indexOf(" ") + 1); - } else { - rest = ""; - } - return h.fn(chan, user, rest, meta); - } - } -} - -function handleDrink(chan, user, count, msg, meta) { - if (!chan.hasPermission(user, "drink")) { - return; - } - - if (count === "") { - count = 1; - } - count = parseInt(count); - if (isNaN(count)) { - return; - } - - meta.drink = true; - meta.forceShowName = true; - meta.addClass = "drink"; - chan.drinks += count; - chan.sendDrinks(chan.users); - if (count < 0 && msg.trim() === "") { - return; - } - - msg = msg + " drink!"; - if (count !== 1) { - msg += " (x" + count + ")"; - } - chan.sendMessage(user, msg, meta); -} - -function handleShadowMute(chan, user, args) { - if (chan.hasPermission(user, "mute") && args.length > 0) { - args[0] = args[0].toLowerCase(); - var person = false; - for (var i = 0; i < chan.users.length; i++) { - if (chan.users[i].name.toLowerCase() === args[0]) { - person = chan.users[i]; - break; - } - } - - if (person) { - if (person.rank >= user.rank) { - user.socket.emit("errorMsg", { - msg: "You don't have permission to mute that person." - }); - return; - } - - /* Reset a previous regular mute */ - if (chan.mutedUsers.contains(person.name.toLowerCase())) { - chan.mutedUsers.remove(person.name.toLowerCase()); - } - - person.meta.smuted = person.meta.muted = true; - chan.sendUserMeta(chan.users, person, 2); - chan.mutedUsers.add("[shadow]" + person.name.toLowerCase()); - chan.logger.log("[mod] " + user.name + " shadow muted " + args[0]); - chan.sendModMessage(user.name + " shadow muted " + args[0], 2); - } - } -} - -function handleMute(chan, user, args) { - if (chan.hasPermission(user, "mute") && args.length > 0) { - args[0] = args[0].toLowerCase(); - var person = false; - for (var i = 0; i < chan.users.length; i++) { - if (chan.users[i].name.toLowerCase() == args[0]) { - person = chan.users[i]; - break; - } - } - - if (person) { - if (person.rank >= user.rank) { - user.socket.emit("errorMsg", { - msg: "You don't have permission to mute that person." - }); - return; - } - person.meta.muted = true; - chan.sendUserMeta(chan.users, person); - chan.mutedUsers.add(person.name.toLowerCase()); - chan.logger.log("[mod] " + user.name + " muted " + args[0]); - chan.sendModMessage(user.name + " muted " + args[0], 2); - } - } -} - -function handleUnmute(chan, user, args) { - if (chan.hasPermission(user, "mute") && args.length > 0) { - args[0] = args[0].toLowerCase(); - var person = false; - for (var i = 0; i < chan.users.length; i++) { - if (chan.users[i].name.toLowerCase() == args[0]) { - person = chan.users[i]; - break; - } - } - - if (person) { - if (person.rank >= user.rank) { - user.socket.emit("errorMsg", { - msg: "You don't have permission to unmute that person." - }); - return; - } - person.meta.muted = false; - var wasSmuted = person.meta.smuted; - person.meta.smuted = false; - chan.sendUserMeta(chan.users, person, wasSmuted ? 2 : false); - chan.mutedUsers.remove(person.name.toLowerCase()); - chan.mutedUsers.remove("[shadow]" + person.name.toLowerCase()); - chan.logger.log("[mod] " + user.name + " unmuted " + args[0]); - chan.sendModMessage(user.name + " unmuted " + args[0], 2); - } - } -} - -function handleKick(chan, user, args) { - if (chan.hasPermission(user, "kick") && args.length > 0) { - args[0] = args[0].toLowerCase(); - if (args[0] == user.name.toLowerCase()) { - user.socket.emit("costanza", { - msg: "Kicking yourself?" - }); - return; - } - var kickee; - for (var i = 0; i < chan.users.length; i++) { - if (chan.users[i].name.toLowerCase() == args[0]) { - if (chan.users[i].rank >= user.rank) { - user.socket.emit("errorMsg", { - msg: "You don't have permission to kick " + args[0] - }); - return; - } - kickee = chan.users[i]; - break; - } - } - if (kickee) { - chan.logger.log("[mod] " + user.name + " kicked " + args[0]); - args[0] = ""; - var reason = args.join(" "); - kickee.kick(reason); - } - } -} - -function handleIPBan(chan, user, args) { - var name = args.shift(); - var range = args.shift(); - var reason; - if (range !== "range") { - reason = range + " " + args.join(" "); - range = false; - } else { - reason = args.join(" "); - range = true; - } - chan.handleBanAllIP(user, name, reason, range); - // Ban the name too for good measure - chan.handleNameBan(user, name, reason); -} - -function handleBan(chan, user, args) { - var name = args.shift(); - var reason = args.join(" "); - chan.handleNameBan(user, name, reason); -} - -function handleUnban(chan, user, args) { - if (chan.hasPermission(user, "ban") && args.length > 0) { - chan.logger.log("[mod] " + user.name + " unbanned " + args[0]); - if (args[0].match(/(\d+)\.(\d+)\.(\d+)\.(\d+)/)) { - chan.unbanIP(user, args[0]); - } - else { - chan.unbanName(user, args[0]); - } - } -} - -function handlePoll(chan, user, msg, hidden) { - if (chan.hasPermission(user, "pollctl")) { - var args = msg.split(","); - var title = args[0]; - args.splice(0, 1); - var poll = new Poll(user.name, title, args, hidden === true); - chan.poll = poll; - chan.sendPoll(chan.users); - chan.logger.log("[poll] " + user.name + " Opened Poll: '" + poll.title + "'"); - } -} - -function handleClear(chan, user) { - if (user.rank < 2) { - return; - } - - chan.chatbuffer = []; - chan.sendAll("clearchat"); -} - -/* - /clean and /cleantitle contributed by http://github.com/unbibium. - Modifications by Calvin Montgomery -*/ - -function generateTargetRegex(target) { - const flagsre = /^(-[img]+\s+)/i - var m = target.match(flagsre); - var flags = ""; - if (m) { - flags = m[0].slice(1,-1); - target = target.replace(flagsre, ""); - } - return new RegExp(target, flags); -} - -function handleClean(chan, user, target) { - if (!chan.hasPermission(user, "playlistdelete")) - return; - target = generateTargetRegex(target); - chan.playlist.clean(function (item) { - return target.test(item.queueby); - }); -} - -function handleCleanTitle(chan, user, target) { - if (!chan.hasPermission(user, "playlistdelete")) - return; - target = generateTargetRegex(target); - chan.playlist.clean(function (item) { - return target.exec(item.media.title) !== null; - }); -} - -exports.handle = handle; - diff --git a/lib/config.js b/lib/config.js index 18a79050..c4427291 100644 --- a/lib/config.js +++ b/lib/config.js @@ -13,6 +13,7 @@ var fs = require("fs"); var path = require("path"); var Logger = require("./logger"); var nodemailer = require("nodemailer"); +var net = require("net"); var YAML = require("yamljs"); var defaults = { @@ -98,7 +99,11 @@ var defaults = { email: "cyzon@cytu.be" } ], - "aggressive-gc": false + "aggressive-gc": false, + playlist: { + "max-items": 4000, + "update-interval": 5 + } }; /** @@ -239,6 +244,77 @@ function preprocessConfig(cfg) { cfg.https["full-address"] = httpsfa; } + + // Socket.IO URLs + cfg.io["ipv4-nossl"] = ""; + cfg.io["ipv4-ssl"] = ""; + cfg.io["ipv6-nossl"] = ""; + cfg.io["ipv6-ssl"] = ""; + for (var i = 0; i < cfg.listen.length; i++) { + var srv = cfg.listen[i]; + if (!srv.io) { + continue; + } + + if (srv.ip === "") { + if (srv.port === cfg.io["default-port"]) { + cfg.io["ipv4-nossl"] = cfg.io["domain"] + ":" + cfg.io["default-port"]; + } else if (srv.port === cfg.https["default-port"]) { + cfg.io["ipv4-ssl"] = cfg.https["domain"] + ":" + cfg.https["default-port"]; + } + continue; + } + + if (net.isIPv4(srv.ip) || srv.ip === "::") { + if (srv.https && !cfg.io["ipv4-ssl"]) { + if (srv.url) { + cfg.io["ipv4-ssl"] = srv.url; + } else { + cfg.io["ipv4-ssl"] = "https://" + srv.ip + ":" + srv.port; + } + } else if (!cfg.io["ipv4-nossl"]) { + if (srv.url) { + cfg.io["ipv4-nossl"] = srv.url; + } else { + cfg.io["ipv4-nossl"] = "http://" + srv.ip + ":" + srv.port; + } + } + } + if (net.isIPv6(srv.ip) || srv.ip === "::") { + if (srv.https && !cfg.io["ipv6-ssl"]) { + if (!srv.url) { + Logger.errlog.log("Config Error: no URL defined for IPv6 " + + "Socket.IO listener! Ignoring this listener " + + "because the Socket.IO client cannot connect to " + + "a raw IPv6 address."); + Logger.errlog.log("(Listener was: " + JSON.stringify(srv) + ")"); + } else { + cfg.io["ipv6-ssl"] = srv.url; + } + } else if (!cfg.io["ipv6-nossl"]) { + if (!srv.url) { + Logger.errlog.log("Config Error: no URL defined for IPv6 " + + "Socket.IO listener! Ignoring this listener " + + "because the Socket.IO client cannot connect to " + + "a raw IPv6 address."); + Logger.errlog.log("(Listener was: " + JSON.stringify(srv) + ")"); + } else { + cfg.io["ipv6-nossl"] = srv.url; + } + } + } + } + + cfg.io["ipv4-default"] = cfg.io["ipv4-ssl"] || cfg.io["ipv4-nossl"]; + cfg.io["ipv6-default"] = cfg.io["ipv6-ssl"] || cfg.io["ipv6-nossl"]; + + // sioconfig + var sioconfig = "var IO_URLS={'ipv4-nossl':'" + cfg.io["ipv4-nossl"] + "'," + + "'ipv4-ssl':'" + cfg.io["ipv4-ssl"] + "'," + + "'ipv6-nossl':'" + cfg.io["ipv6-nossl"] + "'," + + "'ipv6-ssl':'" + cfg.io["ipv6-ssl"] + "'};"; + cfg.sioconfig = sioconfig; + // Generate RegExps for reserved names var reserved = cfg["reserved-names"]; for (var key in reserved) { diff --git a/lib/database.js b/lib/database.js index e191ab07..fa6e9029 100644 --- a/lib/database.js +++ b/lib/database.js @@ -5,6 +5,8 @@ var Logger = require("./logger"); var Config = require("./config"); var Server = require("./server"); var tables = require("./database/tables"); +var net = require("net"); +var util = require("./utilities"); var pool = null; var global_ipbans = {}; @@ -97,23 +99,16 @@ function blackHole() { * Check if an IP address is globally banned */ module.exports.isGlobalIPBanned = function (ip, callback) { - if (typeof callback !== "function") { - return; - } - - // TODO account for IPv6 - // Also possibly just change this to allow arbitrary - // ranges instead of only /32, /24, /16 - const re = /(\d+)\.(\d+)\.(\d+)\.(\d+)/; - // Account for range banning - var s16 = ip.replace(re, "$1.$2"); - var s24 = ip.replace(re, "$1.$2.$3"); - + var range = util.getIPRange(ip); + var wrange = util.getWideIPRange(ip); var banned = ip in global_ipbans || - s16 in global_ipbans || - s24 in global_ipbans; + range in global_ipbans || + wrange in global_ipbans; - callback(null, banned); + if (callback) { + callback(null, banned); + } + return banned; }; /** @@ -478,9 +473,13 @@ module.exports.getAliases = function (ip, callback) { var query = "SELECT name,time FROM aliases WHERE ip"; // if the ip parameter is a /24 range, we want to match accordingly - if(ip.match(/^\d+\.\d+\.\d+$/)) { + if (ip.match(/^\d+\.\d+\.\d+$/) || ip.match(/^\d+\.\d+$/)) { query += " LIKE ?"; ip += ".%"; + } else if (ip.match(/^(?:[0-9a-f]{4}:){3}[0-9a-f]{4}$/) || + ip.match(/^(?:[0-9a-f]{4}:){2}[0-9a-f]{4}$/)) { + query += " LIKE ?"; + ip += ":%"; } else { query += "=?"; } @@ -493,6 +492,8 @@ module.exports.getAliases = function (ip, callback) { names = res.map(function (row) { return row.name; }); } + console.log(query, names); + callback(err, names); }); }; @@ -511,103 +512,12 @@ module.exports.getIPs = function (name, callback) { if(!err) { ips = res.map(function (row) { return row.ip; }); } - callback(err, ips); }); }; /* END REGION */ -/* REGION action log */ - -/* -module.exports.recordAction = function (ip, name, action, args, - callback) { - if(typeof callback !== "function") - callback = blackHole; - - var query = "INSERT INTO actionlog (ip, name, action, args, time) " + - "VALUES (?, ?, ?, ?, ?)"; - - module.exports.query(query, [ip, name, action, args, Date.now()], callback); -}; - -module.exports.clearActions = function (actions, callback) { - if(typeof callback !== "function") - callback = blackHole; - - var list = []; - for(var i in actions) - list.push("?"); - - var actionlist = "(" + list.join(",") + ")"; - - var query = "DELETE FROM actionlog WHERE action IN " + actionlist; - module.exports.query(query, actions, callback); -}; - -module.exports.clearSingleAction = function (item, callback) { - if(typeof callback !== "function") - callback = blackHole; - - var query = "DELETE FROM actionlog WHERE ip=? AND time=?"; - module.exports.query(query, [item.ip, item.time], callback); -}; - - -module.exports.recentRegistrationCount = function (ip, callback) { - if(typeof callback !== "function") - return; - - var query = "SELECT * FROM actionlog WHERE ip=? " + - "AND action='register-success' AND time > ?"; - - module.exports.query(query, [ip, Date.now() - 48 * 3600 * 1000], - function (err, res) { - if(err) { - callback(err, null); - return; - } - - callback(null, res.length); - }); -}; - -module.exports.listActionTypes = function (callback) { - if(typeof callback !== "function") - return; - - var query = "SELECT DISTINCT action FROM actionlog"; - module.exports.query(query, function (err, res) { - if(err) { - callback(err, null); - return; - } - - var types = []; - res.forEach(function (row) { - types.push(row.action); - }); - callback(null, types); - }); -}; - -module.exports.listActions = function (types, callback) { - if(typeof callback !== "function") - return; - - var list = []; - for(var i in types) - list.push("?"); - - var actionlist = "(" + list.join(",") + ")"; - var query = "SELECT * FROM actionlog WHERE action IN " + actionlist; - module.exports.query(query, types, callback); -}; -*/ - -/* END REGION */ - /* REGION stats */ module.exports.addStatPoint = function (time, ucount, ccount, mem, callback) { diff --git a/lib/database/accounts.js b/lib/database/accounts.js index b7039cd0..727fd349 100644 --- a/lib/database/accounts.js +++ b/lib/database/accounts.js @@ -295,12 +295,17 @@ module.exports = { return; } + if (!name) { + callback(null, -1); + return; + } + db.query("SELECT global_rank FROM `users` WHERE name=?", [name], function (err, rows) { if (err) { callback(err, null); } else if (rows.length === 0) { - callback("User does not exist", null); + callback(null, 0); } else { callback(null, rows[0].global_rank); } @@ -344,6 +349,10 @@ module.exports = { return; } + if (names.length === 0) { + return callback(null, []); + } + var list = "(" + names.map(function () { return "?";}).join(",") + ")"; db.query("SELECT global_rank FROM `users` WHERE name IN " + list, names, diff --git a/lib/database/channels.js b/lib/database/channels.js index bcd5f549..1b54da6c 100644 --- a/lib/database/channels.js +++ b/lib/database/channels.js @@ -4,6 +4,8 @@ var fs = require("fs"); var path = require("path"); var Logger = require("../logger"); var tables = require("./tables"); +var Flags = require("../flags"); +var util = require("../utilities"); var blackHole = function () { }; @@ -292,7 +294,7 @@ module.exports = { // than the database has stored. Update accordingly. chan.name = res[0].name; chan.uniqueName = chan.name.toLowerCase(); - chan.registered = true; + chan.setFlag(Flags.C_REGISTERED); chan.logger.log("[init] Loaded channel from database"); callback(null, true); }); @@ -315,7 +317,7 @@ module.exports = { [name], function (err, rows) { if (err) { - callback(err, 1); + callback(err, -1); return; } @@ -529,9 +531,11 @@ module.exports = { return; } - var range = ip.replace(/^(\d+\.\d+\.\d+)\.\d+$/, "$1"); + var range = util.getIPRange(ip); + var wrange = util.getWideIPRange(ip); - db.query("SELECT * FROM `chan_" + chan + "_bans` WHERE ip=? OR ip=?", [ip, range], + db.query("SELECT * FROM `chan_" + chan + "_bans` WHERE ip IN (?, ?, ?)", + [ip, range, wrange], function (err, rows) { callback(err, err ? false : rows.length > 0); }); diff --git a/lib/emitter.js b/lib/emitter.js index 7670cbde..ed487ba6 100644 --- a/lib/emitter.js +++ b/lib/emitter.js @@ -46,6 +46,27 @@ function MakeEmitter(obj) { } }); }; + + obj.unbind = function (ev, fn) { + var self = this; + if (ev in self.__evHandlers) { + if (!fn) { + self.__evHandlers[ev] = []; + } else { + var j = -1; + for (var i = 0; i < self.__evHandlers[ev].length; i++) { + if (self.__evHandlers[ev][i].fn === fn) { + j = i; + break; + } + } + + if (j >= 0) { + self.__evHandlers[ev].splice(j, 1); + } + } + } + }; } module.exports = MakeEmitter; diff --git a/lib/flags.js b/lib/flags.js new file mode 100644 index 00000000..d9e2c870 --- /dev/null +++ b/lib/flags.js @@ -0,0 +1,14 @@ +module.exports = { + C_READY : 1 << 0, + C_ERROR : 1 << 1, + C_REGISTERED : 1 << 2, + + U_READY : 1 << 0, + U_LOGGING_IN : 1 << 1, + U_LOGGED_IN : 1 << 2, + U_REGISTERED : 1 << 3, + U_AFK : 1 << 4, + U_MUTED : 1 << 5, + U_SMUTED : 1 << 6, + U_IN_CHANNEL : 1 << 7 +}; diff --git a/lib/get-info.js b/lib/get-info.js index 9948150f..5e92159a 100644 --- a/lib/get-info.js +++ b/lib/get-info.js @@ -13,7 +13,7 @@ var http = require("http"); var https = require("https"); var domain = require("domain"); var Logger = require("./logger.js"); -var Media = require("./media.js").Media; +var Media = require("./media"); var CustomEmbedFilter = require("./customembed").filter; var Server = require("./server"); var Config = require("./config"); @@ -49,14 +49,8 @@ var Getters = { /* youtube.com */ yt: function (id, callback) { var sv = Server.getServer(); - /* - if (sv.cfg["enable-ytv3"] && sv.cfg["ytv3apikey"]) { - Getters["ytv3"](id, callback); - return; - } - */ - var m = id.match(/([\w-]+)/); + var m = id.match(/([\w-]{11})/); if (m) { id = m[1]; } else { @@ -73,33 +67,33 @@ var Getters = { timeout: 1000 }; - if(Config.get("youtube-v2-key")) { + if (Config.get("youtube-v2-key")) { options.headers = { "X-Gdata-Key": "key=" + Config.get("youtube-v2-key") }; } urlRetrieve(https, options, function (status, data) { - if(status === 404) { - callback("Video not found", null); - return; - } else if(status === 403) { - callback("Private video", null); - return; - } else if(status === 400) { - callback("Invalid video", null); - return; - } else if(status === 503) { - callback("API failure", null); - return; - } else if(status !== 200) { - callback("HTTP " + status, null); - return; + switch (status) { + case 200: + break; /* Request is OK, skip to handling data */ + case 400: + return callback("Invalid request", null); + case 403: + return callback("Private video", null); + case 404: + return callback("Video not found", null); + case 500: + case 503: + return callback("Service unavailable", null); + default: + return callback("HTTP " + status, null); } var buffer = data; try { data = JSON.parse(data); + /* Check for embedding restrictions */ if (data.entry.yt$accessControl) { var ac = data.entry.yt$accessControl; for (var i = 0; i < ac.length; i++) { @@ -115,26 +109,28 @@ var Getters = { var seconds = data.entry.media$group.yt$duration.seconds; var title = data.entry.title.$t; - var media = new Media(id, title, seconds, "yt"); + var meta = {}; + /* Check for country restrictions */ if (data.entry.media$group.media$restriction) { var rest = data.entry.media$group.media$restriction; if (rest.length > 0) { if (rest[0].relationship === "deny") { - media.restricted = rest[0].$t; + meta.restricted = rest[0].$t; } } } + var media = new Media(id, title, seconds, "yt", meta); callback(false, media); - } catch(e) { + } catch (e) { // Gdata version 2 has the rather silly habit of // returning error codes in XML when I explicitly asked // for JSON var m = buffer.match(/([^<]+)<\/internalReason>/); - if(m === null) + if (m === null) m = buffer.match(/([^<]+)<\/code>/); var err = e; - if(m) { + if (m) { if(m[1] === "too_many_recent_calls") { err = "YouTube is throttling the server right "+ "now for making too many requests. "+ @@ -149,65 +145,12 @@ var Getters = { }); }, - /* youtube.com API v3 (requires API key) */ - // DEPRECATED - ytv3: function (id, callback) { - var sv = Server.getServer(); - var m = id.match(/([\w-]+)/); - if (m) { - id = m[1]; - } else { - callback("Invalid ID", null); - return; - } - var params = [ - "part=" + encodeURIComponent("id,snippet,contentDetails"), - "id=" + id, - "key=" + sv.cfg["ytapikey"] - ].join("&"); - var options = { - host: "www.googleapis.com", - port: 443, - path: "/youtube/v3/videos?" + params, - method: "GET", - dataType: "jsonp", - timeout: 1000 - }; - - urlRetrieve(https, options, function (status, data) { - if(status !== 200) { - callback("YTv3: HTTP " + status, null); - return; - } - - try { - data = JSON.parse(data); - // I am a bit disappointed that the API v3 just doesn't - // return anything in any error case - if(data.pageInfo.totalResults !== 1) { - callback("Video not found", null); - return; - } - - var vid = data.items[0]; - var title = vid.snippet.title; - // No, it's not possible to get a number representing - // the video length. Instead, I get a time of the format - // PT#M#S which represents - // "Period of Time" # Minutes, # Seconds - var m = vid.contentDetails.duration.match(/PT(\d+)M(\d+)S/); - var seconds = parseInt(m[1]) * 60 + parseInt(m[2]); - var media = new Media(id, title, seconds, "yt"); - callback(false, media); - } catch(e) { - callback(e, null); - } - }); - }, - /* youtube.com playlists */ yp: function (id, callback, url) { - var sv = Server.getServer(); + /** + * NOTE: callback may be called multiple times, once for each <= 25 video + * batch of videos in the list. It will be called in order. + */ var m = id.match(/([\w-]+)/); if (m) { id = m[1]; @@ -216,11 +159,15 @@ var Getters = { return; } var path = "/feeds/api/playlists/" + id + "?v=2&alt=json"; - // YouTube only returns 25 at a time, so I have to keep asking - // for more with the URL they give me - if(url !== undefined) { + /** + * NOTE: the third parameter, url, is used to chain this retriever + * multiple times to get all the videos from a playlist, as each + * request only returns 25 videos. + */ + if (url !== undefined) { path = "/" + url.split("gdata.youtube.com")[1]; } + var options = { host: "gdata.youtube.com", port: 443, @@ -230,24 +177,27 @@ var Getters = { timeout: 1000 }; - if(Config.get("youtube-v2-key")) { + if (Config.get("youtube-v2-key")) { options.headers = { "X-Gdata-Key": "key=" + Config.get("youtube-v2-key") }; } urlRetrieve(https, options, function (status, data) { - if(status === 404) { - callback("Playlist not found", null); - return; - } else if(status === 403) { - callback("Playlist is private", null); - return; - } else if(status === 503) { - callback("API failure", null); - return; - } else if(status !== 200) { - callback("YTPlaylist HTTP " + status, null); + switch (status) { + case 200: + break; /* Request is OK, skip to handling data */ + case 400: + return callback("Invalid request", null); + case 403: + return callback("Private playlist", null); + case 404: + return callback("Playlist not found", null); + case 500: + case 503: + return callback("Service unavailable", null); + default: + return callback("HTTP " + status, null); } try { @@ -255,6 +205,10 @@ var Getters = { var vids = []; for(var i in data.feed.entry) { try { + /** + * FIXME: This should probably check for embed restrictions + * and country restrictions on each video in the list + */ var item = data.feed.entry[i]; var id = item.media$group.yt$videoid.$t; var title = item.title.$t; @@ -268,11 +222,13 @@ var Getters = { callback(false, vids); var links = data.feed.link; - for(var i in links) { - if(links[i].rel === "next") + for (var i in links) { + if (links[i].rel === "next") { + /* Look up the next batch of videos from the list */ Getters["yp"](id, callback, links[i].href); + } } - } catch(e) { + } catch (e) { callback(e, null); } @@ -281,9 +237,13 @@ var Getters = { /* youtube.com search */ ytSearch: function (terms, callback) { - var sv = Server.getServer(); - for(var i in terms) + /** + * terms is a list of words from the search query. Each word must be + * encoded properly for use in the request URI + */ + for (var i in terms) { terms[i] = encodeURIComponent(terms[i]); + } var query = terms.join("+"); var options = { @@ -295,15 +255,15 @@ var Getters = { timeout: 1000 }; - if(Config.get("youtube-v2-key")) { + if (Config.get("youtube-v2-key")) { options.headers = { "X-Gdata-Key": "key=" + Config.get("youtube-v2-key") }; } urlRetrieve(https, options, function (status, data) { - if(status !== 200) { - callback("YTSearch HTTP " + status, null); + if (status !== 200) { + callback("YouTube search: HTTP " + status, null); return; } @@ -312,6 +272,10 @@ var Getters = { var vids = []; for(var i in data.feed.entry) { try { + /** + * FIXME: This should probably check for embed restrictions + * and country restrictions on each video in the list + */ var item = data.feed.entry[i]; var id = item.media$group.yt$videoid.$t; var title = item.title.$t; @@ -354,18 +318,20 @@ var Getters = { }; urlRetrieve(https, options, function (status, data) { - if(status === 404) { - callback("Video not found", null); - return; - } else if(status === 403) { - callback("Private video", null); - return; - } else if(status === 503) { - callback("API failure", null); - return; - } else if(status !== 200) { - callback("YTv2 HTTP " + status, null); - return; + switch (status) { + case 200: + break; /* Request is OK, skip to handling data */ + case 400: + return callback("Invalid request", null); + case 403: + return callback("Private video", null); + case 404: + return callback("Video not found", null); + case 500: + case 503: + return callback("Service unavailable", null); + default: + return callback("HTTP " + status, null); } try { @@ -377,7 +343,11 @@ var Getters = { callback(false, media); } catch(e) { var err = e; - if(buffer.match(/not found/)) + /** + * This should no longer be necessary as the outer handler + * checks for HTTP 404 + */ + if (buffer.match(/not found/)) err = "Video not found"; callback(err, null); @@ -431,12 +401,6 @@ var Getters = { /* dailymotion.com */ dm: function (id, callback) { - // Dailymotion's API is an example of an API done right - // - Supports SSL - // - I can ask for exactly which fields I want - // - URL is simple - // - Field names are sensible - // Other media providers take notes, please var m = id.match(/([\w-]+)/); if (m) { id = m[1]; @@ -454,19 +418,31 @@ var Getters = { }; urlRetrieve(https, options, function (status, data) { - if (status === 404) { - callback("Video not found", null); - return; - } else if (status !== 200) { - callback("DM HTTP " + status, null); - return; + switch (status) { + case 200: + break; /* Request is OK, skip to handling data */ + case 400: + return callback("Invalid request", null); + case 403: + return callback("Private video", null); + case 404: + return callback("Video not found", null); + case 500: + case 503: + return callback("Service unavailable", null); + default: + return callback("HTTP " + status, null); } try { data = JSON.parse(data); var title = data.title; var seconds = data.duration; - if(title === "Deleted video" && seconds === 10) { + /** + * This is a rather hacky way to indicate that a video has + * been deleted... + */ + if (title === "Deleted video" && seconds === 10) { callback("Video not found", null); return; } @@ -480,12 +456,7 @@ var Getters = { /* soundcloud.com */ sc: function (id, callback) { - // Soundcloud's API is badly designed and badly documented - // In order to lookup track data from a URL, I have to first - // make a call to /resolve to get the track id, then make a second - // call to /tracks/{track.id} to actally get useful data - // This is a waste of bandwidth and a pain in the ass - + /* TODO: require server owners to register their own API key, put in config */ const SC_CLIENT = "2e0c82ab5a020f3a7509318146128abd"; var m = id.match(/([\w-\/\.:]+)/); @@ -506,15 +477,20 @@ var Getters = { }; urlRetrieve(https, options, function (status, data) { - if(status === 404) { - callback("Sound not found", null); - return; - } else if(status === 503) { - callback("API failure", null); - return; - } else if(status !== 302) { - callback("SC HTTP " + status, null); - return; + switch (status) { + case 200: + break; /* Request is OK, skip to handling data */ + case 400: + return callback("Invalid request", null); + case 403: + return callback("Private sound", null); + case 404: + return callback("Sound not found", null); + case 500: + case 503: + return callback("Service unavailable", null); + default: + return callback("HTTP " + status, null); } var track = null; @@ -535,16 +511,30 @@ var Getters = { timeout: 1000 }; - // I want to get off async's wild ride + /** + * There has got to be a way to directly get the data I want without + * making two requests to Soundcloud...right? + * ...right? + */ urlRetrieve(https, options2, function (status, data) { - if(status !== 200) { - callback("SC HTTP " + status, null); - return; + switch (status) { + case 200: + break; /* Request is OK, skip to handling data */ + case 400: + return callback("Invalid request", null); + case 403: + return callback("Private sound", null); + case 404: + return callback("Sound not found", null); + case 500: + case 503: + return callback("Service unavailable", null); + default: + return callback("HTTP " + status, null); } try { data = JSON.parse(data); - // Duration is in ms, but I want s var seconds = data.duration / 1000; var title = data.title; var media = new Media(id, title, seconds, "sc"); @@ -601,11 +591,13 @@ var Getters = { /* ustream.tv */ us: function (id, callback) { - // 2013-09-17 - // They couldn't fucking decide whether channels should - // be at http://www.ustream.tv/channel/foo or just - // http://www.ustream.tv/foo so they do both. - // [](/cleese) + /** + *2013-09-17 + * They couldn't fucking decide whether channels should + * be at http://www.ustream.tv/channel/foo or just + * http://www.ustream.tv/foo so they do both. + * [](/cleese) + */ var m = id.match(/([^\?&#]+)|(channel\/[^\?&#]+)/); if (m) { id = m[1]; @@ -613,6 +605,7 @@ var Getters = { callback("Invalid ID", null); return; } + var options = { host: "www.ustream.tv", port: 80, @@ -627,12 +620,14 @@ var Getters = { return; } - // Regexing the ID out of the HTML because - // Ustream's API is so horribly documented - // I literally could not figure out how to retrieve - // this information. - // - // [](/eatadick) + /** + * Regexing the ID out of the HTML because + * Ustream's API is so horribly documented + * I literally could not figure out how to retrieve + * this information. + * + * [](/eatadick) + */ var m = data.match(/cid":([0-9]+)/); if(m) { var title = "Ustream.tv - " + id; @@ -660,6 +655,9 @@ var Getters = { /* imgur.com albums */ im: function (id, callback) { + /** + * TODO: Consider deprecating this in favor of custom embeds + */ var m = id.match(/([\w-]+)/); if (m) { id = m[1]; @@ -681,6 +679,7 @@ var Getters = { /* google docs */ gd: function (id, callback) { + /* WARNING: hacks inbound */ var options = { host: "docs.google.com", path: "/file/d/" + id + "/edit", @@ -688,9 +687,20 @@ var Getters = { }; urlRetrieve(https, options, function (status, res) { - if (status !== 200) { - callback("Google Docs rejected: HTTP " + status, false); - return; + switch (status) { + case 200: + break; /* Request is OK, skip to handling data */ + case 400: + return callback("Invalid request", null); + case 403: + return callback("Private video", null); + case 404: + return callback("Video not found", null); + case 500: + case 503: + return callback("Service unavailable", null); + default: + return callback("HTTP " + status, null); } var m = res.match(/main\((.*?)\);<\/script>/); @@ -699,6 +709,7 @@ var Getters = { var data = m[1]; data = data.substring(data.indexOf(",") + 1); data = data.replace(/'(.*?)'([:\,\}\]])/g, "\"$1\"$2"); + /* Fixes an issue with certain characters represented as \xkk */ data = data.replace(/\\x(\d*)/g, function (sub, s1) { return String.fromCharCode(parseInt(s1, 16)); }); @@ -706,8 +717,7 @@ var Getters = { var js = JSON.parse(data); var title = js[0].title; var seconds = js[1].videodetails.duration / 1000; - var med = new Media(id, title, seconds, "gd"); - + var meta = {}; var fv = js[1].videoplay.flashVars; var fvstr = ""; for (var k in fv) { @@ -718,7 +728,7 @@ var Getters = { fvstr = fvstr.substring(1); var url = js[1].videoplay.swfUrl + "&enablejsapi=1"; - med.object = { + meta.object = { type: "application/x-shockwave-flash", allowscriptaccess: "always", allowfullscreen: "true", @@ -726,7 +736,7 @@ var Getters = { data: url }; - med.params = [ + meta.params = [ { name: "allowFullScreen", value: "true" @@ -745,6 +755,8 @@ var Getters = { } ]; + var med = new Media(id, title, seconds, "gd", meta); + callback(false, med); } catch (e) { callback("Parsing of Google Docs output failed", null); @@ -780,7 +792,8 @@ function vimeoWorkaround(id, cb) { var parse = function (data) { var i = data.indexOf("{\"cdn_url\""); if (i === -1) { - Logger.errlog.log("Vimeo workaround failed (i=-1): http://vimeo.com/" + id); + /* TODO possibly send an error message? */ + //Logger.errlog.log("Vimeo workaround failed (i=-1): http://vimeo.com/" + id); setImmediate(function () { cb({}); }); @@ -798,10 +811,10 @@ function vimeoWorkaround(id, cb) { } catch (e) { // This shouldn't happen due to the user-agent, but just in case if (data.indexOf("crawler") !== -1) { - Logger.syslog.log("Warning: VimeoIsADoucheCopter got crawler response"); + Logger.syslog.log("Warning: vimdeoWorkaround got crawler response"); failcount++; if (failcount > 4) { - Logger.errlog.log("VimeoIsADoucheCopter got bad response 5 times!"+ + Logger.errlog.log("vimeoWorkaround got bad response 5 times!"+ " Giving up."); setImmediate(function () { cb({}); diff --git a/lib/io/ioserver.js b/lib/io/ioserver.js index 04babb41..aa327ed6 100644 --- a/lib/io/ioserver.js +++ b/lib/io/ioserver.js @@ -6,6 +6,11 @@ var User = require("../user"); var Server = require("../server"); var Config = require("../config"); var $util = require("../utilities"); +var Flags = require("../flags"); +var Account = require("../account"); +var typecheck = require("json-typecheck"); +var net = require("net"); +var util = require("../utilities"); var CONNECT_RATE = { burst: 5, @@ -42,8 +47,13 @@ function handleAuth(data, accept) { * Called after a connection is accepted */ function handleConnection(sock) { - sock._ip = sock.handshake.address.address; - var ip = sock._ip; + var ip = sock.handshake.address.address; + var longip = ip; + sock._ip = ip; + if (net.isIPv6(ip)) { + longip = util.expandIPv6(ip); + } + sock._longip = longip; var srv = Server.getServer(); if (srv.torblocker && srv.torblocker.shouldBlockIP(ip)) { sock.emit("kick", { @@ -69,13 +79,12 @@ function handleConnection(sock) { } // Check for global ban on the IP - db.isGlobalIPBanned(ip, function (err, banned) { - if (banned) { - Logger.syslog.log("Disconnecting " + ip + " - global banned"); - sock.emit("kick", { reason: "Your IP is globally banned." }); - sock.disconnect(true); - } - }); + if (db.isGlobalIPBanned(ip)) { + Logger.syslog.log("Rejecting " + ip + " - global banned"); + sock.emit("kick", { reason: "Your IP is globally banned." }); + sock.disconnect(true); + return; + } sock.on("disconnect", function () { ipCount[ip]--; @@ -99,21 +108,61 @@ function handleConnection(sock) { } Logger.syslog.log("Accepted socket from " + ip); - var user = new User(sock); - if (sock.handshake && sock.handshake.user) { - user.name = sock.handshake.user.name; - user.global_rank = sock.handshake.user.global_rank; - user.loggedIn = true; - user.emit("login"); - user.socket.emit("login", { - success: true, - name: user.name, - guest: false + + sock.typecheckedOn = function (msg, template, cb) { + sock.on(msg, function (data) { + typecheck(data, template, function (err, data) { + if (err) { + sock.emit("errorMsg", { + msg: "Unexpected error for message " + msg + ": " + err.message + }); + } else { + cb(data); + } + }); + }); + }; + + sock.typecheckedOnce = function (msg, template, cb) { + sock.once(msg, function (data) { + typecheck(data, template, function (err, data) { + if (err) { + sock.emit("errorMsg", { + msg: "Unexpected error for message " + msg + ": " + err.message + }); + } else { + cb(data); + } + }); + }); + }; + + var user = new User(sock); + if (sock.handshake.user) { + user.setFlag(Flags.U_REGISTERED); + user.clearFlag(Flags.U_READY); + user.refreshAccount({ name: sock.handshake.user.name }, + function (err, account) { + if (err) { + user.clearFlag(Flags.U_REGISTERED); + user.setFlag(Flags.U_READY); + return; + } + user.socket.emit("login", { + success: true, + name: user.getName(), + guest: false + }); + db.recordVisit(ip, user.getName()); + user.socket.emit("rank", user.account.effectiveRank); + user.setFlag(Flags.U_LOGGED_IN); + user.emit("login", account); + Logger.syslog.log(ip + " logged in as " + user.getName()); + user.setFlag(Flags.U_READY); }); - user.socket.emit("rank", user.global_rank); - Logger.syslog.log(ip + " logged in as " + user.name); } else { user.socket.emit("rank", -1); + user.setFlag(Flags.U_READY); } } @@ -133,15 +182,30 @@ module.exports = { if (id in srv.servers) { io = srv.ioServers[id] = sio.listen(srv.servers[id]); } else { - io = srv.ioServers[id] = sio.listen(bind.port, bind.ip); + if (net.isIPv6(bind.ip) || bind.ip === "::") { + /** + * Socket.IO won't bind to a v6 address natively. + * Instead, we have to create a node HTTP server, bind it + * to the desired address, then have socket.io listen on it + */ + io = srv.ioServers[id] = sio.listen( + require("http").createServer().listen(bind.port, bind.ip) + ); + } else { + io = srv.ioServers[id] = sio.listen(bind.port, bind.ip); + } } - + if (io) { io.set("log level", 1); io.set("authorization", handleAuth); io.on("connection", handleConnection); } }); + + sio.ioServers = Object.keys(srv.ioServers) + .filter(Object.hasOwnProperty.bind(srv.ioServers)) + .map(function (k) { return srv.ioServers[k] }); } }; diff --git a/lib/media.js b/lib/media.js index d394e46e..4d56c436 100644 --- a/lib/media.js +++ b/lib/media.js @@ -1,90 +1,59 @@ -/* -The MIT License (MIT) -Copyright (c) 2013 Calvin Montgomery +var util = require("./utilities"); -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +function Media(id, title, seconds, type, meta) { + if (!meta) { + meta = {}; + } -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -*/ - -var formatTime = require("./utilities").formatTime; - -// Represents a media entry -var Media = function(id, title, seconds, type) { this.id = id; this.title = title; - if(this.title.length > 100) + if (this.title.length > 100) { this.title = this.title.substring(0, 97) + "..."; - this.seconds = seconds == "--:--" ? "--:--" : parseInt(seconds); - this.duration = formatTime(this.seconds); - if(seconds == "--:--") { - this.seconds = 0; } + + this.seconds = seconds === "--:--" ? 0 : parseInt(seconds); + this.duration = util.formatTime(seconds); this.type = type; + this.meta = meta; + this.currentTime = 0; + this.paused = false; } -Media.prototype.dup = function() { - var m = new Media(this.id, this.title, this.seconds, this.type); - return m; -} +Media.prototype = { + pack: function () { + return { + id: this.id, + title: this.title, + seconds: this.seconds, + duration: this.duration, + type: this.type, + meta: { + object: this.meta.object, + params: this.meta.params, + direct: this.meta.direct, + restricted: this.meta.restricted + } + }; + }, -// Returns an object containing the data in this Media but not the -// prototype -Media.prototype.pack = function() { - var x = { - id: this.id, - title: this.title, - seconds: this.seconds, - duration: this.duration, - type: this.type, - }; + getTimeUpdate: function () { + return { + currentTime: this.currentTime, + paused: this.paused + }; + }, - if (this.object) { - x.object = this.object; - } - if (this.params) { - x.params = this.params; - } - return x; -} + getFullUpdate: function () { + var packed = this.pack(); + packed.currentTime = this.currentTime; + packed.paused = this.paused; + return packed; + }, -// Same as pack() but includes the currentTime variable set by the channel -// when the media is being synchronized -Media.prototype.fullupdate = function() { - var x = { - id: this.id, - title: this.title, - seconds: this.seconds, - duration: this.duration, - type: this.type, - currentTime: this.currentTime, - paused: this.paused, - }; - if (this.object) { - x.object = this.object; + reset: function () { + this.currentTime = 0; + this.paused = false; } - if (this.params) { - x.params = this.params; - } - if (this.direct) { - x.direct = this.direct; - } - return x; -} - -Media.prototype.timeupdate = function() { - //return this.fullupdate(); - return { - currentTime: this.currentTime, - paused: this.paused - }; -} - -Media.prototype.reset = function () { - delete this.currentTime; - delete this.direct; }; -exports.Media = Media; +module.exports = Media; diff --git a/lib/notwebsocket.js b/lib/notwebsocket.js deleted file mode 100644 index 7c88e305..00000000 --- a/lib/notwebsocket.js +++ /dev/null @@ -1,195 +0,0 @@ -/* -The MIT License (MIT) -Copyright (c) 2013 Calvin Montgomery - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -*/ - -var Logger = require("./logger"); - -const chars = "abcdefghijklmnopqsrtuvwxyz" + - "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + - "0123456789"; - -var NotWebsocket = function() { - this.hash = ""; - for(var i = 0; i < 30; i++) { - this.hash += chars[parseInt(Math.random() * (chars.length - 1))]; - } - - this.pktqueue = []; - this.handlers = {}; - this.room = ""; - this.lastpoll = Date.now(); - this.noflood = {}; -} - -NotWebsocket.prototype.checkFlood = function(id, rate) { - if(id in this.noflood) { - this.noflood[id].push(Date.now()); - } - else { - this.noflood[id] = [Date.now()]; - } - if(this.noflood[id].length > 10) { - this.noflood[id].shift(); - var hz = 10000 / (this.noflood[id][9] - this.noflood[id][0]); - if(hz > rate) { - throw "Rate is too high: " + id; - } - } -} - -NotWebsocket.prototype.emit = function(msg, data) { - var pkt = [msg, data]; - this.pktqueue.push(pkt); -} - -NotWebsocket.prototype.poll = function() { - this.checkFlood("poll", 100); - this.lastpoll = Date.now(); - var q = []; - for(var i = 0; i < this.pktqueue.length; i++) { - q.push(this.pktqueue[i]); - } - this.pktqueue.length = 0; - return q; -} - -NotWebsocket.prototype.on = function(msg, callback) { - if(!(msg in this.handlers)) - this.handlers[msg] = []; - this.handlers[msg].push(callback); -} - -NotWebsocket.prototype.recv = function(urlstr) { - this.checkFlood("recv", 100); - var msg, data; - try { - var js = JSON.parse(urlstr); - msg = js[0]; - data = js[1]; - } - catch(e) { - Logger.errlog.log("Failed to parse NWS string"); - Logger.errlog.log(urlstr); - } - if(!msg) - return; - if(!(msg in this.handlers)) - return; - for(var i = 0; i < this.handlers[msg].length; i++) { - this.handlers[msg][i](data); - } -} - -NotWebsocket.prototype.join = function(rm) { - if(!(rm in rooms)) { - rooms[rm] = []; - } - - rooms[rm].push(this); -} - -NotWebsocket.prototype.leave = function(rm) { - if(rm in rooms) { - var idx = rooms[rm].indexOf(this); - if(idx >= 0) { - rooms[rm].splice(idx, 1); - } - } -} - -NotWebsocket.prototype.disconnect = function() { - for(var rm in rooms) { - this.leave(rm); - } - - this.recv(JSON.stringify(["disconnect", undefined])); - this.emit("disconnect"); - - clients[this.hash] = null; - delete clients[this.hash]; -} - -function sendJSON(res, obj) { - var response = JSON.stringify(obj, null, 4); - if(res.callback) { - response = res.callback + "(" + response + ")"; - } - var len = unescape(encodeURIComponent(response)).length; - - res.setHeader("Content-Type", "application/json"); - res.setHeader("Content-Length", len); - res.end(response); -} - -var clients = {}; -var rooms = {}; - -function newConnection(req, res) { - var nws = new NotWebsocket(); - clients[nws.hash] = nws; - res.callback = req.query.callback; - sendJSON(res, nws.hash); - return nws; -} -exports.newConnection = newConnection; - -function msgReceived(req, res) { - res.callback = req.query.callback; - var h = req.params.hash; - if(h in clients && clients[h] != null) { - var str = req.params.str; - res.callback = req.query.callback; - try { - if(str == "poll") { - sendJSON(res, clients[h].poll()); - } - else { - clients[h].recv(decodeURIComponent(str)); - sendJSON(res, ""); - } - } - catch(e) { - res.send(429); // 429 Too Many Requests - } - } - else { - res.send(404); - } -} -exports.msgReceived = msgReceived; - -function inRoom(rm) { - var cl = []; - - if(rm in rooms) { - for(var i = 0; i < rooms[rm].length; i++) { - cl.push(rooms[rm][i]); - } - } - - cl.emit = function(msg, data) { - for(var i = 0; i < this.length; i++) { - this[i].emit(msg, data); - } - }; - - return cl; -} -exports.inRoom = inRoom; - -function checkDeadSockets() { - for(var h in clients) { - if(Date.now() - clients[h].lastpoll >= 2000) { - clients[h].disconnect(); - } - } -} - -setInterval(checkDeadSockets, 2000); diff --git a/lib/playlist.js b/lib/playlist.js deleted file mode 100644 index 65c2865d..00000000 --- a/lib/playlist.js +++ /dev/null @@ -1,458 +0,0 @@ -/* -The MIT License (MIT) -Copyright (c) 2013 Calvin Montgomery - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -*/ - -var ULList = require("./ullist").ULList; -var AsyncQueue = require("./asyncqueue"); -var Media = require("./media").Media; -var util = require("./utilities"); -var vimeoWorkaround = require("./get-info").vimeoWorkaround; -var Config = require("./config"); - -function PlaylistItem(media, uid) { - this.media = media; - this.uid = uid; - this.temp = false; - this.queueby = ""; - this.prev = null; - this.next = null; -} - -PlaylistItem.prototype.pack = function() { - return { - media: this.media.pack(), - uid: this.uid, - temp: this.temp, - queueby: this.queueby - }; -} - -function Playlist(chan) { - var name = chan.uniqueName; - this.items = new ULList(); - this.current = null; - this.next_uid = 0; - this._leadInterval = false; - this._lastUpdate = 0; - this._counter = 0; - this.leading = true; - this.callbacks = { - "changeMedia": [], - "mediaUpdate": [], - "remove": [], - }; - this.fnqueue = new AsyncQueue(); - - this.channel = chan; - this.server = chan.server; - var pl = this; - this.on("mediaUpdate", function(m) { - if (chan.dead) { - pl.die(); - return; - } - chan.sendAll("mediaUpdate", m.timeupdate()); - }); - this.on("changeMedia", function(m) { - if (chan.dead) { - pl.die(); - return; - } - chan.resetVideo(); - chan.sendAll("setCurrent", pl.current.uid); - chan.sendAll("changeMedia", m.fullupdate()); - chan.logger.log("[playlist] Now playing: " + m.title + " (" + m.type + ":" + m.id + - ")"); - }); - this.on("remove", function(item) { - if (chan.dead) { - pl.die(); - return; - } - chan.updatePlaylistMeta(); - chan.sendPlaylistMeta(chan.users); - chan.sendAll("delete", { - uid: item.uid - }); - }); -} - -Playlist.prototype.dump = function() { - var arr = this.items.toArray(); - var pos = 0; - for(var i in arr) { - if(this.current && arr[i].uid == this.current.uid) { - pos = i; - break; - } - } - - var time = 0; - if(this.current) - time = this.current.media.currentTime; - - return { - pl: arr, - pos: pos, - time: time - }; -} - -Playlist.prototype.die = function () { - this.clear(); - if(this._leadInterval) { - clearInterval(this._leadInterval); - this._leadInterval = false; - } - if(this._qaInterval) { - clearInterval(this._qaInterval); - this._qaInterval = false; - } - //for(var key in this) - // delete this[key]; - this.dead = true; -} - -Playlist.prototype.load = function(data, callback) { - this.clear(); - for(var i in data.pl) { - var e = data.pl[i].media; - var m = new Media(e.id, e.title, e.seconds, e.type); - m.object = e.object; - m.params = e.params; - var it = this.makeItem(m); - it.temp = data.pl[i].temp; - it.queueby = data.pl[i].queueby; - this.items.append(it); - if(i == parseInt(data.pos)) { - this.current = it; - } - } - - if(callback) - callback(); -} - -Playlist.prototype.on = function(ev, fn) { - if(typeof fn === "undefined") { - var pl = this; - return function() { - for(var i = 0; i < pl.callbacks[ev].length; i++) { - pl.callbacks[ev][i].apply(this, arguments); - } - } - } - else if(typeof fn === "function") { - this.callbacks[ev].push(fn); - } -} - -Playlist.prototype.makeItem = function(media) { - return new PlaylistItem(media, this.next_uid++); -} - -Playlist.prototype.add = function(item, pos) { - var self = this; - if(this.items.length >= 4000) { - return "Playlist limit reached (4,000)"; - } - - var it = this.items.findVideoId(item.media.id); - if(it) { - if(pos === "append" || it == this.current) { - return "This item is already on the playlist"; - } - - self.remove(it.uid); - self.channel.sendAll("delete", { - uid: it.uid - }); - self.channel.updatePlaylistMeta(); - self.channel.sendPlaylistMeta(self.channel.users); - } - - if(pos == "append") { - if(!this.items.append(item)) { - return "Playlist failure"; - } - } else if(pos == "prepend") { - if(!this.items.prepend(item)) { - return "Playlist failure"; - } - } else { - if(!this.items.insertAfter(item, pos)) { - return "Playlist failure"; - } - } - - if(this.items.length == 1) { - this.current = item; - this.startPlayback(); - } - - return false; -} - -Playlist.prototype.addMedia = function (data) { - var pos = data.pos; - if (pos === "next") { - if (this.current !== null) - pos = this.current.uid; - else - pos = "append"; - } - - var m = new Media(data.id, data.title, data.seconds, data.type); - m.object = data.object; - m.params = data.params; - var item = this.makeItem(m); - item.queueby = data.queueby; - item.temp = data.temp; - return { - item: item, - error: this.add(item, pos) - }; -}; - -Playlist.prototype.remove = function (uid) { - var self = this; - var item = self.items.find(uid); - if (item && self.items.remove(uid)) { - if (item === self.current) { - self._next(); - } - return true; - } else { - return false; - } -} - -Playlist.prototype.move = function (from, after) { - var it = this.items.find(from); - if (!this.items.remove(from)) - return false; - - if (after === "prepend") { - if (!this.items.prepend(it)) - return false; - } else if (after === "append") { - if (!this.items.append(it)) - return false; - } else if (!this.items.insertAfter(it, after)) { - return false; - } - - return true; -} - -Playlist.prototype.next = function() { - if(!this.current) - return; - - var it = this.current; - - if (it.temp) { - if (this.remove(it.uid)) { - this.on("remove")(it); - } - } else { - this._next(); - } - - return this.current; -} - -Playlist.prototype._next = function() { - if(!this.current) - return; - this.current = this.current.next; - if(this.current === null && this.items.first !== null) - this.current = this.items.first; - - if(this.current) { - this.startPlayback(); - } -} - -Playlist.prototype.jump = function(uid) { - if(!this.current) - return false; - - var jmp = this.items.find(uid); - if(!jmp) - return false; - - var it = this.current; - - this.current = jmp; - - if(this.current) { - this.startPlayback(); - } - - if(it.temp) { - if (this.remove(it.uid)) { - this.on("remove")(it); - } - } - - return this.current; -} - -Playlist.prototype.clear = function() { - this.items.clear(); - this.next_uid = 0; - this.current = null; - clearInterval(this._leadInterval); -} - -Playlist.prototype.count = function (id) { - var count = 0; - this.items.forEach(function (i) { - if(i.media.id === id) - count++; - }); - return count; -} - -Playlist.prototype.getFullUpdate = function () { - if (!this.current) { - return null; - } else if (!this.current.media) { - return null; - } else { - return this.current.media.fullupdate(); - } -}; - -Playlist.prototype.lead = function(lead) { - this.leading = lead; - var pl = this; - if(!this.leading && this._leadInterval) { - clearInterval(this._leadInterval); - this._leadInterval = false; - } - else if(this.leading && !this._leadInterval) { - this._lastUpdate = Date.now(); - this._leadInterval = setInterval(function() { - pl._leadLoop(); - }, 1000); - } -} - -Playlist.prototype.startPlayback = function (time) { - var self = this; - - if(!self.current || !self.current.media) - return false; - - if (self.current.media.type === "vi" && - !self.current.media.direct && - Config.get("vimeo-workaround")) { - vimeoWorkaround(self.current.media.id, function (direct) { - if (self.current != null && self.current.media != null) { - self.current.media.direct = direct; - self.startPlayback(time); - } - }); - return; - } - - if (!self.leading) { - self.current.media.paused = false; - self.current.media.currentTime = time || 0; - self.on("changeMedia")(self.current.media); - return; - } - - time = time || -3; - self.current.media.paused = time < 0; - self.current.media.currentTime = time; - - if(self._leadInterval) { - clearInterval(self._leadInterval); - self._leadInterval = false; - } - self.on("changeMedia")(self.current.media); - if(self.current.media.seconds > 0) { - self._lastUpdate = Date.now(); - self._leadInterval = setInterval(function() { - self._leadLoop(); - }, 1000); - } -} - -const UPDATE_INTERVAL = 5; - -Playlist.prototype._leadLoop = function() { - if(this.current == null) - return; - - if(this.channel.name == "") { - this.die(); - return; - } - - var dt = (Date.now() - this._lastUpdate) / 1000.0; - var t = this.current.media.currentTime; - - // Transition from lead-in - if (t < 0 && (t + dt) >= 0) { - this.current.media.currentTime = 0; - this.current.media.paused = false; - this._counter = 0; - this._lastUpdate = Date.now(); - this.on("mediaUpdate")(this.current.media); - return; - } - - this.current.media.currentTime += dt; - this._lastUpdate = Date.now(); - this._counter++; - - if(this.current.media.currentTime >= this.current.media.seconds + 2) { - this.next(); - } - else if(this._counter % UPDATE_INTERVAL == 0) { - this.on("mediaUpdate")(this.current.media); - } -} - -/* - Delete items from the playlist for which filter(item) returns - a truthy value - - based on code contributed by http://github.com/unbibium -*/ -Playlist.prototype.clean = function (filter) { - var self = this; - var matches = self.items.findAll(filter); - var count = 0; - var deleteNext = function () { - if (count < matches.length) { - var uid = matches[count].uid; - count++; - if (self.remove(uid)) { - self.channel.sendAll("delete", { - uid: uid - }); - } - deleteNext(); - } else { - // refresh meta only once, at the end - self.channel.updatePlaylistMeta(); - self.channel.sendPlaylistMeta(self.channel.users); - } - }; - // start initial callback - deleteNext(); -}; - -module.exports = Playlist; diff --git a/lib/server.js b/lib/server.js index c04e3f71..81535558 100644 --- a/lib/server.js +++ b/lib/server.js @@ -9,7 +9,7 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -const VERSION = "3.0.3"; +const VERSION = "3.1.0"; var singleton = null; var Config = require("./config"); @@ -40,10 +40,11 @@ var http = require("http"); var https = require("https"); var express = require("express"); var Logger = require("./logger"); -var Channel = require("./channel"); +var Channel = require("./channel/channel"); var User = require("./user"); var $util = require("./utilities"); var db = require("./database"); +var Flags = require("./flags"); var Server = function () { var self = this; @@ -167,14 +168,16 @@ Server.prototype.unloadChannel = function (chan) { return; } - if (chan.registered) { - chan.saveState(); - } + chan.saveState(); chan.logger.log("[init] Channel shutting down"); - - chan.playlist.die(); chan.logger.close(); + + chan.notifyModules("unload", []); + Object.keys(chan.modules).forEach(function (k) { + chan.modules[k].dead = true; + }); + for (var i = 0; i < this.channels.length; i++) { if (this.channels[i].uniqueName === chan.uniqueName) { this.channels.splice(i, 1); @@ -197,29 +200,31 @@ Server.prototype.packChannelList = function (publicOnly) { return true; } - return c.opts.show_public; + return c.modules.options && c.modules.options.get("show_public"); }); return channels.map(this.packChannel.bind(this)); }; Server.prototype.packChannel = function (c) { + var opts = c.modules.options; + var pl = c.modules.playlist; + var chat = c.modules.chat; var data = { name: c.name, - pagetitle: c.opts.pagetitle, - mediatitle: c.playlist.current ? c.playlist.current.media.title : "-", + pagetitle: opts.pagetitle ? opts.pagetitle : c.name, + mediatitle: pl && pl.current ? pl.current.media.title : "-", usercount: c.users.length, - voteskip_eligible: c.calcVoteskipMax(), users: [], - chat: Array.prototype.slice.call(c.chatbuffer), - registered: c.registered, - public: c.opts.show_public + chat: chat ? Array.prototype.slice.call(chat.buffer) : [], + registered: c.is(Flags.C_REGISTERED), + public: opts && opts.get("show_public") }; for (var i = 0; i < c.users.length; i++) { if (c.users[i].name !== "") { - var name = c.users[i].name; - var rank = c.users[i].rank; + var name = c.users[i].getName(); + var rank = c.users[i].account.effectiveRank; if (rank >= 255) { name = "!" + name; } else if (rank >= 4) { @@ -252,7 +257,7 @@ Server.prototype.announce = function (data) { Server.prototype.shutdown = function () { Logger.syslog.log("Unloading channels"); for (var i = 0; i < this.channels.length; i++) { - if (this.channels[i].registered) { + if (this.channels[i].is(Flags.C_REGISTERED)) { Logger.syslog.log("Saving /r/" + this.channels[i].name); this.channels[i].saveState(); } diff --git a/lib/ullist.js b/lib/ullist.js index 5b018792..47cd50c3 100644 --- a/lib/ullist.js +++ b/lib/ullist.js @@ -191,4 +191,4 @@ ULList.prototype.findAll = function(fn) { return result; } -exports.ULList = ULList; +module.exports = ULList; diff --git a/lib/user.js b/lib/user.js index 8758feed..248b4006 100644 --- a/lib/user.js +++ b/lib/user.js @@ -6,51 +6,61 @@ var db = require("./database"); var InfoGetter = require("./get-info"); var Config = require("./config"); var ACP = require("./acp"); +var Account = require("./account"); +var Flags = require("./flags"); function User(socket) { var self = this; MakeEmitter(self); + self.flags = 0; self.socket = socket; self.ip = socket._ip; - self.loggedIn = false; - self.loggingIn = false; - self.rank = -1; - self.global_rank = -1; - self.hasChannelRank = false; + self.longip = socket._longip; + self.account = Account.default(self.longip); self.channel = null; - self.name = ""; - self.canonicalName = ""; - self.profile = { - image: "", - text: "" - }; - self.meta = { - afk: false, - muted: false, - smuted: false, - aliases: [] - }; self.queueLimiter = util.newRateLimiter(); self.chatLimiter = util.newRateLimiter(); self.awaytimer = false; - self.socket.once("initChannelCallbacks", function () { - self.initChannelCallbacks(); - }); + var announcement = Server.getServer().announcement; + if (announcement != null) { + self.socket.emit("announcement", announcement); + } - self.socket.once("initUserPLCallbacks", function () { - self.initUserPLCallbacks(); + self.socket.once("joinChannel", function (data) { + if (typeof data !== "object" || typeof data.name !== "string") { + return; + } + + if (self.inChannel()) { + return; + } + + if (!util.isValidChannelName(data.name)) { + self.socket.emit("errorMsg", { + msg: "Invalid channel name. Channel names may consist of 1-30 " + + "characters in the set a-z, A-Z, 0-9, -, and _" + }); + self.kick("Invalid channel name"); + return; + } + + data.name = data.name.toLowerCase(); + self.waitFlag(Flags.U_READY, function () { + var chan = Server.getServer().getChannel(data.name); + chan.joinUser(self, data); + }); }); self.socket.once("initACP", function () { - self.whenLoggedIn(function () { - if (self.global_rank >= 255) { + self.waitFlag(Flags.U_LOGGED_IN, function () { + if (self.account.globalRank >= 255) { ACP.init(self); } else { self.kick("Attempted initACP from non privileged user. This incident " + "will be reported."); Logger.eventlog.log("[acp] Attempted initACP from socket client " + - self.name + "@" + self.ip); + self.getName() + "@" + self.ip); } }); }); @@ -68,87 +78,129 @@ function User(socket) { pw = ""; } - if (!pw && !self.loggingIn && !self.loggedIn) { + if (self.is(Flags.U_LOGGING_IN) || self.is(Flags.U_LOGGED_IN)) { + return; + } + + if (!pw) { self.guestLogin(name); - } else if (pw && !self.loggingIn && !self.loggedIn) { + } else { self.login(name, pw); } }); - var announcement = Server.getServer().announcement; - if (announcement != null) { - self.socket.emit("announcement", announcement); - } - - self.on("login", function () { - db.recordVisit(self.ip, self.name); - db.users.getProfile(self.name, function (err, profile) { - if (!err) { - self.profile = profile; - if (self.inChannel()) { - self.channel.sendUserProfile(self.channel.users, self); - } - } - }); - - if (self.global_rank >= 255) { + self.on("login", function (account) { + if (account.globalRank >= 255) { self.initAdminCallbacks(); } }); } -/** - * Checks whether the user is in a valid channel - */ +User.prototype.die = function () { + for (var key in this.socket._events) { + delete this.socket._events[key]; + } + + delete this.socket.typecheckedOn; + delete this.socket.typecheckedOnce; + + for (var key in this.__evHandlers) { + delete this.__evHandlers[key]; + } + + if (this.awaytimer) { + clearTimeout(this.awaytimer); + } + + this.dead = true; +}; + +User.prototype.is = function (flag) { + return Boolean(this.flags & flag); +}; + +User.prototype.setFlag = function (flag) { + this.flags |= flag; + this.emit("setFlag", flag); +}; + +User.prototype.clearFlag = function (flag) { + this.flags &= ~flag; + this.emit("clearFlag", flag); +}; + +User.prototype.waitFlag = function (flag, cb) { + var self = this; + if (self.is(flag)) { + cb(); + } else { + var wait = function (f) { + if ((f & flag) === flag) { + self.unbind("setFlag", wait); + cb(); + } + }; + self.on("setFlag", wait); + } +}; + +User.prototype.getName = function () { + return this.account.name; +}; + +User.prototype.getLowerName = function () { + return this.account.lowername; +}; + User.prototype.inChannel = function () { return this.channel != null && !this.channel.dead; }; -/** - * Changes a user's AFK status, updating the channel voteskip if necessary - */ +/* Called when a user's AFK status changes */ User.prototype.setAFK = function (afk) { if (!this.inChannel()) { return; } - if (this.meta.afk === afk) { + /* No change in AFK status, don't need to change anything */ + if (this.is(Flags.U_AFK) === afk) { return; } - this.meta.afk = afk; - - var chan = this.channel; if (afk) { - if (chan.voteskip) { - chan.voteskip.unvote(this.ip); + this.setFlag(Flags.U_AFK); + if (this.channel.voteskip) { + this.channel.voteskip.unvote(this.ip); } } else { + this.clearFlag(Flags.U_AFK); this.autoAFK(); } - chan.checkVoteskipPass(); - chan.sendAll("setAFK", { - name: this.name, + /* Number of AFK users changed, voteskip state changes */ + if (this.channel.modules.voteskip) { + this.channel.modules.voteskip.update(); + } + + this.channel.broadcastAll("setAFK", { + name: this.getName(), afk: afk }); }; -/** - * Sets a timer to automatically mark the user as AFK after - * a period of inactivity - */ +/* Automatically tag a user as AFK after a period of inactivity */ User.prototype.autoAFK = function () { var self = this; if (self.awaytimer) { clearTimeout(self.awaytimer); } - if (!self.inChannel()) { + if (!self.inChannel() || !self.channel.modules.options) { return; } - var timeout = parseFloat(self.channel.opts.afk_timeout); + /* Don't set a timer if the duration is invalid */ + var timeout = parseFloat(self.channel.modules.options.get("afk_timeout")); if (isNaN(timeout) || timeout <= 0) { return; } @@ -158,289 +210,11 @@ User.prototype.autoAFK = function () { }, timeout * 1000); }; -/** - * Sends a kick message and disconnects the user - */ User.prototype.kick = function (reason) { this.socket.emit("kick", { reason: reason }); this.socket.disconnect(true); }; -/** - * Initializes socket message callbacks for a channel user - */ -User.prototype.initChannelCallbacks = function () { - var self = this; - - // Verifies datatype before calling a function - // Passes a default value if the typecheck fails - var typecheck = function (type, def, fn) { - return function (data) { - if (typeof data !== type) { - fn(def); - } else { - fn(data); - } - }; - }; - - // Verify that the user is in a channel, and that the passed data is an Object - var wrapTypecheck = function (msg, fn) { - self.socket.on(msg, typecheck("object", {}, function (data) { - if (self.inChannel()) { - fn(data); - } - })); - }; - - // Verify that the user is in a channel, but don't typecheck the data - var wrap = function (msg, fn) { - self.socket.on(msg, function (data) { - if (self.inChannel()) { - fn(data); - } - }); - }; - - self.socket.on("disconnect", function () { - if (self.awaytimer) { - clearTimeout(self.awaytimer); - } - - if (self.inChannel()) { - self.channel.part(self); - } - }); - - self.socket.once("joinChannel", typecheck("object", {}, function (data) { - if (self.inChannel()) { - return; - } - - if (typeof data.name !== "string") { - return; - } - - if (!util.isValidChannelName(data.name)) { - self.socket.emit("errorMsg", { - msg: "Invalid channel name. Channel names may consist of 1-30 " + - "characters in the set a-z, A-Z, 0-9, -, and _" - }); - self.kick("Invalid channel name"); - return; - } - - data.name = data.name.toLowerCase(); - var chan = Server.getServer().getChannel(data.name); - chan.preJoin(self, data.pw); - })); - - wrapTypecheck("assignLeader", function (data) { - self.channel.handleChangeLeader(self, data); - }); - - wrapTypecheck("setChannelRank", function (data) { - self.channel.handleSetRank(self, data); - }); - - wrapTypecheck("unban", function (data) { - self.channel.handleUnban(self, data); - }); - - wrapTypecheck("chatMsg", function (data) { - if (typeof data.msg !== "string") { - return; - } - - if (data.msg.indexOf("/afk") !== 0) { - self.setAFK(false); - self.autoAFK(); - } - - self.channel.handleChat(self, data); - }); - - wrapTypecheck("pm", function (data) { - if (typeof data.msg !== "string" || typeof data.to !== "string") { - return; - } - - self.channel.handlePm(self, data); - }); - - wrapTypecheck("newPoll", function (data) { - self.channel.handleOpenPoll(self, data); - }); - - wrapTypecheck("vote", function (data) { - self.channel.handlePollVote(self, data); - }); - - wrap("closePoll", function () { - self.channel.handleClosePoll(self); - }); - - wrap("playerReady", function () { - self.channel.sendMediaUpdate([self]); - }); - - wrap("requestPlaylist", function () { - self.channel.sendPlaylist([self]); - }); - - wrapTypecheck("queue", function (data) { - self.channel.handleQueue(self, data); - }); - - wrapTypecheck("queuePlaylist", function (data) { - self.channel.handleQueuePlaylist(self, data); - }); - - wrapTypecheck("setTemp", function (data) { - self.channel.handleSetTemp(self, data); - }); - - wrapTypecheck("moveMedia", function (data) { - self.channel.handleMove(self, data); - }); - - wrap("delete", function (data) { - self.channel.handleDelete(self, data); - }); - - wrapTypecheck("uncache", function (data) { - self.channel.handleUncache(self, data); - }); - - wrap("jumpTo", function (data) { - self.channel.handleJumpTo(self, data); - }); - - wrap("playNext", function () { - self.channel.handlePlayNext(self); - }); - - wrap("clearPlaylist", function () { - self.channel.handleClear(self); - }); - - wrap("shufflePlaylist", function () { - self.channel.handleShuffle(self); - }); - - wrap("togglePlaylistLock", function () { - self.channel.handleToggleLock(self); - }); - - wrapTypecheck("mediaUpdate", function (data) { - self.channel.handleUpdate(self, data); - }); - - wrapTypecheck("searchMedia", function (data) { - if (typeof data.query !== "string") { - return; - } - data.query = data.query.substring(0, 255); - - var searchYT = function () { - InfoGetter.Getters.ytSearch(data.query.split(" "), function (e, vids) { - if (!e) { - self.socket.emit("searchResults", { - source: "yt", - results: vids - }); - } - }); - }; - - if (data.source === "yt") { - searchYT(); - } else { - self.channel.search(data.query, function (vids) { - if (vids.length === 0) { - searchYT(); - } else { - self.socket.emit("searchResults", { - source: "library", - results: vids - }); - } - }); - } - - }); - - wrapTypecheck("setOptions", function (data) { - self.channel.handleUpdateOptions(self, data); - }); - - wrapTypecheck("setPermissions", function (data) { - self.channel.handleSetPermissions(self, data); - }); - - wrapTypecheck("setChannelCSS", function (data) { - self.channel.handleSetCSS(self, data); - }); - - wrapTypecheck("setChannelJS", function (data) { - self.channel.handleSetJS(self, data); - }); - - wrapTypecheck("setMotd", function (data) { - self.channel.handleSetMotd(self, data); - }); - - wrapTypecheck("updateFilter", function (data) { - self.channel.handleUpdateFilter(self, data); - }); - - wrap("importFilters", function (data) { - self.channel.handleImportFilters(self, data); - }); - - // REMOVE FILTER - // https://www.youtube.com/watch?v=SxUU3zncVmI - wrapTypecheck("removeFilter", function (data) { - self.channel.handleRemoveFilter(self, data); - }); - - wrapTypecheck("moveFilter", function (data) { - self.channel.handleMoveFilter(self, data); - }); - - wrapTypecheck("updateEmote", function (data) { - self.channel.handleUpdateEmote(self, data); - }); - - wrap("importEmotes", function (data) { - self.channel.handleImportEmotes(self, data); - }); - - wrapTypecheck("removeEmote", function (data) { - self.channel.handleRemoveEmote(self, data); - }); - - wrap("requestBanlist", function () { - self.channel.sendBanlist([self]); - }); - - wrap("requestChannelRanks", function () { - self.channel.sendChannelRanks([self]); - }); - - wrap("requestChatFilters", function () { - self.channel.sendChatFilters([self]); - }); - - wrap("voteskip", function () { - self.channel.handleVoteskip(self); - }); - - wrap("readChanLog", function () { - self.channel.handleReadLog(self); - }); -}; - User.prototype.initAdminCallbacks = function () { var self = this; self.socket.on("borrow-rank", function (rank) { @@ -449,46 +223,30 @@ User.prototype.initAdminCallbacks = function () { return; } - if (rank > self.global_rank) { + if (rank > self.account.globalRank) { return; } - if (rank === 255 && self.global_rank > 255) { - rank = self.global_rank; + if (rank === 255 && self.account.globalRank > 255) { + rank = self.account.globalRank; } - self.rank = rank; + self.account.channelRank = rank; self.socket.emit("rank", rank); - self.channel.sendAll("setUserRank", { - name: self.name, + self.channel.broadcastAll("setUserRank", { + name: self.getName(), rank: rank }); } }); }; -User.prototype.whenLoggedIn = function (fn) { - if (this.loggedIn) { - fn(); - } else { - this.once("login", fn); - } -}; - -User.prototype.whenChannelRank = function (fn) { - if (this.hasChannelRank) { - fn(); - } else { - this.once("channelRank", fn); - } -}; - User.prototype.login = function (name, pw) { var self = this; - self.loggingIn = true; + self.setFlag(Flags.U_LOGGING_IN); db.users.verifyLogin(name, pw, function (err, user) { - self.loggingIn = false; + self.clearFlag(Flags.U_LOGGING_IN); if (err) { if (err === "Invalid username/password combination") { Logger.eventlog.log("[loginfail] Login failed (bad password): " + name @@ -502,19 +260,31 @@ User.prototype.login = function (name, pw) { return; } - self.rank = self.global_rank = user.global_rank; - self.name = user.name; - self.loggedIn = true; - self.socket.emit("login", { - success: true, - name: user.name - }); - self.socket.emit("rank", self.rank); - Logger.syslog.log(self.ip + " logged in as " + name); + var opts = { name: user.name }; if (self.inChannel()) { - self.channel.logger.log(self.ip + " logged in as " + name); + opts.channel = self.channel.name; } - self.emit("login"); + self.setFlag(Flags.U_REGISTERED); + self.refreshAccount(opts, function (err, account) { + if (err) { + Logger.errlog.log("[SEVERE] getAccount failed for user " + user.name); + Logger.errlog.log(err); + user.clearFlag(Flags.U_REGISTERED); + return; + } + self.socket.emit("login", { + success: true, + name: user.name + }); + db.recordVisit(self.longip, self.getName()); + self.socket.emit("rank", self.account.effectiveRank); + Logger.syslog.log(self.ip + " logged in as " + user.name); + if (self.inChannel()) { + self.channel.logger.log(self.longip + " logged in as " + user.name); + } + self.setFlag(Flags.U_LOGGED_IN); + self.emit("login", self.account); + }); }); }; @@ -544,9 +314,9 @@ User.prototype.guestLogin = function (name) { } // Prevent duplicate logins - self.loggingIn = true; + self.setFlag(Flags.U_LOGGING_IN); db.users.isUsernameTaken(name, function (err, taken) { - self.loggingIn = false; + self.clearFlag(Flags.U_LOGGING_IN); if (err) { self.socket.emit("login", { success: false, @@ -566,7 +336,7 @@ User.prototype.guestLogin = function (name) { if (self.inChannel()) { var nameLower = name.toLowerCase(); for (var i = 0; i < self.channel.users.length; i++) { - if (self.channel.users[i].name.toLowerCase() === nameLower) { + if (self.channel.users[i].getLowerName() === nameLower) { self.socket.emit("login", { success: false, error: "That name is already in use on this channel." @@ -578,24 +348,32 @@ User.prototype.guestLogin = function (name) { // Login succeeded lastguestlogin[self.ip] = Date.now(); - self.rank = 0; - self.global_rank = 0; - self.socket.emit("rank", 0); - self.name = name; - self.loggedIn = true; - self.socket.emit("login", { - success: true, - name: name, - guest: true - }); - // TODO you shouldn't be able to guest login without being in a channel - Logger.syslog.log(self.ip + " signed in as " + name); + var opts = { name: name }; if (self.inChannel()) { - self.channel.logger.log(self.ip + " signed in as " + name); + opts.channel = self.channel.name; } + self.refreshAccount(opts, function (err, account) { + if (err) { + Logger.errlog.log("[SEVERE] getAccount failed for guest login " + name); + Logger.errlog.log(err); + return; + } - self.emit("login"); + self.socket.emit("login", { + success: true, + name: name, + guest: true + }); + db.recordVisit(self.longip, self.getName()); + self.socket.emit("rank", 0); + Logger.syslog.log(self.ip + " signed in as " + name); + if (self.inChannel()) { + self.channel.logger.log(self.longip + " signed in as " + name); + } + self.setFlag(Flags.U_LOGGED_IN); + self.emit("login", self.account); + }); }); }; @@ -614,8 +392,40 @@ setInterval(function () { } }, 5 * 60 * 1000); -User.prototype.initUserPLCallbacks = function () { - require("./userplaylists").init(this); +User.prototype.refreshAccount = function (opts, cb) { + if (!cb) { + cb = opts; + opts = {}; + } + + var different = false; + for (var key in opts) { + if (opts[key] !== this.account[key]) { + different = true; + break; + } + } + + if (!different) { + return; + } + + var name = ("name" in opts) ? opts.name : this.account.name; + opts.registered = this.is(Flags.U_REGISTERED); + var self = this; + var old = this.account; + Account.getAccount(name, this.longip, opts, function (err, account) { + if (!err) { + /* Update account if anything changed in the meantime */ + for (var key in old) { + if (self.account[key] !== old[key]) { + account[key] = self.account[key]; + } + } + self.account = account; + } + cb(err, account); + }); }; module.exports = User; diff --git a/lib/userplaylists.js b/lib/userplaylists.js deleted file mode 100644 index ad38b15b..00000000 --- a/lib/userplaylists.js +++ /dev/null @@ -1,81 +0,0 @@ -var db = require("./database"); - -function listPlaylists(user) { - db.listUserPlaylists(user.name, function (err, rows) { - if (err) { - user.socket.emit("errorMsg", { - msg: "Database error when attempting to fetch list of playlists" - }); - return; - } - - user.socket.emit("listPlaylists", rows); - }); -} - -function clonePlaylist(user, data) { - if (!user.inChannel()) { - user.socket.emit("errorMsg", { - msg: "You must be in a channel in order to clone its playlist" - }); - return; - } - - if (typeof data.name !== "string") { - return; - } - - var pl = user.channel.playlist.items.toArray(); - db.saveUserPlaylist(pl, user.name, data.name, function (err, res) { - if (err) { - user.socket.emit("errorMsg", { - msg: "Database error when saving playlist" - }); - } else { - listPlaylists(user); - } - }); -} - -function deletePlaylist(user, data) { - if (typeof data.name !== "string") { - return; - } - - db.deleteUserPlaylist(user.name, data.name, function (err) { - if (err) { - user.socket.emit("errorMsg", { - msg: err - }); - return; - } - - setImmediate(function () { - listPlaylists(user); - }); - }); -} - -module.exports.init = function (user) { - if (user.userPlInited) { - return; - } - - var s = user.socket; - var wrap = function (cb) { - return function (data) { - if (!user.loggedIn || user.global_rank < 1) { - s.emit("errorMsg", { - msg: "You must be logged in to manage playlists" - }); - return; - } - cb(user, data); - }; - }; - - s.on("listPlaylists", wrap(listPlaylists)); - s.on("clonePlaylist", wrap(clonePlaylist)); - s.on("deletePlaylist", wrap(deletePlaylist)); - user.userPlInited = true; -}; diff --git a/lib/utilities.js b/lib/utilities.js index 0aea2a1e..6aa03738 100644 --- a/lib/utilities.js +++ b/lib/utilities.js @@ -1,5 +1,5 @@ (function () { - var root, crypto = false; + var root, crypto, net = false; if (typeof window === "undefined") { root = module.exports; @@ -9,6 +9,7 @@ if (typeof require === "function") { crypto = require("crypto"); + net = require("net"); } var Set = function (items) { @@ -78,15 +79,74 @@ }, root.maskIP = function (ip) { - if(ip.match(/^\d+\.\d+\.\d+\.\d+$/)) { - // standard 32 bit IP - return ip.replace(/\d+\.\d+\.(\d+\.\d+)/, "x.x.$1"); - } else if(ip.match(/^\d+\.\d+\.\d+/)) { - // /24 range - return ip.replace(/\d+\.\d+\.(\d+)/, "x.x.$1.*"); + if (net.isIPv4(ip)) { + /* Full /32 IPv4 address */ + return ip.replace(/^(\d+\.\d+\.\d+)\.\d+/, "$1.x"); + } else if (net.isIPv4(ip + ".0")) { + /* /24 IPv4 range */ + return ip + ".0/24"; + } else if (net.isIPv4(ip + ".0.0")) { + /* /16 IPv4 widerange */ + return ip + ".0.0/16"; + } else if (net.isIPv6(ip)) { + /* /128 IPv6 address */ + return ip.replace(/^((?:[0-9a-f]+:){3}[0-9a-f]+):(?:[0-9a-f]+:){3}[0-9a-f]+$/, + "$1:x:x:x:x"); + } else if (net.isIPv6(ip + ":0:0:0:0")) { + /* /64 IPv6 range */ + return ip + "::0/64"; + } else if (net.isIPv6(ip + ":0:0:0:0:0")) { + /* /48 IPv6 widerange */ + return ip + "::0/48"; + } else { + return ip; } }, + root.getIPRange = function (ip) { + if (net.isIPv6(ip)) { + return root.expandIPv6(ip) + .replace(/((?:[0-9a-f]{4}:){3}[0-9a-f]{4}):(?:[0-9a-f]{4}:){3}[0-9a-f]{4}/, "$1"); + } else { + return ip.replace(/((?:[0-9]+\.){2}[0-9]+)\.[0-9]+/, "$1"); + } + }, + + root.getWideIPRange = function (ip) { + if (net.isIPv6(ip)) { + return root.expandIPv6(ip) + .replace(/((?:[0-9a-f]{4}:){2}[0-9a-f]{4}):(?:[0-9a-f]{4}:){4}[0-9a-f]{4}/, "$1"); + } else { + return ip.replace(/([0-9]+\.[0-9]+)\.[0-9]+\.[0-9]+/, "$1"); + } + }, + + root.expandIPv6 = function (ip) { + var result = "0000:0000:0000:0000:0000:0000:0000:0000".split(":"); + var parts = ip.split("::"); + var left = parts[0].split(":"); + var i = 0; + left.forEach(function (block) { + while (block.length < 4) { + block = "0" + block; + } + result[i++] = block; + }); + + if (parts.length > 1) { + var right = parts[1].split(":"); + i = 7; + right.forEach(function (block) { + while (block.length < 4) { + block = "0" + block; + } + result[i--] = block; + }); + } + + return result.join(":"); + }, + root.formatTime = function (sec) { if(sec === "--:--") return sec; diff --git a/lib/web/webserver.js b/lib/web/webserver.js index 67fd7c19..736e0e54 100644 --- a/lib/web/webserver.js +++ b/lib/web/webserver.js @@ -116,11 +116,14 @@ function handleChannel(req, res) { } var sio; - if (req.secure) { - sio = Config.get("https.full-address"); - } else { - sio = Config.get("io.domain") + ":" + Config.get("io.default-port"); + if (net.isIPv6(ipForRequest(req))) { + sio = Config.get("io.ipv6-default"); } + + if (!sio) { + sio = Config.get("io.ipv4-default"); + } + sio += "/socket.io/socket.io.js"; sendJade(res, "channel", { @@ -166,14 +169,19 @@ function handleSocketConfig(req, res) { res.type("application/javascript"); - var io_url = Config.get("io.domain") + ":" + Config.get("io.default-port"); - var web_url = Config.get("http.domain") + ":" + Config.get("http.default-port"); - var ssl_url = Config.get("https.domain") + ":" + Config.get("https.default-port"); - res.send("var IO_URL='"+io_url+"',WEB_URL='"+web_url+"',SSL_URL='" + ssl_url + - "',ALLOW_SSL="+Config.get("https.enabled")+";" + - (Config.get("https.enabled") ? - "if(location.protocol=='https:'||USEROPTS.secure_connection){" + - "IO_URL=WEB_URL=SSL_URL;}" : "")); + var sioconfig = Config.get("sioconfig"); + var iourl; + var ip = ipForRequest(req); + + if (net.isIPv6(ip)) { + iourl = Config.get("io.ipv6-default"); + } + + if (!iourl) { + iourl = Config.get("io.ipv4-default"); + } + sioconfig += "var IO_URL='" + iourl + "';"; + res.send(sioconfig); } function handleUserAgreement(req, res) { diff --git a/package.json b/package.json index e29fbb09..7cd1ba7d 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Calvin Montgomery", "name": "CyTube", "description": "Online media synchronizer and chat", - "version": "3.0.3", + "version": "3.1.0", "repository": { "url": "http://github.com/calzoneman/sync" }, @@ -16,6 +16,8 @@ "cookie": "~0.1.0", "yamljs": "~0.1.4", "express-minify": "0.0.7", + "q": "^1.0.0", + "json-typecheck": "^0.1.0", "oauth": "^0.9.11" } }