diff --git a/package.json b/package.json index 2df98f07..d3dd2c32 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Calvin Montgomery", "name": "CyTube", "description": "Online media synchronizer and chat", - "version": "3.56.3", + "version": "3.56.5", "repository": { "url": "http://github.com/calzoneman/sync" }, diff --git a/src/channel/poll.js b/src/channel/poll.js index 26a1ec3a..c181a7ab 100644 --- a/src/channel/poll.js +++ b/src/channel/poll.js @@ -79,7 +79,9 @@ PollModule.prototype.onUserPostJoin = function (user) { this.addUserToPollRoom(user); const self = this; user.on("effectiveRankChange", () => { - self.addUserToPollRoom(user); + if (self.channel && !self.channel.dead) { + self.addUserToPollRoom(user); + } }); }; diff --git a/src/config.js b/src/config.js index 8d08801f..d54f378d 100644 --- a/src/config.js +++ b/src/config.js @@ -386,6 +386,12 @@ function preprocessConfig(cfg) { return contact.name !== 'calzoneman'; }); + if (!cfg.io.throttle) { + cfg.io.throttle = { + 'in-rate-limit': Infinity + }; + } + return cfg; } diff --git a/src/ffmpeg.js b/src/ffmpeg.js index 99895951..64caf02d 100644 --- a/src/ffmpeg.js +++ b/src/ffmpeg.js @@ -103,8 +103,14 @@ function translateStatusCode(statusCode) { "the file to be downloaded."; case 404: return "The requested link could not be found (404)."; + case 405: + return "The website hosting the link does not support HEAD requests, " + + "so the link could not be retrieved."; case 410: return "The requested link does not exist (410 Gone)."; + case 501: + return "The requested link could not be retrieved because the server " + + "hosting it does not support CyTube's request."; case 500: case 503: return "The website hosting the audio/video link encountered an error " + @@ -143,68 +149,76 @@ function testUrl(url, cb, params = { redirCount: 0, cookie: '' }) { if (cookie) { data.headers = { 'Cookie': cookie }; } - var req = transport.request(data, function (res) { - req.abort(); - if (res.statusCode === 301 || res.statusCode === 302) { - if (redirCount > 2) { - return cb("The request for the audio/video file has been redirected " + - "more than twice. This could indicate a misconfiguration " + - "on the website hosting the link. For best results, use " + - "a direct link. See https://git.io/vrE75 for details."); + try { + var req = transport.request(data, function (res) { + req.abort(); + + if (res.statusCode === 301 || res.statusCode === 302) { + if (redirCount > 2) { + return cb("The request for the audio/video file has been redirected " + + "more than twice. This could indicate a misconfiguration " + + "on the website hosting the link. For best results, use " + + "a direct link. See https://git.io/vrE75 for details."); + } + const nextParams = { + redirCount: redirCount + 1, + cookie: cookie + getCookie(res) + }; + return testUrl(fixRedirectIfNeeded(data, res.headers["location"]), cb, + nextParams); } - const nextParams = { - redirCount: redirCount + 1, - cookie: cookie + getCookie(res) - }; - return testUrl(fixRedirectIfNeeded(data, res.headers["location"]), cb, - nextParams); - } - if (res.statusCode !== 200) { - return cb(translateStatusCode(res.statusCode)); - } + if (res.statusCode !== 200) { + return cb(translateStatusCode(res.statusCode)); + } - if (!/^audio|^video/.test(res.headers["content-type"])) { - return cb("Expected a content-type starting with 'audio' or 'video', but " + - "got '" + res.headers["content-type"] + "'. Only direct links " + - "to video and audio files are accepted, and the website hosting " + - "the file must be configured to send the correct MIME type. " + - "See https://git.io/vrE75 for details."); - } + if (!/^audio|^video/.test(res.headers["content-type"])) { + return cb("Expected a content-type starting with 'audio' or 'video', but " + + "got '" + res.headers["content-type"] + "'. Only direct links " + + "to video and audio files are accepted, and the website hosting " + + "the file must be configured to send the correct MIME type. " + + "See https://git.io/vrE75 for details."); + } - cb(); - }); + cb(); + }); - req.on("error", function (err) { - if (/hostname\/ip doesn't match/i.test(err.message)) { - cb("The remote server provided an invalid SSL certificate. Details: " - + err.reason); - return; - } else if (ECODE_MESSAGES.hasOwnProperty(err.code)) { - cb(`${ECODE_MESSAGES[err.code](err)} (error code: ${err.code})`); - return; - } + req.on("error", function (err) { + if (/hostname\/ip doesn't match/i.test(err.message)) { + cb("The remote server provided an invalid SSL certificate. Details: " + + err.reason); + return; + } else if (ECODE_MESSAGES.hasOwnProperty(err.code)) { + cb(`${ECODE_MESSAGES[err.code](err)} (error code: ${err.code})`); + return; + } - // HPE_INVALID_CONSTANT comes from node's HTTP parser because - // facebook's CDN violates RFC 2616 by sending a body even though - // the request uses the HEAD method. - // Avoid logging this because it's a known issue. - if (!(err.code === 'HPE_INVALID_CONSTANT' && /fbcdn/.test(url))) { - LOGGER.error( - "Error sending preflight request: %s (code=%s) (link: %s)", - err.message, - err.code, - url - ); - } + // HPE_INVALID_CONSTANT comes from node's HTTP parser because + // facebook's CDN violates RFC 2616 by sending a body even though + // the request uses the HEAD method. + // Avoid logging this because it's a known issue. + if (!(err.code === 'HPE_INVALID_CONSTANT' && /fbcdn/.test(url))) { + LOGGER.error( + "Error sending preflight request: %s (code=%s) (link: %s)", + err.message, + err.code, + url + ); + } + cb("An unexpected error occurred while trying to process the link. " + + "Try again, and contact support for further troubleshooting if the " + + "problem continues." + (err.code ? (" Error code: " + err.code) : "")); + }); + + req.end(); + } catch (error) { + LOGGER.error('Unable to make raw file probe request: %s', error.stack); cb("An unexpected error occurred while trying to process the link. " + "Try again, and contact support for further troubleshooting if the " + - "problem continues." + (err.code ? (" Error code: " + err.code) : "")); - }); - - req.end(); + "problem continues."); + } } function readOldFormat(buf) { diff --git a/src/io/ioserver.js b/src/io/ioserver.js index f392d8c9..35111299 100644 --- a/src/io/ioserver.js +++ b/src/io/ioserver.js @@ -233,6 +233,8 @@ class IOServer { return; } + this.setRateLimiter(socket); + emitMetrics(socket); LOGGER.info('Accepted socket from %s', socket.context.ipAddress); @@ -250,6 +252,25 @@ class IOServer { } } + setRateLimiter(socket) { + const thunk = () => Config.get('io.throttle.in-rate-limit'); + + socket._inRateLimit = new TokenBucket(thunk, thunk); + + socket.on('cytube:count-event', () => { + if (socket._inRateLimit.throttle()) { + LOGGER.warn( + 'Kicking client %s: exceeded in-rate-limit of %d', + socket.context.ipAddress, + thunk() + ); + + socket.emit('kick', { reason: 'Rate limit exceeded' }); + socket.disconnect(); + } + }); + } + initSocketIO() { patchSocketMetrics(); patchTypecheckedFunctions(); @@ -306,10 +327,12 @@ const outgoingPacketCount = new Counter({ function patchSocketMetrics() { const onevent = Socket.prototype.onevent; const packet = Socket.prototype.packet; + const emit = require('events').EventEmitter.prototype.emit; Socket.prototype.onevent = function patchedOnevent() { onevent.apply(this, arguments); incomingEventCount.inc(1); + emit.call(this, 'cytube:count-event'); }; Socket.prototype.packet = function patchedPacket() { diff --git a/src/util/token-bucket.js b/src/util/token-bucket.js index c71e97a5..1fb84025 100644 --- a/src/util/token-bucket.js +++ b/src/util/token-bucket.js @@ -1,16 +1,27 @@ class TokenBucket { constructor(capacity, refillRate) { + if (typeof refillRate !== 'function') { + const _refillRate = refillRate; + refillRate = () => _refillRate; + } + if (typeof capacity !== 'function') { + const _capacity = capacity; + capacity = () => _capacity; + } + this.capacity = capacity; this.refillRate = refillRate; - this.count = capacity; + this.count = capacity(); this.lastRefill = Date.now(); } throttle() { const now = Date.now(); - const delta = Math.floor((now - this.lastRefill) / 1000 * this.refillRate); + const delta = Math.floor( + (now - this.lastRefill) / 1000 * this.refillRate() + ); if (delta > 0) { - this.count = Math.min(this.capacity, this.count + delta); + this.count = Math.min(this.capacity(), this.count + delta); this.lastRefill = now; } diff --git a/src/web/account.js b/src/web/account.js index a6a821e9..41bf6733 100644 --- a/src/web/account.js +++ b/src/web/account.js @@ -13,6 +13,7 @@ var Config = require("../config"); var session = require("../session"); var csrf = require("./csrf"); const url = require("url"); +import crypto from 'crypto'; const LOGGER = require('@calzoneman/jsli')('web/accounts'); @@ -536,76 +537,91 @@ function handlePasswordReset(req, res) { return; } - if (actualEmail !== email.trim()) { + if (actualEmail === '') { + sendPug(res, "account-passwordreset", { + reset: false, + resetEmail: "", + resetErr: `Username ${name} cannot be recovered because it ` + + "doesn't have an email address associated with it." + }); + return; + } else if (actualEmail.toLowerCase() !== email.trim().toLowerCase()) { sendPug(res, "account-passwordreset", { reset: false, resetEmail: "", resetErr: "Provided email does not match the email address on record for " + name }); return; - } else if (actualEmail === "") { - sendPug(res, "account-passwordreset", { - reset: false, - resetEmail: "", - resetErr: name + " doesn't have an email address on record. Please contact an " + - "administrator to manually reset your password." - }); - return; } - var hash = $util.sha1($util.randomSalt(64)); - // 24-hour expiration - var expire = Date.now() + 86400000; - var ip = req.realIP; - - db.addPasswordReset({ - ip: ip, - name: name, - email: email, - hash: hash, - expire: expire - }, function (err, _dbres) { + crypto.randomBytes(20, (err, bytes) => { if (err) { + LOGGER.error( + 'Could not generate random bytes for password reset: %s', + err.stack + ); sendPug(res, "account-passwordreset", { reset: false, - resetEmail: "", - resetErr: err + resetEmail: email, + resetErr: "Internal error when generating password reset" }); return; } - Logger.eventlog.log("[account] " + ip + " requested password recovery for " + - name + " <" + email + ">"); + var hash = bytes.toString('hex'); + // 24-hour expiration + var expire = Date.now() + 86400000; + var ip = req.realIP; - if (!emailConfig.getPasswordReset().isEnabled()) { - sendPug(res, "account-passwordreset", { - reset: false, - resetEmail: email, - resetErr: "This server does not have mail support enabled. Please " + - "contact an administrator for assistance." - }); - return; - } + db.addPasswordReset({ + ip: ip, + name: name, + email: actualEmail, + hash: hash, + expire: expire + }, function (err, _dbres) { + if (err) { + sendPug(res, "account-passwordreset", { + reset: false, + resetEmail: "", + resetErr: err + }); + return; + } - const baseUrl = `${req.realProtocol}://${req.header("host")}`; + Logger.eventlog.log("[account] " + ip + " requested password recovery for " + + name + " <" + email + ">"); - emailController.sendPasswordReset({ - username: name, - address: email, - url: `${baseUrl}/account/passwordrecover/${hash}` - }).then(_result => { - sendPug(res, "account-passwordreset", { - reset: true, - resetEmail: email, - resetErr: false - }); - }).catch(error => { - LOGGER.error("Sending password reset email failed: %s", error); - sendPug(res, "account-passwordreset", { - reset: false, - resetEmail: email, - resetErr: "Sending reset email failed. Please contact an " + - "administrator for assistance." + if (!emailConfig.getPasswordReset().isEnabled()) { + sendPug(res, "account-passwordreset", { + reset: false, + resetEmail: email, + resetErr: "This server does not have mail support enabled. Please " + + "contact an administrator for assistance." + }); + return; + } + + const baseUrl = `${req.realProtocol}://${req.header("host")}`; + + emailController.sendPasswordReset({ + username: name, + address: email, + url: `${baseUrl}/account/passwordrecover/${hash}` + }).then(_result => { + sendPug(res, "account-passwordreset", { + reset: true, + resetEmail: email, + resetErr: false + }); + }).catch(error => { + LOGGER.error("Sending password reset email failed: %s", error); + sendPug(res, "account-passwordreset", { + reset: false, + resetEmail: email, + resetErr: "Sending reset email failed. Please contact an " + + "administrator for assistance." + }); }); }); });