Merge branch '3.0' into uws

This commit is contained in:
Calvin Montgomery 2018-07-26 21:02:01 -07:00
commit 17cf6023db
7 changed files with 181 additions and 109 deletions

View File

@ -2,7 +2,7 @@
"author": "Calvin Montgomery", "author": "Calvin Montgomery",
"name": "CyTube", "name": "CyTube",
"description": "Online media synchronizer and chat", "description": "Online media synchronizer and chat",
"version": "3.56.3", "version": "3.56.5",
"repository": { "repository": {
"url": "http://github.com/calzoneman/sync" "url": "http://github.com/calzoneman/sync"
}, },

View File

@ -79,7 +79,9 @@ PollModule.prototype.onUserPostJoin = function (user) {
this.addUserToPollRoom(user); this.addUserToPollRoom(user);
const self = this; const self = this;
user.on("effectiveRankChange", () => { user.on("effectiveRankChange", () => {
self.addUserToPollRoom(user); if (self.channel && !self.channel.dead) {
self.addUserToPollRoom(user);
}
}); });
}; };

View File

@ -386,6 +386,12 @@ function preprocessConfig(cfg) {
return contact.name !== 'calzoneman'; return contact.name !== 'calzoneman';
}); });
if (!cfg.io.throttle) {
cfg.io.throttle = {
'in-rate-limit': Infinity
};
}
return cfg; return cfg;
} }

View File

@ -103,8 +103,14 @@ function translateStatusCode(statusCode) {
"the file to be downloaded."; "the file to be downloaded.";
case 404: case 404:
return "The requested link could not be found (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: case 410:
return "The requested link does not exist (410 Gone)."; 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 500:
case 503: case 503:
return "The website hosting the audio/video link encountered an error " + 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) { if (cookie) {
data.headers = { 'Cookie': cookie }; data.headers = { 'Cookie': cookie };
} }
var req = transport.request(data, function (res) {
req.abort();
if (res.statusCode === 301 || res.statusCode === 302) { try {
if (redirCount > 2) { var req = transport.request(data, function (res) {
return cb("The request for the audio/video file has been redirected " + req.abort();
"more than twice. This could indicate a misconfiguration " +
"on the website hosting the link. For best results, use " + if (res.statusCode === 301 || res.statusCode === 302) {
"a direct link. See https://git.io/vrE75 for details."); 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) { if (res.statusCode !== 200) {
return cb(translateStatusCode(res.statusCode)); return cb(translateStatusCode(res.statusCode));
} }
if (!/^audio|^video/.test(res.headers["content-type"])) { if (!/^audio|^video/.test(res.headers["content-type"])) {
return cb("Expected a content-type starting with 'audio' or 'video', but " + return cb("Expected a content-type starting with 'audio' or 'video', but " +
"got '" + res.headers["content-type"] + "'. Only direct links " + "got '" + res.headers["content-type"] + "'. Only direct links " +
"to video and audio files are accepted, and the website hosting " + "to video and audio files are accepted, and the website hosting " +
"the file must be configured to send the correct MIME type. " + "the file must be configured to send the correct MIME type. " +
"See https://git.io/vrE75 for details."); "See https://git.io/vrE75 for details.");
} }
cb(); cb();
}); });
req.on("error", function (err) { req.on("error", function (err) {
if (/hostname\/ip doesn't match/i.test(err.message)) { if (/hostname\/ip doesn't match/i.test(err.message)) {
cb("The remote server provided an invalid SSL certificate. Details: " cb("The remote server provided an invalid SSL certificate. Details: "
+ err.reason); + err.reason);
return; return;
} else if (ECODE_MESSAGES.hasOwnProperty(err.code)) { } else if (ECODE_MESSAGES.hasOwnProperty(err.code)) {
cb(`${ECODE_MESSAGES[err.code](err)} (error code: ${err.code})`); cb(`${ECODE_MESSAGES[err.code](err)} (error code: ${err.code})`);
return; return;
} }
// HPE_INVALID_CONSTANT comes from node's HTTP parser because // HPE_INVALID_CONSTANT comes from node's HTTP parser because
// facebook's CDN violates RFC 2616 by sending a body even though // facebook's CDN violates RFC 2616 by sending a body even though
// the request uses the HEAD method. // the request uses the HEAD method.
// Avoid logging this because it's a known issue. // Avoid logging this because it's a known issue.
if (!(err.code === 'HPE_INVALID_CONSTANT' && /fbcdn/.test(url))) { if (!(err.code === 'HPE_INVALID_CONSTANT' && /fbcdn/.test(url))) {
LOGGER.error( LOGGER.error(
"Error sending preflight request: %s (code=%s) (link: %s)", "Error sending preflight request: %s (code=%s) (link: %s)",
err.message, err.message,
err.code, err.code,
url 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. " + cb("An unexpected error occurred while trying to process the link. " +
"Try again, and contact support for further troubleshooting if the " + "Try again, and contact support for further troubleshooting if the " +
"problem continues." + (err.code ? (" Error code: " + err.code) : "")); "problem continues.");
}); }
req.end();
} }
function readOldFormat(buf) { function readOldFormat(buf) {

View File

@ -233,6 +233,8 @@ class IOServer {
return; return;
} }
this.setRateLimiter(socket);
emitMetrics(socket); emitMetrics(socket);
LOGGER.info('Accepted socket from %s', socket.context.ipAddress); 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() { initSocketIO() {
patchSocketMetrics(); patchSocketMetrics();
patchTypecheckedFunctions(); patchTypecheckedFunctions();
@ -306,10 +327,12 @@ const outgoingPacketCount = new Counter({
function patchSocketMetrics() { function patchSocketMetrics() {
const onevent = Socket.prototype.onevent; const onevent = Socket.prototype.onevent;
const packet = Socket.prototype.packet; const packet = Socket.prototype.packet;
const emit = require('events').EventEmitter.prototype.emit;
Socket.prototype.onevent = function patchedOnevent() { Socket.prototype.onevent = function patchedOnevent() {
onevent.apply(this, arguments); onevent.apply(this, arguments);
incomingEventCount.inc(1); incomingEventCount.inc(1);
emit.call(this, 'cytube:count-event');
}; };
Socket.prototype.packet = function patchedPacket() { Socket.prototype.packet = function patchedPacket() {

View File

@ -1,16 +1,27 @@
class TokenBucket { class TokenBucket {
constructor(capacity, refillRate) { 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.capacity = capacity;
this.refillRate = refillRate; this.refillRate = refillRate;
this.count = capacity; this.count = capacity();
this.lastRefill = Date.now(); this.lastRefill = Date.now();
} }
throttle() { throttle() {
const now = Date.now(); 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) { if (delta > 0) {
this.count = Math.min(this.capacity, this.count + delta); this.count = Math.min(this.capacity(), this.count + delta);
this.lastRefill = now; this.lastRefill = now;
} }

View File

@ -13,6 +13,7 @@ var Config = require("../config");
var session = require("../session"); var session = require("../session");
var csrf = require("./csrf"); var csrf = require("./csrf");
const url = require("url"); const url = require("url");
import crypto from 'crypto';
const LOGGER = require('@calzoneman/jsli')('web/accounts'); const LOGGER = require('@calzoneman/jsli')('web/accounts');
@ -536,76 +537,91 @@ function handlePasswordReset(req, res) {
return; 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", { sendPug(res, "account-passwordreset", {
reset: false, reset: false,
resetEmail: "", resetEmail: "",
resetErr: "Provided email does not match the email address on record for " + name resetErr: "Provided email does not match the email address on record for " + name
}); });
return; 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)); crypto.randomBytes(20, (err, bytes) => {
// 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) {
if (err) { if (err) {
LOGGER.error(
'Could not generate random bytes for password reset: %s',
err.stack
);
sendPug(res, "account-passwordreset", { sendPug(res, "account-passwordreset", {
reset: false, reset: false,
resetEmail: "", resetEmail: email,
resetErr: err resetErr: "Internal error when generating password reset"
}); });
return; return;
} }
Logger.eventlog.log("[account] " + ip + " requested password recovery for " + var hash = bytes.toString('hex');
name + " <" + email + ">"); // 24-hour expiration
var expire = Date.now() + 86400000;
var ip = req.realIP;
if (!emailConfig.getPasswordReset().isEnabled()) { db.addPasswordReset({
sendPug(res, "account-passwordreset", { ip: ip,
reset: false, name: name,
resetEmail: email, email: actualEmail,
resetErr: "This server does not have mail support enabled. Please " + hash: hash,
"contact an administrator for assistance." expire: expire
}); }, function (err, _dbres) {
return; 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({ if (!emailConfig.getPasswordReset().isEnabled()) {
username: name, sendPug(res, "account-passwordreset", {
address: email, reset: false,
url: `${baseUrl}/account/passwordrecover/${hash}` resetEmail: email,
}).then(_result => { resetErr: "This server does not have mail support enabled. Please " +
sendPug(res, "account-passwordreset", { "contact an administrator for assistance."
reset: true, });
resetEmail: email, return;
resetErr: false }
});
}).catch(error => { const baseUrl = `${req.realProtocol}://${req.header("host")}`;
LOGGER.error("Sending password reset email failed: %s", error);
sendPug(res, "account-passwordreset", { emailController.sendPasswordReset({
reset: false, username: name,
resetEmail: email, address: email,
resetErr: "Sending reset email failed. Please contact an " + url: `${baseUrl}/account/passwordrecover/${hash}`
"administrator for assistance." }).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."
});
}); });
}); });
}); });