From 2fb28f4d431e2789e4f130048af0b23a22569f2e Mon Sep 17 00:00:00 2001 From: calzoneman Date: Sun, 28 Jul 2013 11:49:29 -0400 Subject: [PATCH 01/27] Change config system --- api.js | 34 ++++++++-------- auth.js | 1 - config.js | 111 ++++++++++++++++++++++++++++++++++++---------------- database.js | 10 ++--- server.js | 37 ++++++++++-------- user.js | 6 +-- 6 files changed, 123 insertions(+), 76 deletions(-) diff --git a/api.js b/api.js index 7b9c38e8..01dad0ce 100644 --- a/api.js +++ b/api.js @@ -12,22 +12,22 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI var Auth = require("./auth"); var Logger = require("./logger"); var apilog = new Logger.Logger("api.log"); -var Config = require("./config"); var ActionLog = require("./actionlog"); var fs = require("fs"); -function getIP(req) { - var raw = req.connection.remoteAddress; - var forward = req.header("x-forwarded-for"); - if(Config.REVERSE_PROXY && forward) { - var ip = forward.split(",")[0]; - Logger.syslog.log("REVPROXY " + raw + " => " + ip); - return ip; - } - return raw; -} 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"] && forward) { + var ip = forward.split(",")[0]; + Logger.syslog.log("REVPROXY " + raw + " => " + ip); + return ip; + } + return raw; + } + var API = function () { } @@ -251,7 +251,7 @@ module.exports = function (Server) { return; } - if(!Config.MAIL) { + if(!Server.cfg["enable-mail"]) { this.sendJSON(res, { success: false, error: "This server does not have email enabled. Contact an administrator" @@ -269,24 +269,24 @@ module.exports = function (Server) { "A password reset request was issued for your account `", name, "` on ", - Config.DOMAIN, + 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: ", - Config.DOMAIN, + Server.cfg["domain"], "/reset.html?", hash ].join(""); var mail = { - from: "CyTube Services <" + Config.MAIL_FROM + ">", + from: "CyTube Services <" + Server.cfg["mail-from"] + ">", to: email, subject: "Password reset request", text: msg }; var api = this; - Config.MAIL.sendMail(mail, function(err, response) { + cfg["nodemailer"].sendMail(mail, function(err, response) { if(err) { Logger.errlog.log("Mail fail: " + err); api.sendJSON(res, { @@ -299,7 +299,7 @@ module.exports = function (Server) { success: true }); - if(Config.DEBUG) { + if(Server.cfg["debug"]) { Logger.syslog.log(response); } } diff --git a/auth.js b/auth.js index 171c7532..89fb8efc 100644 --- a/auth.js +++ b/auth.js @@ -11,7 +11,6 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI var mysql = require("mysql-libmysqlclient"); var Database = require("./database.js"); -var Config = require("./config.js"); var bcrypt = require("bcrypt"); var hashlib = require("node_hash"); var Logger = require("./logger.js"); diff --git a/config.js b/config.js index 3e18c403..22c765d8 100644 --- a/config.js +++ b/config.js @@ -9,38 +9,83 @@ 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. */ -exports.MYSQL_SERVER = ""; -exports.MYSQL_DB = ""; -exports.MYSQL_USER = ""; -exports.MYSQL_PASSWORD = ""; -exports.IO_PORT = 1337; // Socket.IO port, DO NOT USE PORT 80. -exports.WEBSERVER_PORT = 8080; // Webserver port. Binding port 80 requires root permissions -exports.MAX_PER_IP = 10; -exports.GUEST_LOGIN_DELAY = 60; // Seconds - -/* - Set to true if your IO_URL and WEB_URL are behind a reverse proxy - (e.g. Cloudflare) so that client IPs are passed through correctly. - - If you are not behind a reverse proxy, leave it as false, otherwise - clients can fake their IP address in the x-forwarded-for header -*/ -exports.REVERSE_PROXY = false; - +var fs = require("fs"); +var Logger = require("./logger"); var nodemailer = require("nodemailer"); -exports.MAIL = false; -/* Example for setting up email: -exports.MAIL = nodemailer.createTransport("SMTP", { - service: "Gmail", - auth: { - user: "some.user@gmail.com", - pass: "supersecretpassword" - } -}); -See https://github.com/andris9/Nodemailer -*/ -exports.MAIL_FROM = "some.user@gmail.com"; -// Domain for password reset link -// Email sent goes to exports.DOMAIN/reset.html?resethash -exports.DOMAIN = "http://localhost"; +var defaults = { + "mysql-server" : "localhost", + "mysql-db" : "cytube", + "mysql-user" : "cytube", + "mysql-pw" : "supersecretpass", + "express-host" : "0.0.0.0", + "asset-cache-ttl" : 0, + "web-port" : 8080, + "io-port" : 1337, + "ip-connection-limit" : 10, + "guest-login-delay" : 60, + "trust-x-forward" : false, + "enable-mail" : false, + "mail-transport" : "SMTP", + "mail-config" : { + "service" : "Gmail", + "auth" : { + "user" : "some.user@gmail.com", + "pass" : "supersecretpassword" + } + }, + "mail-from" : "some.user@gmail.com", + "domain" : "http://localhost" +} + +function save(cfg, file) { + fs.writeFile(file, JSON.stringify(cfg, null, 4), function (err) { + if(err) { + Logger.errlog.log("Failed to save config"); + Logger.errlog.log(err); + } + }); +} + +exports.load = function (Server, file, callback) { + var cfg = {}; + for(var k in defaults) + cfg[k] = defaults[k]; + + fs.readFile(file, function (err, data) { + if(err) { + if(err.code == "ENOENT") { + Logger.syslog.log("Config file not found, generating default"); + Logger.syslog.log("Edit cfg.json to configure"); + data = "{}"; + } + else { + Logger.errlog.log("Config load failed"); + Logger.errlog.log(err); + return; + } + } + + try { + data = JSON.parse(data + ""); + } catch(e) { + Logger.errlog.log("Config JSON is invalid: "); + Logger.errlog.log(e); + return; + } + + for(var k in data) + cfg[k] = data[k]; + + if(cfg["enable-mail"]) { + cfg["nodemailer"] = nodemailer.createTransport( + cfg["mail-transport"], + cfg["mail-config"] + ); + } + + save(cfg, file); + Server.cfg = cfg; + callback(); + }); +} diff --git a/database.js b/database.js index f1068610..9bc4fb62 100644 --- a/database.js +++ b/database.js @@ -24,10 +24,10 @@ var CONFIG = {}; var global_bans = {}; function setup(cfg) { - SERVER = cfg.MYSQL_SERVER; - USER = cfg.MYSQL_USER; - DATABASE = cfg.MYSQL_DB; - PASSWORD = cfg.MYSQL_PASSWORD; + SERVER = cfg["mysql-server"]; + USER = cfg["mysql-user"]; + DATABASE = cfg["mysql-db"]; + PASSWORD = cfg["mysql-pw"]; CONFIG = cfg; } @@ -41,7 +41,7 @@ function getConnection() { Logger.errlog.log("DB connection failed"); return false; } - if(CONFIG.DEBUG) { + if(CONFIG["debug"]) { db._querySync = db.querySync; db.querySync = function(q) { Logger.syslog.log("DEBUG: " + q); diff --git a/server.js b/server.js index 54d71fc3..fe6c204e 100644 --- a/server.js +++ b/server.js @@ -10,7 +10,7 @@ const VERSION = "2.1.2"; function getIP(req) { var raw = req.connection.remoteAddress; var forward = req.header("x-forwarded-for"); - if(Config.REVERSE_PROXY && forward) { + if(Server.cfg["trust-x-forward"] && forward) { var ip = forward.split(",")[0]; Logger.syslog.log("REVPROXY " + raw + " => " + ip); return ip; @@ -20,7 +20,7 @@ function getIP(req) { function getSocketIP(socket) { var raw = socket.handshake.address.address; - if(Config.REVERSE_PROXY) { + if(Server.cfg["trust-x-forward"]) { if(typeof socket.handshake.headers["x-forwarded-for"] == "string") { var ip = socket.handshake.headers["x-forwarded-for"] .split(",")[0]; @@ -126,8 +126,10 @@ var Server = { }); // bind servers - this.httpserv = this.app.listen(Config.WEBSERVER_PORT); - this.ioserv = express().listen(Config.IO_PORT); + this.httpserv = this.app.listen(Server.cfg["web-port"], + Server.cfg["express-host"]); + this.ioserv = express().listen(Server.cfg["io-port"], + Server.cfg["express-host"]); // init socket.io this.io = require("socket.io").listen(this.ioserv); @@ -152,7 +154,7 @@ var Server = { this.ips[ip] = 0; this.ips[ip]++; - if(this.ips[ip] > Config.MAX_PER_IP) { + if(this.ips[ip] > Server.cfg["ip-connection-limit"]) { socket.emit("kick", { reason: "Too many connections from your IP address" }); @@ -167,7 +169,7 @@ var Server = { // init database this.db = require("./database"); - this.db.setup(Config); + this.db.setup(Server.cfg); this.db.init(); // init ACP @@ -190,15 +192,16 @@ var Server = { }; Logger.syslog.log("Starting CyTube v" + VERSION); -Server.init(); +Config.load(Server, "cfg.json", function () { + Server.init(); + if(!Server.cfg["debug"]) { + process.on("uncaughtException", function (err) { + Logger.errlog.log("[SEVERE] Uncaught Exception: " + err); + Logger.errlog.log(err.stack); + }); -if(!Config.DEBUG) { - process.on("uncaughtException", function (err) { - Logger.errlog.log("[SEVERE] Uncaught Exception: " + err); - Logger.errlog.log(err.stack); - }); - - process.on("SIGINT", function () { - Server.shutdown(); - }); -} + process.on("SIGINT", function () { + Server.shutdown(); + }); + } +}); diff --git a/user.js b/user.js index 9207ebe6..6772118d 100644 --- a/user.js +++ b/user.js @@ -14,7 +14,6 @@ var Auth = require("./auth.js"); var Channel = require("./channel.js").Channel; var formatTime = require("./media.js").formatTime; var Logger = require("./logger.js"); -var Config = require("./config.js"); var ActionLog = require("./actionlog"); // Represents a client connected via socket.io @@ -506,11 +505,12 @@ User.prototype.login = function(name, pw, session) { if(pw == "" && session == "") { if(this.ip in lastguestlogin) { var diff = (Date.now() - lastguestlogin[this.ip])/1000; - if(diff < Config.GUEST_LOGIN_DELAY) { + if(diff < this.server.cfg["guest-login-delay"]) { this.socket.emit("login", { success: false, error: ["Guest logins are restricted to one per ", - Config.GUEST_LOGIN_DELAY + " seconds per IP. ", + this.server.cfg["guest-login-delay"] + + " seconds per IP. ", "This restriction does not apply to registered users." ].join("") }); From 6899186600b5e299bdb787597ea937a7a442665b Mon Sep 17 00:00:00 2001 From: calzoneman Date: Sun, 28 Jul 2013 17:10:35 -0400 Subject: [PATCH 02/27] Add cache TTL option; fixes --- api.js | 8 ++++---- config.js | 7 ++++++- package.json | 2 +- server.js | 5 +++-- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/api.js b/api.js index 01dad0ce..03d8ed2c 100644 --- a/api.js +++ b/api.js @@ -286,7 +286,7 @@ module.exports = function (Server) { text: msg }; var api = this; - cfg["nodemailer"].sendMail(mail, function(err, response) { + Server.cfg["nodemailer"].sendMail(mail, function(err, response) { if(err) { Logger.errlog.log("Mail fail: " + err); api.sendJSON(res, { @@ -317,12 +317,12 @@ module.exports = function (Server) { name: info[0], pw: info[1] }); - ActionLog.record(ip, name, "password-recover-success"); - Logger.syslog.log(ip + " recovered password for " + name); + ActionLog.record(ip, info[0], "password-recover-success"); + Logger.syslog.log(ip + " recovered password for " + info[0]); return; } catch(e) { - ActionLog.record(ip, name, "password-recover-failure"); + ActionLog.record(ip, "", "password-recover-failure"); this.sendJSON(res, { success: false, error: e diff --git a/config.js b/config.js index 22c765d8..f313b27c 100644 --- a/config.js +++ b/config.js @@ -39,7 +39,12 @@ var defaults = { } function save(cfg, file) { - fs.writeFile(file, JSON.stringify(cfg, null, 4), function (err) { + var x = {}; + for(var k in cfg) { + if(k !== "nodemailer") + x[k] = cfg[k]; + } + fs.writeFile(file, JSON.stringify(x, null, 4), function (err) { if(err) { Logger.errlog.log("Failed to save config"); Logger.errlog.log(err); diff --git a/package.json b/package.json index e7dde87a..a0814256 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Calvin Montgomery", "name": "CyTube", "description": "Online media synchronizer and chat", - "version": "2.1.2", + "version": "2.1.3", "repository": { "url": "http://github.com/calzoneman/sync" }, diff --git a/server.js b/server.js index fe6c204e..e8eef0b9 100644 --- a/server.js +++ b/server.js @@ -5,7 +5,7 @@ var Logger = require("./logger"); var Channel = require("./channel"); var User = require("./user"); -const VERSION = "2.1.2"; +const VERSION = "2.1.3"; function getIP(req) { var raw = req.connection.remoteAddress; @@ -95,6 +95,7 @@ var Server = { this.app.get("/:thing(*)", function (req, res, next) { var opts = { root: __dirname + "/www", + maxAge: this.cfg["asset-cache-ttl"] } res.sendfile(req.params.thing, opts, function (err) { if(err) { @@ -114,7 +115,7 @@ var Server = { } } }); - }); + }.bind(this)); // fallback this.app.use(function (err, req, res, next) { From c50baef9c6297976cbd2b5889423136461343d0d Mon Sep 17 00:00:00 2001 From: calzoneman Date: Sun, 28 Jul 2013 17:25:06 -0400 Subject: [PATCH 03/27] Change logger to stream, decode HTML messages - Using a WritableStream instead of manually buffering is a good idea - Chat messages are logged with HTML entities decoded (< instead of <) --- channel.js | 5 +++-- logger.js | 30 ++++++++++++------------------ 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/channel.js b/channel.js index cca26a27..c7bc8494 100644 --- a/channel.js +++ b/channel.js @@ -263,7 +263,7 @@ Channel.prototype.saveDump = function() { }; var text = JSON.stringify(dump); fs.writeFileSync("chandump/" + this.name, text); - this.logger.flush(); + this.logger.close(); } // Save channel dumps every 5 minutes, in case of crash @@ -1829,7 +1829,8 @@ Channel.prototype.sendMessage = function(username, msg, msgclass, data) { this.chatbuffer.push(msgobj); if(this.chatbuffer.length > 15) this.chatbuffer.shift(); - this.logger.log("<" + username + "." + msgclass + "> " + msg); + var unescaped = sanitize(msg).entityDecode(); + this.logger.log("<" + username + "." + msgclass + "> " + unescaped); }; /* REGION Rank stuff */ diff --git a/logger.js b/logger.js index f1129336..4bbc79fa 100644 --- a/logger.js +++ b/logger.js @@ -18,28 +18,22 @@ function getTimeString() { var Logger = function(filename) { this.filename = filename; - this.buffer = []; - - setInterval(function() { - this.flush(); - }.bind(this), 15000); + this.writer = fs.createWriteStream(filename, { + flags: "a", + encoding: "utf-8" + }); } -Logger.prototype.log = function(what) { - this.buffer.push("[" + getTimeString() + "] " + what); +Logger.prototype.log = function () { + var msg = ""; + for(var i in arguments) + msg += arguments[i]; + var str = "[" + getTimeString() + "] " + msg + "\n"; + this.writer.write(str); } -Logger.prototype.flush = function() { - if(this.buffer.length == 0) - return; - var text = this.buffer.join("\n") + "\n"; - this.buffer = []; - fs.appendFile(this.filename, text, function(err) { - if(err) { - errlog.log("Append to " + this.filename + " failed: "); - errlog.log(err); - } - }.bind(this)); +Logger.prototype.close = function () { + this.writer.end(); } var errlog = new Logger("error.log"); From 1150d03474ac0fb563b9e518e01d9e6a88aa8c11 Mon Sep 17 00:00:00 2001 From: calzoneman Date: Sun, 28 Jul 2013 17:36:53 -0400 Subject: [PATCH 04/27] AFKers don't affect voteskip (#193) --- channel.js | 12 +++++++++++- chatcommand.js | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/channel.js b/channel.js index c7bc8494..e16201b1 100644 --- a/channel.js +++ b/channel.js @@ -34,6 +34,7 @@ var Channel = function(name, Server) { // Initialize defaults this.registered = false; this.users = []; + this.afkcount = 0; this.playlist = new Playlist(this); this.library = {}; this.position = -1; @@ -958,6 +959,7 @@ Channel.prototype.broadcastChatFilters = function() { Channel.prototype.broadcastVoteskipUpdate = function() { var amt = this.voteskip ? this.voteskip.counts[0] : 0; var need = this.voteskip ? parseInt(this.users.length * this.opts.voteskip_ratio) : 0; + need -= this.afkcount; for(var i = 0; i < this.users.length; i++) { if(Rank.hasPermission(this.users[i], "seeVoteskip") || this.leader == this.users[i]) { @@ -1536,12 +1538,20 @@ Channel.prototype.tryVoteskip = function(user) { if(!this.opts.allow_voteskip) { return; } + // Voteskip = auto-unafk + if(user.meta.afk) { + user.meta.afk = false; + this.broadcastUserUpdate(user); + this.afkcount--; + } if(!this.voteskip) { this.voteskip = new Poll("voteskip", "voteskip", ["yes"]); } this.voteskip.vote(user.ip, 0); this.broadcastVoteskipUpdate(); - if(this.voteskip.counts[0] >= parseInt(this.users.length * this.opts.voteskip_ratio)) { + var need = parseInt(this.users.length * this.opts.voteskip_ratio); + need -= this.afkcount; + if(this.voteskip.counts[0] >= need) { this.playNext(); } } diff --git a/chatcommand.js b/chatcommand.js index 9c7233b1..bef06d9a 100644 --- a/chatcommand.js +++ b/chatcommand.js @@ -25,6 +25,20 @@ function handle(chan, user, msg, data) { } else if(msg.indexOf("/afk") == 0) { user.meta.afk = !user.meta.afk; + if(user.meta.afk) + chan.afkcount++; + else + chan.afkcount--; + if(chan.voteskip) { + var need = parseInt(chan.users.length * chan.opts.voteskip_ratio); + need -= chan.afkcount; + if(chan.voteskip.counts[0] >= need) { + chan.playNext(); + } + else { + chan.broadcastVoteskipUpdate(); + } + } chan.broadcastUserUpdate(user); } else if(msg.indexOf("/m ") == 0) { From 77a57d24c157c10d4d5f53aff0e578d757502604 Mon Sep 17 00:00:00 2001 From: calzoneman Date: Sun, 28 Jul 2013 17:58:22 -0400 Subject: [PATCH 05/27] Implement Auto-AFK (#192) - Channel-configurable delay - User is marked AFK if no chat messages are received before the delay expires - User is marked un-AFK if a chat message is received or if the user voteskips --- channel.js | 13 ++++++++--- chatcommand.js | 17 +------------- user.js | 39 ++++++++++++++++++++++++++++++++ www/assets/js/channelsettings.js | 3 ++- www/assets/js/util.js | 1 + www/channeloptions.html | 7 ++++++ 6 files changed, 60 insertions(+), 20 deletions(-) diff --git a/channel.js b/channel.js index e16201b1..a212b364 100644 --- a/channel.js +++ b/channel.js @@ -77,6 +77,7 @@ var Channel = function(name, Server) { this.opts = { allow_voteskip: true, voteskip_ratio: 0.5, + afk_timeout: 180, pagetitle: this.name, maxlength: 0, externalcss: "", @@ -202,6 +203,9 @@ Channel.prototype.loadDump = function() { } this.sendAll("setPermissions", this.permissions); this.broadcastOpts(); + this.users.forEach(function (u) { + u.autoAFK(); + }); if(data.filters) { for(var i = 0; i < data.filters.length; i++) { var f = data.filters[i]; @@ -1540,9 +1544,7 @@ Channel.prototype.tryVoteskip = function(user) { } // Voteskip = auto-unafk if(user.meta.afk) { - user.meta.afk = false; - this.broadcastUserUpdate(user); - this.afkcount--; + user.setAFK(false); } if(!this.voteskip) { this.voteskip = new Poll("voteskip", "voteskip", ["yes"]); @@ -1691,6 +1693,11 @@ Channel.prototype.tryUpdateOptions = function(user, data) { if(key in adminonly && user.rank < Rank.Owner) { continue; } + if(key === "afk_timeout" && this.opts[key] != data[key]) { + this.users.forEach(function (u) { + u.autoAFK(); + }); + } this.opts[key] = data[key]; } } diff --git a/chatcommand.js b/chatcommand.js index bef06d9a..388bee42 100644 --- a/chatcommand.js +++ b/chatcommand.js @@ -24,22 +24,7 @@ function handle(chan, user, msg, data) { } } else if(msg.indexOf("/afk") == 0) { - user.meta.afk = !user.meta.afk; - if(user.meta.afk) - chan.afkcount++; - else - chan.afkcount--; - if(chan.voteskip) { - var need = parseInt(chan.users.length * chan.opts.voteskip_ratio); - need -= chan.afkcount; - if(chan.voteskip.counts[0] >= need) { - chan.playNext(); - } - else { - chan.broadcastVoteskipUpdate(); - } - } - chan.broadcastUserUpdate(user); + user.setAFK(!user.meta.afk); } else if(msg.indexOf("/m ") == 0) { if(user.rank >= Rank.Moderator) { diff --git a/user.js b/user.js index 6772118d..64315a8a 100644 --- a/user.js +++ b/user.js @@ -37,6 +37,8 @@ var User = function(socket, Server) { image: "", text: "" }; + this.awaytimer = false; + this.autoAFK(); this.initCallbacks(); if(Server.announcement != null) { @@ -78,6 +80,41 @@ User.prototype.noflood = function(name, hz) { } } +User.prototype.setAFK = function (afk) { + if(this.channel === null) + return; + var chan = this.channel; + this.meta.afk = afk; + if(this.meta.afk) + chan.afkcount++; + else + chan.afkcount--; + if(chan.voteskip) { + chan.voteskip.unvote(this.ip); + var need = parseInt(chan.users.length * chan.opts.voteskip_ratio); + need -= chan.afkcount; + if(chan.voteskip.counts[0] >= need) { + chan.playNext(); + } + else { + chan.broadcastVoteskipUpdate(); + } + } + chan.broadcastUserUpdate(this); +} + +User.prototype.autoAFK = function () { + if(this.awaytimer) + clearTimeout(this.awaytimer); + + if(this.channel === null || this.channel.opts.afk_timeout == 0) + return; + + this.awaytimer = setTimeout(function () { + this.setAFK(true); + }.bind(this), this.channel.opts.afk_timeout * 1000); +} + User.prototype.initCallbacks = function() { this.socket.on("disconnect", function() { if(this.channel != null) @@ -165,6 +202,8 @@ User.prototype.initCallbacks = function() { this.socket.on("chatMsg", function(data) { if(this.channel != null) { + this.setAFK(false); + this.autoAFK(); this.channel.tryChat(this, data); } }.bind(this)); diff --git a/www/assets/js/channelsettings.js b/www/assets/js/channelsettings.js index f222578f..c7919efe 100644 --- a/www/assets/js/channelsettings.js +++ b/www/assets/js/channelsettings.js @@ -69,7 +69,8 @@ externaljs: $("#opt_externaljs").val(), chat_antiflood: $("#opt_chat_antiflood").prop("checked"), show_public: $("#opt_show_public").prop("checked"), - enable_link_regex: $("#opt_enable_link_regex").prop("checked") + enable_link_regex: $("#opt_enable_link_regex").prop("checked"), + afk_timeout: parseInt($("#opt_afktimeout").val()) }); }); diff --git a/www/assets/js/util.js b/www/assets/js/util.js index b1898d4a..36712689 100644 --- a/www/assets/js/util.js +++ b/www/assets/js/util.js @@ -779,6 +779,7 @@ function handleModPermissions() { $("#opt_show_public").prop("checked", CHANNEL.opts.show_public); $("#opt_show_public").attr("disabled", CLIENT.rank < 3); $("#opt_enable_link_regex").prop("checked", CHANNEL.opts.enable_link_regex); + $("#opt_afktimeout").val(CHANNEL.opts.afk_timeout); $("#opt_allow_voteskip").prop("checked", CHANNEL.opts.allow_voteskip); $("#opt_voteskip_ratio").val(CHANNEL.opts.voteskip_ratio); (function() { diff --git a/www/channeloptions.html b/www/channeloptions.html index 87a3d4b7..b3df2ebe 100644 --- a/www/channeloptions.html +++ b/www/channeloptions.html @@ -56,6 +56,13 @@ + +
+ +
+ +
+

Admin-Only Controls From ac75cf34ab11d63d3aeca163a899731dd90a0433 Mon Sep 17 00:00:00 2001 From: calzoneman Date: Sun, 28 Jul 2013 19:47:13 -0400 Subject: [PATCH 06/27] Fix not autogenerating folders --- server.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/server.js b/server.js index e8eef0b9..c34b7fe1 100644 --- a/server.js +++ b/server.js @@ -193,6 +193,15 @@ var Server = { }; Logger.syslog.log("Starting CyTube v" + VERSION); + +fs.exists("chanlogs", function (exists) { + exists || fs.mkdir("chanlogs"); +}); + +fs.exists("chandump", function (exists) { + exists || fs.mkdir("chandump"); +}); + Config.load(Server, "cfg.json", function () { Server.init(); if(!Server.cfg["debug"]) { From 1df015b83927ae4ac9b51fd899538c1b554a7612 Mon Sep 17 00:00:00 2001 From: calzoneman Date: Sun, 28 Jul 2013 19:47:55 -0400 Subject: [PATCH 07/27] Would help if I imported the module --- server.js | 1 + 1 file changed, 1 insertion(+) diff --git a/server.js b/server.js index c34b7fe1..308dc9d1 100644 --- a/server.js +++ b/server.js @@ -1,4 +1,5 @@ var path = require("path"); +var fs = require("fs"); var express = require("express"); var Config = require("./config"); var Logger = require("./logger"); From 93dd730bde04da0f7ca4de2bf2e7f0ca5d0825a7 Mon Sep 17 00:00:00 2001 From: calzoneman Date: Mon, 29 Jul 2013 18:13:06 -0400 Subject: [PATCH 08/27] Add check to prevent write-after-end error --- logger.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/logger.js b/logger.js index 4bbc79fa..d2b2d391 100644 --- a/logger.js +++ b/logger.js @@ -17,6 +17,7 @@ function getTimeString() { } var Logger = function(filename) { + this.dead = false; this.filename = filename; this.writer = fs.createWriteStream(filename, { flags: "a", @@ -25,6 +26,10 @@ var Logger = function(filename) { } Logger.prototype.log = function () { + if(this.dead) { + Logger.errlog.log("WARNING: Attempted write to dead logger: ", this.filename); + return; + } var msg = ""; for(var i in arguments) msg += arguments[i]; @@ -33,7 +38,9 @@ Logger.prototype.log = function () { } Logger.prototype.close = function () { - this.writer.end(); + this.writer.end("", null, function () { + this.dead = true; + }); } var errlog = new Logger("error.log"); From 4c7da26f1370f536455d195a6e6bd61eb5f2d3fe Mon Sep 17 00:00:00 2001 From: calzoneman Date: Mon, 29 Jul 2013 18:16:11 -0400 Subject: [PATCH 09/27] Add close() check --- logger.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/logger.js b/logger.js index d2b2d391..429edfa4 100644 --- a/logger.js +++ b/logger.js @@ -27,7 +27,7 @@ var Logger = function(filename) { Logger.prototype.log = function () { if(this.dead) { - Logger.errlog.log("WARNING: Attempted write to dead logger: ", this.filename); + errlog.log("WARNING: Attempted write to dead logger: ", this.filename); return; } var msg = ""; @@ -38,6 +38,10 @@ Logger.prototype.log = function () { } Logger.prototype.close = function () { + if(this.dead) { + errlog.log("WARNING: Attempted closure on dead logger: ", this.filename); + return; + } this.writer.end("", null, function () { this.dead = true; }); From 687a561079515db1dd007497a2455b38c580b08c Mon Sep 17 00:00:00 2001 From: calzoneman Date: Mon, 29 Jul 2013 19:32:51 -0400 Subject: [PATCH 10/27] Fix logger dead handler --- logger.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/logger.js b/logger.js index 429edfa4..7d83fe9b 100644 --- a/logger.js +++ b/logger.js @@ -26,13 +26,16 @@ var Logger = function(filename) { } Logger.prototype.log = function () { - if(this.dead) { - errlog.log("WARNING: Attempted write to dead logger: ", this.filename); - return; - } var msg = ""; for(var i in arguments) msg += arguments[i]; + + if(this.dead) { + errlog.log("WARNING: Attempted write to dead logger: ", this.filename); + errlog.log("Message was: ", msg); + return; + } + var str = "[" + getTimeString() + "] " + msg + "\n"; this.writer.write(str); } @@ -44,7 +47,7 @@ Logger.prototype.close = function () { } this.writer.end("", null, function () { this.dead = true; - }); + }.bind(this)); } var errlog = new Logger("error.log"); From ba26d9abbb9de3b1a1ecd664eed2a808d3258b6f Mon Sep 17 00:00:00 2001 From: calzoneman Date: Mon, 29 Jul 2013 19:59:52 -0400 Subject: [PATCH 11/27] Fix logger being closed inappropriately --- channel.js | 1 - logger.js | 21 +++++++++++---------- server.js | 1 + 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/channel.js b/channel.js index a212b364..8881597b 100644 --- a/channel.js +++ b/channel.js @@ -268,7 +268,6 @@ Channel.prototype.saveDump = function() { }; var text = JSON.stringify(dump); fs.writeFileSync("chandump/" + this.name, text); - this.logger.close(); } // Save channel dumps every 5 minutes, in case of crash diff --git a/logger.js b/logger.js index 7d83fe9b..85d5748d 100644 --- a/logger.js +++ b/logger.js @@ -17,7 +17,6 @@ function getTimeString() { } var Logger = function(filename) { - this.dead = false; this.filename = filename; this.writer = fs.createWriteStream(filename, { flags: "a", @@ -31,23 +30,25 @@ Logger.prototype.log = function () { msg += arguments[i]; if(this.dead) { - errlog.log("WARNING: Attempted write to dead logger: ", this.filename); - errlog.log("Message was: ", msg); return; } var str = "[" + getTimeString() + "] " + msg + "\n"; - this.writer.write(str); + try { + this.writer.write(str); + } catch(e) { + errlog.log("WARNING: Attempted logwrite failed: " + this.filename); + errlog.log("Message was: " + msg); + errlog.log(e); + } } Logger.prototype.close = function () { - if(this.dead) { - errlog.log("WARNING: Attempted closure on dead logger: ", this.filename); - return; + try { + this.writer.end(); + } catch(e) { + errlog.log("Log close failed: " + this.filename); } - this.writer.end("", null, function () { - this.dead = true; - }.bind(this)); } var errlog = new Logger("error.log"); diff --git a/server.js b/server.js index 308dc9d1..4c82a298 100644 --- a/server.js +++ b/server.js @@ -55,6 +55,7 @@ var Server = { if(chan.registered) chan.saveDump(); chan.playlist.die(); + chan.logger.close(); for(var i in this.channels) { if(this.channels[i].canonical_name == chan.canonical_name) { this.channels.splice(i, 1); From 995b92ebedc4b41737e0dd32b14d1f47634a66e3 Mon Sep 17 00:00:00 2001 From: calzoneman Date: Mon, 29 Jul 2013 20:02:31 -0400 Subject: [PATCH 12/27] Reset AFK timer when returning from being AFK --- user.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/user.js b/user.js index 64315a8a..8dec1941 100644 --- a/user.js +++ b/user.js @@ -85,6 +85,8 @@ User.prototype.setAFK = function (afk) { return; var chan = this.channel; this.meta.afk = afk; + if(!afk) + this.autoAFK(); if(this.meta.afk) chan.afkcount++; else From 17c733be449a38703e8cc6d59ec26d36b85af762 Mon Sep 17 00:00:00 2001 From: calzoneman Date: Mon, 29 Jul 2013 20:06:01 -0400 Subject: [PATCH 13/27] Dedupe playlist option notification --- user.js | 6 ++++-- www/assets/js/util.js | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/user.js b/user.js index 8dec1941..e886d63b 100644 --- a/user.js +++ b/user.js @@ -204,8 +204,10 @@ User.prototype.initCallbacks = function() { this.socket.on("chatMsg", function(data) { if(this.channel != null) { - this.setAFK(false); - this.autoAFK(); + if(data.msg.indexOf("/afk") == -1) { + this.setAFK(false); + this.autoAFK(); + } this.channel.tryChat(this, data); } }.bind(this)); diff --git a/www/assets/js/util.js b/www/assets/js/util.js index 36712689..ec5ad9c9 100644 --- a/www/assets/js/util.js +++ b/www/assets/js/util.js @@ -837,7 +837,7 @@ function handlePermissionChange() { hasPermission("playlistjump") || hasPermission("playlistdelete") || hasPermission("settemp")) { - if(USEROPTS.first_visit) { + if(USEROPTS.first_visit && $("#plonotification").length == 0) { var al = makeAlert("Playlist Options", [ "From the Options menu, you can choose to automatically", " hide the buttons on each entry (and show them when", @@ -845,6 +845,7 @@ function handlePermissionChange() { " style of playlist buttons.", "
"].join("")) .addClass("span12") + .attr("id", "plonotification") .insertBefore($("#queue")); al.find(".close").remove(); From fe2985e8ddada4950c6d0322996c4af2fd0eb58d Mon Sep 17 00:00:00 2001 From: calzoneman Date: Tue, 30 Jul 2013 09:07:30 -0400 Subject: [PATCH 14/27] Save playlists with cached data --- database.js | 2 ++ package.json | 2 +- playlist.js | 13 +++++++++++++ server.js | 2 +- 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/database.js b/database.js index 9bc4fb62..c57bebac 100644 --- a/database.js +++ b/database.js @@ -900,6 +900,8 @@ function saveUserPlaylist(pl, user, name) { for(var i = 0; i < pl.length; i++) { var e = { id: pl[i].media.id, + title: pl[i].media.title, + seconds: pl[i].media.seconds, type: pl[i].media.type }; time += pl[i].media.seconds; diff --git a/package.json b/package.json index a0814256..cc33e28a 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Calvin Montgomery", "name": "CyTube", "description": "Online media synchronizer and chat", - "version": "2.1.3", + "version": "2.1.4", "repository": { "url": "http://github.com/calzoneman/sync" }, diff --git a/playlist.js b/playlist.js index e6bfd727..f6f412f9 100644 --- a/playlist.js +++ b/playlist.js @@ -239,6 +239,19 @@ Playlist.prototype.addMedia = function(data, callback) { }; this.queueAction(action); + // Pre-cached data + if(typeof data.title === "string" && + typeof data.seconds === "number") { + if(data.maxlength && data.seconds > data.maxlength) { + action.expire = 0; + callback("Media is too long!", null); + return; + } + it.media = new Media(data.id, data.title, data.seconds, data.type); + action.waiting = false; + return; + } + InfoGetter.getMedia(data.id, data.type, function(err, media) { if(err) { action.expire = 0; diff --git a/server.js b/server.js index 4c82a298..f5ddbf14 100644 --- a/server.js +++ b/server.js @@ -6,7 +6,7 @@ var Logger = require("./logger"); var Channel = require("./channel"); var User = require("./user"); -const VERSION = "2.1.3"; +const VERSION = "2.1.4"; function getIP(req) { var raw = req.connection.remoteAddress; From d9c4c32c6d5f0a076a7edde9d7fa6a89cf01d848 Mon Sep 17 00:00:00 2001 From: calzoneman Date: Tue, 30 Jul 2013 09:27:13 -0400 Subject: [PATCH 15/27] Handle #215, re-add multifilter editor --- www/assets/js/channelsettings.js | 56 ++++++++++++++++++++++++++++++++ www/channeloptions.html | 29 +++++++++-------- 2 files changed, 72 insertions(+), 13 deletions(-) diff --git a/www/assets/js/channelsettings.js b/www/assets/js/channelsettings.js index c7919efe..714b91be 100644 --- a/www/assets/js/channelsettings.js +++ b/www/assets/js/channelsettings.js @@ -142,4 +142,60 @@ $("#newfilter_flags").val("g"); $("#newfilter_replace").val(""); }); + function splitreEntry(str) { + var split = []; + var current = []; + for(var i = 0; i < str.length; i++) { + if(str[i] == "\\" && i+1 < str.length && str[i+1].match(/\s/)) { + current.push(str[i+1]); + i++; + continue; + } + else if(str[i].match(/\s/)) { + split.push(current.join("")); + current = []; + } + else { + current.push(str[i]); + } + } + split.push(current.join("")); + return split; + } + + $("#multifiltersubmit").click(function () { + var lines = $("#multifiltereditor").val().split("\n"); + for(var i in lines) { + var ln = lines[i]; + var fields = splitreEntry(ln); + if(fields.length < 3 || fields.length > 4) { + makeAlert("Error on line "+(parseInt(i)+1)+". Format: name regex flags replacement", "alert-error") + .insertBefore($("#multifiltereditor")); + return; + } + + var name = "", re = "", f = "", replace = ""; + if(fields.length == 3) { + name = fields[0]; + re = fields[0]; + f = fields[1]; + replace = fields[2]; + } + else if(fields.length == 4) { + name = fields[0]; + re = fields[1]; + f = fields[2]; + replace = fields[3]; + } + + socket.emit("updateFilter", { + name: name, + source: re, + flags: f, + replace: replace, + filterlinks: false, + active: true + }); + } + }); })(); diff --git a/www/channeloptions.html b/www/channeloptions.html index b3df2ebe..4af0e432 100644 --- a/www/channeloptions.html +++ b/www/channeloptions.html @@ -114,19 +114,6 @@

Filters will be processed in the order that they are listed here. Click and drag a row to rearrange the order. Click a regex, flags, or replacement field to edit a filter. Changes are automatically saved when you finish editing.

- - - - - - - - - - - - -
DeleteNameRegexFlagsReplacementAffects LinksActive
Add Filter
@@ -175,6 +162,22 @@
+ + + + + + + + + + + + +
DeleteNameRegexFlagsReplacementAffects LinksActive
+

Multi-Filter Editor (format: name regex flags replacement). Only use this if you know what you are doing

+ +

Max 20KB. If you need more space, host the file externally and use the External CSS option

From 9194c341af49a68f042f805a2bb65fe51c430128 Mon Sep 17 00:00:00 2001 From: calzoneman Date: Tue, 30 Jul 2013 09:43:58 -0400 Subject: [PATCH 16/27] HTTP Access Log --- server.js | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/server.js b/server.js index f5ddbf14..69bc707e 100644 --- a/server.js +++ b/server.js @@ -72,26 +72,41 @@ var Server = { db: null, ips: {}, acp: null, + httpaccess: null, + logHTTP: function (req, status) { + if(status === undefined) + status = 200; + var ip = req.connection.remoteAddress; + var ip2 = req.header("x-forwarded-for"); + var ipstr = ip === ip2 ? ip : ip + " (X-Forwarded-For " + ip2 + ")"; + this.httpaccess.log([ipstr, req.method, req.url, status, req.headers["user-agent"]].join(" ")); + }, init: function () { + this.httpaccess = new Logger.Logger("httpaccess.log"); this.app = express(); // channel path this.app.get("/r/:channel(*)", function (req, res, next) { var c = req.params.channel; - if(!c.match(/^[\w-_]+$/)) + if(!c.match(/^[\w-_]+$/)) { res.redirect("/" + c); - else + } + else { + this.logHTTP(req); res.sendfile(__dirname + "/www/channel.html"); - }); + } + }.bind(this)); // api path this.api = require("./api")(this); this.app.get("/api/:apireq(*)", function (req, res, next) { + this.logHTTP(req); this.api.handle(req.url.substring(5), req, res); }.bind(this)); this.app.get("/", function (req, res, next) { + this.logHTTP(req); res.sendfile(__dirname + "/www/index.html"); - }); + }.bind(this)); // default path this.app.get("/:thing(*)", function (req, res, next) { @@ -101,6 +116,7 @@ var Server = { } res.sendfile(req.params.thing, opts, function (err) { if(err) { + this.logHTTP(req, err.status); // Damn path traversal attacks if(req.params.thing.indexOf("%2e") != -1) { res.send("Don't try that again, I'll ban you"); @@ -116,17 +132,21 @@ var Server = { res.send(err.status); } } - }); + else { + this.logHTTP(req); + } + }.bind(this)); }.bind(this)); // fallback this.app.use(function (err, req, res, next) { + this.logHTTP(req, err.status); if(err.status == 404) { res.send(404); } else { next(err); } - }); + }.bind(this)); // bind servers this.httpserv = this.app.listen(Server.cfg["web-port"], From 36d3232d9fb8c7a4d2ac21da36b2b022678d10a9 Mon Sep 17 00:00:00 2001 From: calzoneman Date: Tue, 30 Jul 2013 12:26:08 -0400 Subject: [PATCH 17/27] Strip URL query before logging --- server.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/server.js b/server.js index 69bc707e..d0648cd2 100644 --- a/server.js +++ b/server.js @@ -77,9 +77,15 @@ var Server = { if(status === undefined) status = 200; var ip = req.connection.remoteAddress; - var ip2 = req.header("x-forwarded-for"); - var ipstr = ip === ip2 ? ip : ip + " (X-Forwarded-For " + ip2 + ")"; - this.httpaccess.log([ipstr, req.method, req.url, status, req.headers["user-agent"]].join(" ")); + var ip2 = false; + if(this.cfg["trust-x-forward"]) + ip2 = req.header("x-forwarded-for") || req.header("cf-connecting-ip"); + var ipstr = !ip2 ? ip : ip + " (X-Forwarded-For " + ip2 + ")"; + var url = req.url; + // Remove query + if(url.indexOf("?") != -1) + url = url.substring(0, url.lastIndexOf("?")); + this.httpaccess.log([ipstr, req.method, url, status, req.headers["user-agent"]].join(" ")); }, init: function () { this.httpaccess = new Logger.Logger("httpaccess.log"); From 5a91a7ce217e0bce01dd62c3f3ae79f3db6228ab Mon Sep 17 00:00:00 2001 From: calzoneman Date: Tue, 30 Jul 2013 18:24:09 -0400 Subject: [PATCH 18/27] Only change afkcount if afk state changes --- user.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/user.js b/user.js index e886d63b..80d76f1c 100644 --- a/user.js +++ b/user.js @@ -83,14 +83,17 @@ User.prototype.noflood = function(name, hz) { User.prototype.setAFK = function (afk) { if(this.channel === null) return; + var changed = this.meta.afk != afk; var chan = this.channel; this.meta.afk = afk; if(!afk) this.autoAFK(); - if(this.meta.afk) - chan.afkcount++; - else - chan.afkcount--; + if(changed) { + if(this.meta.afk) + chan.afkcount++; + else + chan.afkcount--; + } if(chan.voteskip) { chan.voteskip.unvote(this.ip); var need = parseInt(chan.users.length * chan.opts.voteskip_ratio); From 49f7fb2e15ebee4d982630ece9aaa732e1a25d55 Mon Sep 17 00:00:00 2001 From: Calvin Montgomery Date: Tue, 30 Jul 2013 23:21:32 -0400 Subject: [PATCH 19/27] Change the way action log is queried --- acp.js | 6 +++ actionlog.js | 32 +++++++++++++++- api.js | 4 +- www/assets/js/acp.js | 87 ++++++++++++++++++++------------------------ 4 files changed, 80 insertions(+), 49 deletions(-) diff --git a/acp.js b/acp.js index 899a058a..e5993ab6 100644 --- a/acp.js +++ b/acp.js @@ -150,6 +150,12 @@ module.exports = function (Server) { } }); + user.socket.on("acp-actionlog-list", function () { + user.socket.emit("acp-actionlog-list", + ActionLog.getLogTypes() + ); + }); + user.socket.on("acp-actionlog-clear", function(data) { ActionLog.clear(data); ActionLog.record(user.ip, user.name, "acp-actionlog-clear", data); diff --git a/actionlog.js b/actionlog.js index e5c18df4..312a2f74 100644 --- a/actionlog.js +++ b/actionlog.js @@ -105,12 +105,42 @@ exports.tooManyRegistrations = function (ip) { return rows.length > 4; } -exports.readLog = function () { +exports.getLogTypes = function () { + var db = Database.getConnection(); + if(!db) + return false; + + var query = "SELECT DISTINCT action FROM actionlog"; + var result = db.querySync(query); + if(!result) { + Logger.errlog.log("! Failed to read action log"); + return []; + } + + result = result.fetchAllSync(); + var actions = []; + for(var i in result) + actions.push(result[i].action); + + return actions; +} + +exports.readLog = function (actions) { var db = Database.getConnection(); if(!db) return false; var query = "SELECT * FROM actionlog"; + if(actions !== undefined) { + var list = new Array(actions.length); + for(var i in actions) + list[i] = "?"; + list = list.join(","); + query += Database.createQuery( + " WHERE action IN ("+list+")", + actions + ); + } var result = db.querySync(query); if(!result) { Logger.errlog.log("! Failed to read action log"); diff --git a/api.js b/api.js index 03d8ed2c..26a10867 100644 --- a/api.js +++ b/api.js @@ -510,13 +510,15 @@ module.exports = function (Server) { var name = params.name || ""; var pw = params.pw || ""; var session = params.session || ""; + var types = params.actions || ""; var row = Auth.login(name, pw, session); if(!row || row.global_rank < 255) { res.send(403); return; } - var actions = ActionLog.readLog(); + var actiontypes = types.split(","); + var actions = ActionLog.readLog(actiontypes); this.sendJSON(res, actions); }, diff --git a/www/assets/js/acp.js b/www/assets/js/acp.js index e91d1fd8..9cd0a05c 100644 --- a/www/assets/js/acp.js +++ b/www/assets/js/acp.js @@ -70,40 +70,10 @@ $("#listloaded_refresh").click(function() { socket.emit("acp-list-loaded"); }); menuHandler("#show_actionlog", "#actionlog"); -$("#show_actionlog").click(getActionLog); -$("#actionlog_filter").click(function() { - var tbl = $("#actionlog table"); - var actions = $(this).val(); - $("#actionlog tbody").remove(); - var entries = []; - tbl.data("allentries").forEach(function(e) { - if(actions.indexOf(e.action) == -1) - return; - entries.push(e); - }); - $("#actionlog_pagination").remove(); - if(entries.length > 20) { - var pag = $("
").addClass("pagination") - .attr("id", "actionlog_pagination") - .insertAfter($("#actionlog table")); - var btns = $("