diff --git a/package.json b/package.json index 698ae34c..5bf34594 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Calvin Montgomery", "name": "CyTube", "description": "Online media synchronizer and chat", - "version": "3.20.0", + "version": "3.21.0", "repository": { "url": "http://github.com/calzoneman/sync" }, diff --git a/src/channel/chat.js b/src/channel/chat.js index 16204089..4637cd19 100644 --- a/src/channel/chat.js +++ b/src/channel/chat.js @@ -121,6 +121,27 @@ ChatModule.prototype.shadowMutedUsers = function () { }); }; +ChatModule.prototype.restrictNewAccount = function restrictNewAccount(user, data) { + if (user.account.effectiveRank < 2 && this.channel.modules.options) { + const firstSeen = user.getFirstSeenTime(); + const opts = this.channel.modules.options; + if (firstSeen > Date.now() - opts.get("new_user_chat_delay")*1000) { + user.socket.emit("spamFiltered", { + reason: "NEW_USER_CHAT" + }); + return true; + } else if ((firstSeen > Date.now() - opts.get("new_user_chat_link_delay")*1000) + && data.msg.match(LINK)) { + user.socket.emit("spamFiltered", { + reason: "NEW_USER_CHAT_LINK" + }); + return true; + } + } + + return false; +}; + ChatModule.prototype.handleChatMsg = function (user, data) { var self = this; counters.add("chat:incoming"); @@ -131,6 +152,12 @@ ChatModule.prototype.handleChatMsg = function (user, data) { // Limit to 240 characters data.msg = data.msg.substring(0, 240); + + // Restrict new accounts/IPs from chatting and posting links + if (this.restrictNewAccount(user, data)) { + return; + } + // If channel doesn't permit them, strip ASCII control characters if (!this.channel.modules.options || !this.channel.modules.options.get("allow_ascii_control")) { @@ -174,6 +201,11 @@ ChatModule.prototype.handlePm = function (user, data) { }); } + // Restrict new accounts/IPs from chatting and posting links + if (this.restrictNewAccount(user, data)) { + return; + } + if (data.msg.match(Config.get("link-domain-blacklist-regex"))) { this.channel.logger.log(user.displayip + " (" + user.getName() + ") was kicked for " + "blacklisted domain"); diff --git a/src/channel/opts.js b/src/channel/opts.js index 45455324..acabee46 100644 --- a/src/channel/opts.js +++ b/src/channel/opts.js @@ -25,7 +25,9 @@ function OptionsModule(channel) { allow_dupes: false, // Allow duplicate videos on the playlist torbanned: false, // Block connections from Tor exit nodes allow_ascii_control: false,// Allow ASCII control characters (\x00-\x1f) - playlist_max_per_user: 0 // Maximum number of playlist items per user + playlist_max_per_user: 0, // Maximum number of playlist items per user + new_user_chat_delay: 10 * 60, // Minimum account/IP age to chat + new_user_chat_link_delay: 60 * 60 // Minimum account/IP age to post links }; } @@ -271,6 +273,20 @@ OptionsModule.prototype.handleSetOptions = function (user, data) { } } + if ("new_user_chat_delay" in data) { + var delay = data.new_user_chat_delay; + if (!isNaN(delay) && delay >= 0) { + this.opts.new_user_chat_delay = delay; + } + } + + if ("new_user_chat_link_delay" in data) { + var delay = data.new_user_chat_link_delay; + if (!isNaN(delay) && delay >= 0) { + this.opts.new_user_chat_link_delay = delay; + } + } + this.channel.logger.log("[mod] " + user.getName() + " updated channel options"); this.sendOpts(this.channel.users); }; diff --git a/src/database/accounts.js b/src/database/accounts.js index 5128e858..3c0e4846 100644 --- a/src/database/accounts.js +++ b/src/database/accounts.js @@ -134,7 +134,7 @@ module.exports = { callback(err, null); return; } - + if (accts.length >= Config.get("max-accounts-per-ip")) { delete registrationLock[lname]; callback("You have registered too many accounts from this "+ @@ -207,7 +207,7 @@ module.exports = { the hashes match. */ - db.query("SELECT name,password,global_rank FROM `users` WHERE name=?", + db.query("SELECT name,password,global_rank,time FROM `users` WHERE name=?", [name], function (err, rows) { if (err) { diff --git a/src/io/ioserver.js b/src/io/ioserver.js index 9077f891..5afa011c 100644 --- a/src/io/ioserver.js +++ b/src/io/ioserver.js @@ -15,6 +15,7 @@ var crypto = require("crypto"); var isTorExit = require("../tor").isTorExit; var session = require("../session"); import counters from '../counters'; +import { verifyIPSessionCookie } from '../web/middleware/ipsessioncookie'; var CONNECT_RATE = { burst: 5, @@ -25,33 +26,54 @@ var ipThrottle = {}; // Keep track of number of connections per IP var ipCount = {}; +function parseCookies(socket, accept) { + var req = socket.request; + if (req.headers.cookie) { + cookieParser(req, null, () => { + accept(null, true); + }); + } else { + req.cookies = {}; + req.signedCookies = {}; + accept(null, true); + } +} /** * Called before an incoming socket.io connection is accepted. */ function handleAuth(socket, accept) { - var data = socket.request; - socket.user = false; - if (data.headers.cookie) { - cookieParser(data, null, function () { - var auth = data.signedCookies.auth; - if (!auth) { - return accept(null, true); - } - - session.verifySession(auth, function (err, user) { - if (!err) { - socket.user = { - name: user.name, - global_rank: user.global_rank - }; - } - accept(null, true); - }); - }); - } else { - accept(null, true); + var auth = socket.request.signedCookies.auth; + if (!auth) { + return accept(null, true); } + + session.verifySession(auth, function (err, user) { + if (!err) { + socket.user = { + name: user.name, + global_rank: user.global_rank, + registrationTime: new Date(user.time) + }; + } + accept(null, true); + }); +} + +function handleIPSessionCookie(socket, accept) { + var cookie = socket.request.signedCookies['ip-session']; + if (!cookie) { + socket.ipSessionFirstSeen = new Date(); + return accept(null, true); + } + + var sessionMatch = verifyIPSessionCookie(socket._realip, cookie); + if (sessionMatch) { + socket.ipSessionFirstSeen = sessionMatch.date; + } else { + socket.ipSessionFirstSeen = new Date(); + } + accept(null, true); } function throttleIP(sock) { @@ -214,6 +236,7 @@ function handleConnection(sock) { user.setFlag(Flags.U_REGISTERED); user.clearFlag(Flags.U_READY); user.account.name = sock.user.name; + user.registrationTime = sock.user.registrationTime; user.refreshAccount(function (err, account) { if (err) { user.clearFlag(Flags.U_REGISTERED); @@ -247,8 +270,10 @@ module.exports = { }; var io = sio.instance = sio(); - io.use(handleAuth); io.use(ipForwardingMiddleware(webConfig)); + io.use(parseCookies); + io.use(handleIPSessionCookie); + io.use(handleAuth); io.on("connection", handleConnection); Config.get("listen").forEach(function (bind) { diff --git a/src/user.js b/src/user.js index a538c9b2..e2bd66e8 100644 --- a/src/user.js +++ b/src/user.js @@ -288,6 +288,7 @@ User.prototype.login = function (name, pw) { } self.account.name = user.name; + self.registrationTime = new Date(user.time); self.setFlag(Flags.U_REGISTERED); self.refreshAccount(function (err, account) { if (err) { @@ -449,4 +450,18 @@ User.prototype.refreshAccount = function (cb) { }); }; +User.prototype.getFirstSeenTime = function getFirstSeenTime() { + if (this.registrationTime && this.socket.ipSessionFirstSeen) { + return Math.min(this.registrationTime.getTime(), this.socket.ipSessionFirstSeen.getTime()); + } else if (this.registrationTime) { + return this.registrationTime.getTime(); + } else if (this.socket.ipSessionFirstSeen) { + return this.socket.ipSessionFirstSeen.getTime(); + } else { + Logger.errlog.log(`User "${this.getName()}" (IP: ${this.realip}) has neither ` + + "an IP sesion first seen time nor a registered account."); + return Date.now(); + } +}; + module.exports = User; diff --git a/src/web/middleware/ipsessioncookie.js b/src/web/middleware/ipsessioncookie.js new file mode 100644 index 00000000..779c1da2 --- /dev/null +++ b/src/web/middleware/ipsessioncookie.js @@ -0,0 +1,86 @@ +import path from 'path'; +import fs from 'fs'; +import crypto from 'crypto'; + +const STATE_FOLDER_PATH = path.resolve(__dirname, '..', '..', '..', 'state'); +const SALT_PATH = path.resolve(__dirname, '..', '..', '..', 'state', 'ipsessionsalt.json'); + +const NO_EXPIRATION = new Date('Fri, 31 Dec 9999 23:59:59 GMT'); +var SALT; +try { + SALT = require(SALT_PATH); +} catch (error) { + SALT = crypto.randomBytes(32).toString('base64'); + try { + fs.mkdirSync(STATE_FOLDER_PATH); + } catch (error) { + if (error.code !== 'EEXIST') { + throw error; + } + } + fs.writeFileSync(SALT_PATH, JSON.stringify(SALT)); +} + +function sha256(input) { + var hash = crypto.createHash("sha256"); + hash.update(input); + return hash.digest("base64"); +} + +export function createIPSessionCookie(ip, date) { + const hashInput = [ + ip, + date.getTime(), + SALT + ].join(':'); + + return [ + date.getTime(), + sha256(hashInput) + ].join(':'); +} + +export function verifyIPSessionCookie(ip, cookie) { + const parts = cookie.split(':'); + if (parts.length !== 2) { + return false; + } + + const timestamp = parseInt(parts[0], 10); + if (isNaN(timestamp)) { + return false; + } + + const date = new Date(timestamp); + const expected = createIPSessionCookie(ip, date); + if (expected !== cookie) { + return false; + } + + return { + date: date, + }; +} + +export function ipSessionCookieMiddleware(req, res, next) { + var firstSeen = new Date(); + var hasSession = false; + if (req.signedCookies && req.signedCookies['ip-session']) { + var sessionMatch = verifyIPSessionCookie(req.realIP, req.signedCookies['ip-session']); + if (sessionMatch) { + hasSession = true; + firstSeen = sessionMatch.date; + } + } + + if (!hasSession) { + res.cookie('ip-session', createIPSessionCookie(req.realIP, firstSeen), { + signed: true, + httpOnly: true, + expires: NO_EXPIRATION + }); + } + + req.ipSessionFirstSeen = firstSeen; + next(); +} diff --git a/src/web/webserver.js b/src/web/webserver.js index 60ff5f83..8093fa2e 100644 --- a/src/web/webserver.js +++ b/src/web/webserver.js @@ -146,6 +146,7 @@ module.exports = { } app.use(cookieParser(webConfig.getCookieSecret())); app.use(csrf.init(webConfig.getCookieDomain())); + app.use('/r/:channel', require('./middleware/ipsessioncookie').ipSessionCookieMiddleware); initializeLog(app); require('./middleware/authorize')(app, session); diff --git a/templates/channeloptions.pug b/templates/channeloptions.pug index dfff94e1..6b7e69d8 100644 --- a/templates/channeloptions.pug +++ b/templates/channeloptions.pug @@ -46,6 +46,15 @@ mixin textbox-auto(id, label, placeholder) else input.form-control.cs-textbox(id=id, type="text") +mixin textbox-timeinput-auto(id, label, placeholder) + .form-group + label.control-label.col-sm-4(for=id)= label + .col-sm-8 + if placeholder + input.form-control.cs-textbox-timeinput(id=id, type="text", placeholder=placeholder) + else + input.form-control.cs-textbox-timeinput(id=id, type="text") + mixin miscoptions #cs-miscoptions.tab-pane.active h4 General Settings @@ -63,6 +72,11 @@ mixin miscoptions +rcheckbox-auto("cs-chat_antiflood", "Throttle chat") +textbox-auto("cs-chat_antiflood_burst", "# of messages allowed before throttling") +textbox-auto("cs-chat_antiflood_sustained", "# of messages (after burst) allowed per second") + +textbox-timeinput-auto("cs-new_user_chat_delay", "Delay before new accounts can chat", "0") + .form-group + .col-sm-8.col-sm-offset-4 + span.text-info Restrictions to new accounts can be disabled by setting the delay to 0. + +textbox-timeinput-auto("cs-new_user_chat_link_delay", "Delay before new accounts can post links in chat", "0") .form-group .col-sm-8.col-sm-offset-4 span.text-info Changes are automatically saved. diff --git a/www/js/callbacks.js b/www/js/callbacks.js index e894faa4..6b89328e 100644 --- a/www/js/callbacks.js +++ b/www/js/callbacks.js @@ -88,6 +88,22 @@ Callbacks = { scrollChat(); }, + spamFiltered: function(data) { + var message = "Spam Filtered."; + switch (data.reason) { + case "NEW_USER_CHAT": + message = "Your account is too new to chat in this channel. " + + "Please wait a while and try again."; + break; + case "NEW_USER_CHAT_LINK": + message = "Your account is too new to post links in this channel. " + + "Please wait a while and try again."; + break; + } + + errDialog(message); + }, + needPassword: function (wrongpw) { var div = $("
"); $("").text("Channel Password") diff --git a/www/js/ui.js b/www/js/ui.js index a5410905..2e6b6869 100644 --- a/www/js/ui.js +++ b/www/js/ui.js @@ -636,6 +636,36 @@ $(".cs-textbox").keyup(function () { }, 1000); }); +$(".cs-textbox-timeinput").keyup(function (event) { + var box = $(this); + var key = box.attr("id").replace("cs-", ""); + var value = box.val(); + var lastkey = Date.now(); + box.data("lastkey", lastkey); + + setTimeout(function () { + if (box.data("lastkey") !== lastkey || box.val() !== value) { + return; + } + + $("#cs-textbox-timeinput-validation-error-" + key).remove(); + $(event.target).parent().removeClass("has-error"); + var data = {}; + try { + data[key] = parseTimeout(value); + } catch (error) { + var msg = "Invalid timespan value '" + value + "'. Please use the format " + + "HH:MM:SS or enter a single number for the number of seconds."; + var validationError = $("

").addClass("text-danger").text(msg) + .attr("id", "cs-textbox-timeinput-validation-error-" + key); + validationError.insertAfter(event.target); + $(event.target).parent().addClass("has-error"); + return; + } + socket.emit("setOptions", data); + }, 1000); +}); + $("#cs-chanlog-refresh").click(function () { socket.emit("readChanLog"); }); diff --git a/www/js/util.js b/www/js/util.js index 166e539a..fa38aa54 100644 --- a/www/js/util.js +++ b/www/js/util.js @@ -761,18 +761,23 @@ function applyOpts() { } } -function showPollMenu() { - function parseTimeout(t) { - var m; - if (m = t.match(/^(\d+):(\d+)$/)) { - return parseInt(m[1], 10) * 60 + parseInt(m[2], 10); - } else if (m = t.match(/^(\d+)$/)) { - return parseInt(m[1], 10); - } else { - throw new Error("Invalid timeout value '" + t + "'"); - } +function parseTimeout(t) { + var m; + if (m = t.match(/^(\d+):(\d+):(\d+)$/)) { + // HH:MM:SS + return parseInt(m[1], 10) * 3600 + parseInt(m[2], 10) * 60 + parseInt(m[3], 10); + } else if (m = t.match(/^(\d+):(\d+)$/)) { + // MM:SS + return parseInt(m[1], 10) * 60 + parseInt(m[2], 10); + } else if (m = t.match(/^(\d+)$/)) { + // Seconds + return parseInt(m[1], 10); + } else { + throw new Error("Invalid timeout value '" + t + "'"); } +} +function showPollMenu() { $("#pollwrap .poll-menu").remove(); var menu = $("

").addClass("well poll-menu") .prependTo($("#pollwrap")); @@ -932,6 +937,8 @@ function handleModPermissions() { $("#cs-torbanned").prop("checked", CHANNEL.opts.torbanned); $("#cs-allow_ascii_control").prop("checked", CHANNEL.opts.allow_ascii_control); $("#cs-playlist_max_per_user").val(CHANNEL.opts.playlist_max_per_user || 0); + $("#cs-new_user_chat_delay").val(formatTime(CHANNEL.opts.new_user_chat_delay || 0)); + $("#cs-new_user_chat_link_delay").val(formatTime(CHANNEL.opts.new_user_chat_link_delay || 0)); (function() { if(typeof CHANNEL.opts.maxlength != "number") { $("#cs-maxlength").val("");