init commit

This commit is contained in:
rainbownapkin 2021-12-06 19:56:40 -05:00
parent ae639426d0
commit 7a491681cc
257 changed files with 95524 additions and 80 deletions

59
src/account.js Normal file
View file

@ -0,0 +1,59 @@
import db from './database';
import Promise from 'bluebird';
const dbGetGlobalRank = Promise.promisify(db.users.getGlobalRank);
const dbMultiGetGlobalRank = Promise.promisify(db.users.getGlobalRanks);
const dbGetChannelRank = Promise.promisify(db.channels.getRank);
const dbMultiGetChannelRank = Promise.promisify(db.channels.getRanks);
const dbGetAliases = Promise.promisify(db.getAliases);
const DEFAULT_PROFILE = Object.freeze({ image: '', text: '' });
class Account {
constructor(ip, user, aliases) {
this.ip = ip;
this.user = user;
this.aliases = aliases;
this.channelRank = -1;
this.guestName = null;
this.update();
}
update() {
if (this.user !== null) {
this.name = this.user.name;
this.globalRank = this.user.global_rank;
} else if (this.guestName !== null) {
this.name = this.guestName;
this.globalRank = 0;
} else {
this.name = '';
this.globalRank = -1;
}
this.lowername = this.name.toLowerCase();
this.effectiveRank = Math.max(this.channelRank, this.globalRank);
this.profile = (this.user === null) ? DEFAULT_PROFILE : this.user.profile;
}
}
module.exports.Account = Account;
module.exports.rankForName = async function rankForNameAsync(name, channel) {
const [globalRank, channelRank] = await Promise.all([
dbGetGlobalRank(name),
dbGetChannelRank(channel, name)
]);
return Math.max(globalRank, channelRank);
};
module.exports.rankForIP = async function rankForIP(ip, channel) {
const aliases = await dbGetAliases(ip);
const [globalRanks, channelRanks] = await Promise.all([
dbMultiGetGlobalRank(aliases),
dbMultiGetChannelRank(channel, aliases)
]);
return Math.max.apply(Math, globalRanks.concat(channelRanks));
};

285
src/acp.js Normal file
View file

@ -0,0 +1,285 @@
var Logger = require("./logger");
var Server = require("./server");
var db = require("./database");
var util = require("./utilities");
import { v4 as uuidv4 } from 'uuid';
function eventUsername(user) {
return user.getName() + "@" + user.realip;
}
function handleAnnounce(user, data) {
var sv = Server.getServer();
sv.announce({
id: uuidv4(),
title: data.title,
text: data.content,
from: user.getName()
});
Logger.eventlog.log("[acp] " + eventUsername(user) + " opened announcement `" +
data.title + "`");
}
function handleAnnounceClear(user) {
Server.getServer().announce(null);
Logger.eventlog.log("[acp] " + eventUsername(user) + " cleared announcement");
}
function handleGlobalBan(user, data) {
const globalBanDB = db.getGlobalBanDB();
globalBanDB.addGlobalIPBan(data.ip, data.note).then(() => {
Logger.eventlog.log("[acp] " + eventUsername(user) + " global banned " + data.ip);
return globalBanDB.listGlobalBans().then(bans => {
// Why is it called reason in the DB and note in the socket frame?
// Who knows...
const mappedBans = bans.map(ban => {
return { ip: ban.ip, note: ban.reason };
});
user.socket.emit("acp-gbanlist", mappedBans);
});
}).catch(error => {
user.socket.emit("errMessage", {
msg: error.message
});
});
}
function handleGlobalBanDelete(user, data) {
const globalBanDB = db.getGlobalBanDB();
globalBanDB.removeGlobalIPBan(data.ip).then(() => {
Logger.eventlog.log("[acp] " + eventUsername(user) + " un-global banned " +
data.ip);
return globalBanDB.listGlobalBans().then(bans => {
// Why is it called reason in the DB and note in the socket frame?
// Who knows...
const mappedBans = bans.map(ban => {
return { ip: ban.ip, note: ban.reason };
});
user.socket.emit("acp-gbanlist", mappedBans);
});
}).catch(error => {
user.socket.emit("errMessage", {
msg: error.message
});
});
}
function handleListUsers(user, data) {
var value = data.value;
var field = data.field;
value = (typeof value !== 'string') ? '' : value;
field = (typeof field !== 'string') ? 'name' : field;
var fields = ["id", "name", "global_rank", "email", "ip", "time"];
if(!fields.includes(field)){
user.socket.emit("errMessage", {
msg: `The field "${field}" doesn't exist or isn't searchable.`
});
return;
}
db.users.search(field, value, fields, function (err, users) {
if (err) {
user.socket.emit("errMessage", {
msg: err
});
return;
}
user.socket.emit("acp-list-users", users);
});
}
function handleSetRank(user, data) {
var name = data.name;
var rank = data.rank;
if (typeof name !== "string" || typeof rank !== "number") {
return;
}
if (rank >= user.global_rank) {
user.socket.emit("errMessage", {
msg: "You are not permitted to promote others to equal or higher rank than " +
"yourself."
});
return;
}
db.users.getGlobalRank(name, function (err, oldrank) {
if (err) {
user.socket.emit("errMessage", {
msg: err
});
return;
}
if (oldrank >= user.global_rank) {
user.socket.emit("errMessage", {
msg: "You are not permitted to change the rank of users who rank " +
"higher than you."
});
return;
}
db.users.setGlobalRank(name, rank, function (err) {
if (err) {
user.socket.emit("errMessage", {
msg: err
});
} else {
Logger.eventlog.log("[acp] " + eventUsername(user) + " set " + name +
"'s global_rank to " + rank);
user.socket.emit("acp-set-rank", data);
}
});
});
}
function handleResetPassword(user, data, ack) {
var name = data.name;
var email = data.email;
if (typeof name !== "string" || typeof email !== "string") {
return;
}
db.users.getGlobalRank(name, function (err, rank) {
if (rank >= user.global_rank) {
user.socket.emit("errMessage", {
msg: "You don't have permission to reset the password for " + name
});
return;
}
var hash = util.sha1(util.randomSalt(64));
var expire = Date.now() + 86400000;
db.addPasswordReset({
ip: "",
name: name,
email: email,
hash: hash,
expire: expire
}, function (err) {
if (err) {
ack && ack({ error: err });
return;
}
Logger.eventlog.log("[acp] " + eventUsername(user) + " initialized a " +
"password recovery for " + name);
ack && ack({ hash });
});
});
}
function handleListChannels(user, data) {
var field = data.field;
var value = data.value;
if (typeof field !== "string" || typeof value !== "string") {
return;
}
var dbfunc;
if (field === "owner") {
dbfunc = db.channels.searchOwner;
} else {
dbfunc = db.channels.search;
}
dbfunc(value, function (err, rows) {
if (err) {
user.socket.emit("errMessage", {
msg: err
});
return;
}
user.socket.emit("acp-list-channels", rows);
});
}
function handleDeleteChannel(user, data) {
var name = data.name;
if (typeof data.name !== "string") {
return;
}
var sv = Server.getServer();
if (sv.isChannelLoaded(name)) {
sv.getChannel(name).users.forEach(function (u) {
u.kick("Channel shutting down");
});
}
db.channels.drop(name, function (err) {
Logger.eventlog.log("[acp] " + eventUsername(user) + " deleted channel " + name);
if (err) {
user.socket.emit("errMessage", {
msg: err
});
} else {
user.socket.emit("acp-delete-channel", {
name: name
});
}
});
}
function handleListActiveChannels(user) {
user.socket.emit("acp-list-activechannels", Server.getServer().packChannelList(false, true));
}
function handleForceUnload(user, data) {
var name = data.name;
if (typeof name !== "string") {
return;
}
var sv = Server.getServer();
if (!sv.isChannelLoaded(name)) {
return;
}
var chan = sv.getChannel(name);
var users = Array.prototype.slice.call(chan.users);
chan.emit("empty");
users.forEach(function (u) {
u.kick("Channel shutting down");
});
Logger.eventlog.log("[acp] " + eventUsername(user) + " forced unload of " + name);
}
function init(user) {
var s = user.socket;
s.on("acp-announce", handleAnnounce.bind(this, user));
s.on("acp-announce-clear", handleAnnounceClear.bind(this, user));
s.on("acp-gban", handleGlobalBan.bind(this, user));
s.on("acp-gban-delete", handleGlobalBanDelete.bind(this, user));
s.on("acp-list-users", handleListUsers.bind(this, user));
s.on("acp-set-rank", handleSetRank.bind(this, user));
s.on("acp-reset-password", handleResetPassword.bind(this, user));
s.on("acp-list-channels", handleListChannels.bind(this, user));
s.on("acp-delete-channel", handleDeleteChannel.bind(this, user));
s.on("acp-list-activechannels", handleListActiveChannels.bind(this, user));
s.on("acp-force-unload", handleForceUnload.bind(this, user));
const globalBanDB = db.getGlobalBanDB();
globalBanDB.listGlobalBans().then(bans => {
// Why is it called reason in the DB and note in the socket frame?
// Who knows...
const mappedBans = bans.map(ban => {
return { ip: ban.ip, note: ban.reason };
});
user.socket.emit("acp-gbanlist", mappedBans);
}).catch(error => {
user.socket.emit("errMessage", {
msg: error.message
});
});
Logger.eventlog.log("[acp] Initialized ACP for " + eventUsername(user));
}
module.exports.init = init;

54
src/asyncqueue.js Normal file
View file

@ -0,0 +1,54 @@
var AsyncQueue = function () {
this._q = [];
this._lock = false;
this._tm = 0;
};
AsyncQueue.prototype.next = function () {
if (this._q.length > 0) {
if (!this.lock())
return;
var item = this._q.shift();
var fn = item[0];
this._tm = Date.now() + item[1];
fn(this);
}
};
AsyncQueue.prototype.lock = function () {
if (this._lock) {
if (this._tm > 0 && Date.now() > this._tm) {
this._tm = 0;
return true;
}
return false;
}
this._lock = true;
return true;
};
AsyncQueue.prototype.release = function () {
var self = this;
if (!self._lock)
return false;
self._lock = false;
setImmediate(function () {
self.next();
});
return true;
};
AsyncQueue.prototype.queue = function (fn) {
var self = this;
self._q.push([fn, 20000]);
self.next();
};
AsyncQueue.prototype.reset = function () {
this._q = [];
this._lock = false;
};
module.exports = AsyncQueue;

106
src/bgtask.js Normal file
View file

@ -0,0 +1,106 @@
/*
bgtask.js
Registers background jobs to run periodically while the server is
running.
*/
var Config = require("./config");
var db = require("./database");
var Promise = require("bluebird");
const LOGGER = require('@calzoneman/jsli')('bgtask');
var init = null;
/* Alias cleanup */
function initAliasCleanup() {
var CLEAN_INTERVAL = parseInt(Config.get("aliases.purge-interval"));
var CLEAN_EXPIRE = parseInt(Config.get("aliases.max-age"));
setInterval(function () {
db.cleanOldAliases(CLEAN_EXPIRE, function (err) {
LOGGER.info("Cleaned old aliases");
if (err)
LOGGER.error(err);
});
}, CLEAN_INTERVAL);
}
/* Password reset cleanup */
function initPasswordResetCleanup() {
var CLEAN_INTERVAL = 8*60*60*1000;
setInterval(function () {
db.cleanOldPasswordResets(function (err) {
if (err)
LOGGER.error(err);
});
}, CLEAN_INTERVAL);
}
function initChannelDumper(Server) {
const chanPath = Config.get('channel-path');
var CHANNEL_SAVE_INTERVAL = parseInt(Config.get("channel-save-interval"))
* 60000;
setInterval(function () {
if (Server.channels.length === 0) {
return;
}
var wait = CHANNEL_SAVE_INTERVAL / Server.channels.length;
LOGGER.info(`Saving channels with delay ${wait}`);
Promise.reduce(Server.channels, (_, chan) => {
return Promise.delay(wait).then(async () => {
if (!chan.dead && chan.users && chan.users.length > 0) {
try {
await chan.saveState();
LOGGER.info(`Saved /${chanPath}/${chan.name}`);
} catch (error) {
LOGGER.error(
'Failed to save /%s/%s: %s',
chanPath,
chan ? chan.name : '<undefined>',
error.stack
);
}
}
}).catch(error => {
LOGGER.error(`Failed to save channel: ${error.stack}`);
});
}, 0).catch(error => {
LOGGER.error(`Failed to save channels: ${error.stack}`);
});
}, CHANNEL_SAVE_INTERVAL);
}
function initAccountCleanup() {
setInterval(() => {
(async () => {
let rows = await db.users.findAccountsPendingDeletion();
for (let row of rows) {
try {
await db.users.purgeAccount(row.id);
LOGGER.info('Purged account from request %j', row);
} catch (error) {
LOGGER.error('Error purging account %j: %s', row, error.stack);
}
}
})().catch(error => {
LOGGER.error('Error purging deleted accounts: %s', error.stack);
});
}, 3600 * 1000);
}
module.exports = function (Server) {
if (init === Server) {
LOGGER.warn("Attempted to re-init background tasks");
return;
}
init = Server;
initAliasCleanup();
initChannelDumper(Server);
initPasswordResetCleanup();
initAccountCleanup();
};

46
src/camo.js Normal file
View file

@ -0,0 +1,46 @@
import crypto from 'crypto';
import * as urlparse from 'url';
const LOGGER = require('@calzoneman/jsli')('camo');
function isWhitelisted(camoConfig, url) {
const whitelistedDomains = camoConfig.getWhitelistedDomainsRegexp();
const parsed = urlparse.parse(url);
return whitelistedDomains.test('.' + parsed.hostname);
}
export function camoify(camoConfig, url) {
if (typeof url !== 'string') {
throw new TypeError(`camoify expected a string, not [${url}]`);
}
if (isWhitelisted(camoConfig, url)) {
return url.replace(/^http:/, 'https:');
}
const hmac = crypto.createHmac('sha1', camoConfig.getKey());
hmac.update(url);
const digest = hmac.digest('hex');
// https://github.com/atmos/camo#url-formats
if (camoConfig.getEncoding() === 'hex') {
const hexUrl = Buffer.from(url, 'utf8').toString('hex');
return `${camoConfig.getServer()}/${digest}/${hexUrl}`;
} else {
const encoded = encodeURIComponent(url);
return `${camoConfig.getServer()}/${digest}?url=${encoded}`;
}
}
export function transformImgTags(camoConfig, tagName, attribs) {
if (typeof attribs.src === 'string') {
try {
const oldSrc = attribs.src;
attribs.src = camoify(camoConfig, attribs.src);
LOGGER.debug('Camoified "%s" to "%s"', oldSrc, attribs.src);
} catch (error) {
LOGGER.error(`Failed to generate camo URL for "${attribs.src}": ${error}`);
}
}
return { tagName, attribs };
}

View file

@ -0,0 +1,36 @@
import { DatabaseStore } from './dbstore';
import Config from '../config';
import Promise from 'bluebird';
var CHANNEL_STORE = null;
export function init() {
CHANNEL_STORE = loadChannelStore();
}
export function load(id, channelName) {
if (CHANNEL_STORE === null) {
return Promise.reject(new Error('ChannelStore not initialized yet'));
}
return CHANNEL_STORE.load(id, channelName);
}
export function save(id, channelName, data) {
if (CHANNEL_STORE === null) {
return Promise.reject(new Error('ChannelStore not initialized yet'));
}
return CHANNEL_STORE.save(id, channelName, data);
}
function loadChannelStore() {
if (Config.get('channel-storage.type') === 'file') {
throw new Error(
'channel-storage type "file" is no longer supported. Please see ' +
'NEWS.md for instructions on upgrading.'
);
}
return new DatabaseStore();
}

View file

@ -0,0 +1,24 @@
import Promise from 'bluebird';
import Config from '../config';
import db from '../database';
import { DatabaseStore } from './dbstore';
/* eslint no-console: off */
function main() {
Config.load('config.yaml');
db.init();
const dbStore = new DatabaseStore();
Promise.delay(1000).then(() => {
return dbStore.load(process.argv[2]);
}).then((data) => {
console.log(JSON.stringify(data, null, 4));
process.exit(0);
}).catch((err) => {
console.error(`Error retrieving channel data: ${err.stack}`);
process.exit(1);
});
}
main();

View file

@ -0,0 +1,122 @@
import Promise from 'bluebird';
import { ChannelStateSizeError } from '../errors';
import db from '../database';
import { Counter } from 'prom-client';
const LOGGER = require('@calzoneman/jsli')('dbstore');
const SIZE_LIMIT = 1048576;
const QUERY_CHANNEL_DATA = 'SELECT `key`, `value` FROM channel_data WHERE channel_id = ?';
const loadRowcount = new Counter({
name: 'cytube_channel_db_load_rows_total',
help: 'Total rows loaded from the channel_data table'
});
const loadCharcount = new Counter({
name: 'cytube_channel_db_load_chars_total',
help: 'Total characters (JSON length) loaded from the channel_data table'
});
const saveRowcount = new Counter({
name: 'cytube_channel_db_save_rows_total',
help: 'Total rows saved in the channel_data table'
});
const saveCharcount = new Counter({
name: 'cytube_channel_db_save_chars_total',
help: 'Total characters (JSON length) saved in the channel_data table'
});
function queryAsync(query, substitutions) {
return new Promise((resolve, reject) => {
db.query(query, substitutions, (err, res) => {
if (err) {
if (!(err instanceof Error)) {
err = new Error(err);
}
reject(err);
} else {
resolve(res);
}
});
});
}
function buildUpdateQuery(numEntries) {
const values = [];
for (let i = 0; i < numEntries; i++) {
values.push('(?, ?, ?)');
}
return `INSERT INTO channel_data VALUES ${values.join(', ')} ` +
'ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)';
}
export class DatabaseStore {
load(id, channelName) {
if (!id || id === 0) {
return Promise.reject(new Error(`Cannot load state for [${channelName}]: ` +
`id was passed as [${id}]`));
}
return queryAsync(QUERY_CHANNEL_DATA, [id]).then(rows => {
loadRowcount.inc(rows.length);
const data = {};
rows.forEach(row => {
try {
data[row.key] = JSON.parse(row.value);
loadCharcount.inc(row.value.length);
} catch (e) {
LOGGER.error(`Channel data for channel "${channelName}", ` +
`key "${row.key}" is invalid: ${e}`);
}
});
return data;
});
}
async save(id, channelName, data) {
if (!id || id === 0) {
throw new Error(
`Cannot save state for [${channelName}]: ` +
`id was passed as [${id}]`
);
}
let totalSize = 0;
let rowCount = 0;
const substitutions = [];
for (const key in data) {
if (typeof data[key] === 'undefined') {
continue;
}
rowCount++;
const value = JSON.stringify(data[key]);
totalSize += value.length;
substitutions.push(id);
substitutions.push(key);
substitutions.push(value);
}
if (rowCount === 0) {
return;
}
if (totalSize > SIZE_LIMIT) {
throw new ChannelStateSizeError(
'Channel state size is too large',
{
limit: SIZE_LIMIT,
actual: totalSize
}
);
}
saveRowcount.inc(rowCount);
saveCharcount.inc(totalSize);
return await queryAsync(buildUpdateQuery(rowCount), substitutions);
}
}

View file

@ -0,0 +1,63 @@
var ChannelModule = require("./module");
var Flags = require("../flags");
function AccessControlModule(_channel) {
ChannelModule.apply(this, arguments);
}
AccessControlModule.prototype = Object.create(ChannelModule.prototype);
AccessControlModule.prototype.onUserPreJoin = function (user, data, cb) {
var chan = this.channel,
opts = this.channel.modules.options;
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_HAS_CHANNEL_RANK, function () {
if (user.is(Flags.U_IN_CHANNEL)) {
return;
}
if (user.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;

View file

@ -0,0 +1,35 @@
var ChannelModule = require("./module");
var Flags = require("../flags");
function AnonymousCheck(_channel) {
ChannelModule.apply(this, arguments);
}
AnonymousCheck.prototype = Object.create(ChannelModule.prototype);
AnonymousCheck.prototype.onUserPreJoin = function (user, data, cb) {
const opts = this.channel.modules.options;
var anonymousBanned = opts.get("block_anonymous_users");
if (user.socket.disconnected) {
return cb("User disconnected", ChannelModule.DENY);
}
if(anonymousBanned && user.isAnonymous()) {
user.socket.on("disconnect", function () {
if (!user.is(Flags.U_IN_CHANNEL)) {
cb("User disconnected", ChannelModule.DENY);
}
});
user.socket.emit("errorMsg", { msg : "This channel has blocked anonymous users. Please provide a user name to join."});
user.waitFlag(Flags.U_LOGGED_IN, function () {
cb(null, ChannelModule.PASSTHROUGH);
});
return;
} else{
cb(null, ChannelModule.PASSTHROUGH);
}
};
module.exports = AnonymousCheck;

760
src/channel/channel.js Normal file
View file

@ -0,0 +1,760 @@
var ChannelModule = require("./module");
var Flags = require("../flags");
var fs = require("fs");
var path = require("path");
var sio = require("socket.io");
var db = require("../database");
import * as ChannelStore from '../channel-storage/channelstore';
import { ChannelStateSizeError } from '../errors';
import { EventEmitter } from 'events';
import { throttle } from '../util/throttle';
import Logger from '../logger';
const LOGGER = require('@calzoneman/jsli')('channel');
const USERCOUNT_THROTTLE = 10000;
class ReferenceCounter {
constructor(channel) {
this.channel = channel;
this.channelName = channel.name;
this.refCount = 0;
this.references = {};
}
ref(caller) {
if (caller) {
if (this.references.hasOwnProperty(caller)) {
this.references[caller]++;
} else {
this.references[caller] = 1;
}
}
this.refCount++;
}
unref(caller) {
if (caller) {
if (this.references.hasOwnProperty(caller)) {
this.references[caller]--;
if (this.references[caller] === 0) {
delete this.references[caller];
}
} else {
LOGGER.error("ReferenceCounter::unref() called by caller [" +
caller + "] but this caller had no active references! " +
`(channel: ${this.channelName})`);
return;
}
}
this.refCount--;
this.checkRefCount();
}
checkRefCount() {
if (this.refCount === 0) {
if (Object.keys(this.references).length > 0) {
LOGGER.error("ReferenceCounter::refCount reached 0 but still had " +
"active references: " +
JSON.stringify(Object.keys(this.references)) +
` (channel: ${this.channelName})`);
for (var caller in this.references) {
this.refCount += this.references[caller];
}
} else if (this.channel.users && this.channel.users.length > 0) {
LOGGER.error("ReferenceCounter::refCount reached 0 but still had " +
this.channel.users.length + " active users" +
` (channel: ${this.channelName})`);
this.refCount = this.channel.users.length;
} else {
this.channel.emit("empty");
}
}
}
}
function Channel(name) {
this.name = name;
this.uniqueName = name.toLowerCase();
this.modules = {};
this.logger = new Logger.Logger(
path.join(
__dirname, "..", "..", "chanlogs", this.uniqueName + ".log"
)
);
this.users = [];
this.refCounter = new ReferenceCounter(this);
this.flags = 0;
this.id = 0;
this.ownerName = null;
this.broadcastUsercount = throttle(() => {
this.broadcastAll("usercount", this.users.length);
}, USERCOUNT_THROTTLE);
const self = this;
db.channels.load(this, function (err) {
if (err && err !== "Channel is not registered") {
self.emit("loadFail", "Failed to load channel data from the database. Please try again later.");
self.setFlag(Flags.C_ERROR);
} else {
self.initModules();
self.loadState();
db.channels.updateLastLoaded(self.id);
}
});
}
Channel.prototype = Object.create(EventEmitter.prototype);
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 (f) {
if (f === flag) {
self.removeListener("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",
"./emotes" : "emotes",
"./chat" : "chat",
"./drink" : "drink",
"./filters" : "filters",
"./customization" : "customization",
"./opts" : "options",
"./library" : "library",
"./playlist" : "playlist",
"./mediarefresher": "mediarefresher",
"./voteskip" : "voteskip",
"./poll" : "poll",
"./kickban" : "kickban",
"./ranks" : "rank",
"./accesscontrol" : "password",
"./anonymouscheck": "anoncheck"
};
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 () {
/* Don't load from disk if not registered */
if (!this.is(Flags.C_REGISTERED)) {
this.modules.permissions.loadUnregistered();
this.setFlag(Flags.C_READY);
return;
}
const self = this;
function errorLoad(msg, suggestTryAgain = true) {
const extra = suggestTryAgain ? " Please try again later." : "";
self.emit("loadFail", "Failed to load channel data from the database: " +
msg + extra);
self.setFlag(Flags.C_ERROR);
}
ChannelStore.load(this.id, this.uniqueName).then(data => {
Object.keys(this.modules).forEach(m => {
try {
this.modules[m].load(data);
} catch (e) {
LOGGER.error("Failed to load module " + m + " for channel " +
this.uniqueName);
}
});
this.setFlag(Flags.C_READY);
}).catch(ChannelStateSizeError, err => {
const message = "This channel's state size has exceeded the memory limit " +
"enforced by this server. Please contact an administrator " +
"for assistance.";
LOGGER.error(err.stack);
errorLoad(message, false);
}).catch(err => {
if (err.code === 'ENOENT') {
Object.keys(this.modules).forEach(m => {
this.modules[m].load({});
});
this.setFlag(Flags.C_READY);
return;
} else {
const message = "An error occurred when loading this channel's data from " +
"disk. Please contact an administrator for assistance. " +
`The error was: ${err}.`;
LOGGER.error(err.stack);
errorLoad(message);
}
});
};
Channel.prototype.saveState = async function () {
if (!this.is(Flags.C_REGISTERED)) {
return;
} else if (!this.is(Flags.C_READY)) {
throw new Error(
`Attempted to save channel ${this.name} ` +
`but it wasn't finished loading yet!`
);
}
if (this.is(Flags.C_ERROR)) {
throw new Error(`Channel is in error state`);
}
this.logger.log("[init] Saving channel state to disk");
const data = {};
Object.keys(this.modules).forEach(m => {
if (
this.modules[m].dirty ||
!this.modules[m].supportsDirtyCheck
) {
this.modules[m].save(data);
} else {
LOGGER.debug(
"Skipping save for %s[%s]: not dirty",
this.uniqueName,
m
);
}
});
try {
await ChannelStore.save(this.id, this.uniqueName, data);
Object.keys(this.modules).forEach(m => {
this.modules[m].dirty = false;
});
} catch (error) {
if (error instanceof ChannelStateSizeError) {
this.users.forEach(u => {
if (u.account.effectiveRank >= 2) {
u.socket.emit("warnLargeChandump", {
limit: error.limit,
actual: error.actual
});
}
});
}
throw error;
}
};
Channel.prototype.checkModules = function (fn, args, cb) {
const self = this;
const refCaller = `Channel::checkModules/${fn}`;
this.waitFlag(Flags.C_READY, function () {
if (self.dead) return;
self.refCounter.ref(refCaller);
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.refCounter.unref(refCaller);
return;
}
var m = keys.shift();
if (m === undefined) {
/* No more modules to check */
cb(null, ChannelModule.PASSTHROUGH);
self.refCounter.unref(refCaller);
return;
}
if (!self.modules) {
LOGGER.warn(
'checkModules(%s): self.modules is undefined; dead=%s,' +
' current=%s, remaining=%s',
fn,
self.dead,
m,
keys
);
return;
}
var module = self.modules[m];
module[fn].apply(module, args);
};
args.push(next);
process.nextTick(next, null, ChannelModule.PASSTHROUGH);
});
};
Channel.prototype.notifyModules = function (fn, args) {
var self = this;
this.waitFlag(Flags.C_READY, function () {
if (self.dead) return;
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) {
const self = this;
self.refCounter.ref("Channel::user");
self.waitFlag(Flags.C_READY, function () {
/* User closed the connection before the channel finished loading */
if (user.socket.disconnected) {
self.refCounter.unref("Channel::user");
return;
}
user.channel = self;
user.waitFlag(Flags.U_LOGGED_IN, () => {
if (self.dead) {
LOGGER.warn(
'Got U_LOGGED_IN for %s after channel already unloaded',
user.getName()
);
return;
}
if (user.is(Flags.U_REGISTERED)) {
db.channels.getRank(self.name, user.getName(), (error, rank) => {
if (!error) {
user.setChannelRank(rank);
user.setFlag(Flags.U_HAS_CHANNEL_RANK);
if (user.inChannel()) {
self.broadcastAll("setUserRank", {
name: user.getName(),
rank: user.account.effectiveRank
});
}
}
});
}
});
if (user.socket.disconnected) {
self.refCounter.unref("Channel::user");
return;
} else if (self.dead) {
return;
}
self.checkModules("onUserPreJoin", [user, data], function (err, result) {
if (result === ChannelModule.PASSTHROUGH) {
user.channel = self;
self.acceptUser(user);
} else {
user.channel = null;
user.account.channelRank = 0;
user.account.effectiveRank = user.account.globalRank;
self.refCounter.unref("Channel::user");
}
});
});
};
Channel.prototype.acceptUser = function (user) {
user.setFlag(Flags.U_IN_CHANNEL);
user.socket.join(this.name);
user.autoAFK();
user.socket.on("readChanLog", this.handleReadLog.bind(this, user));
LOGGER.info(user.realip + " joined " + this.name);
if (user.socket.context.torConnection) {
if (this.modules.options && this.modules.options.get("torbanned")) {
user.kick("This channel has banned connections from Tor.");
this.logger.log("[login] Blocked connection from Tor exit at " +
user.displayip);
return;
}
this.logger.log("[login] Accepted connection from Tor exit at " +
user.displayip);
} else {
this.logger.log("[login] Accepted connection from " + user.displayip);
}
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");
}
}
var loginStr = "[login] " + user.displayip + " logged in as " + user.getName();
if (user.account.globalRank === 0) loginStr += " (guest)";
loginStr += " (aliases: " + user.account.aliases.join(",") + ")";
self.logger.log(loginStr);
self.sendUserJoin(self.users, user);
if (user.getName().toLowerCase() === self.ownerName) {
db.channels.updateOwnerLastSeen(self.id);
}
});
this.users.push(user);
user.socket.on("disconnect", this.partUser.bind(this, user));
Object.keys(this.modules).forEach(function (m) {
if (user.dead) return;
self.modules[m].onUserPostJoin(user);
});
this.sendUserlist([user]);
// Managing this from here is not great, but due to the sequencing involved
// and the limitations of the existing design, it'll have to do.
if (this.modules.playlist.leader !== null) {
user.socket.emit("setLeader", this.modules.playlist.leader.getName());
}
this.broadcastUsercount();
if (!this.is(Flags.C_REGISTERED)) {
user.socket.emit("channelNotRegistered");
}
user.on('afk', function(afk){
self.sendUserMeta(self.users, user);
// TODO: Drop legacy setAFK frame after a few months
self.broadcastAll("setAFK", { name: user.getName(), afk: afk });
});
user.on("effectiveRankChange", (newRank, oldRank) => {
this.maybeResendUserlist(user, newRank, oldRank);
});
};
Channel.prototype.partUser = function (user) {
if (!this.logger) {
LOGGER.error("partUser called on dead channel");
return;
}
this.logger.log("[login] " + user.displayip + " (" + 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.broadcastAll("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.broadcastUsercount();
this.refCounter.unref("Channel::user");
user.die();
};
Channel.prototype.maybeResendUserlist = function maybeResendUserlist(user, newRank, oldRank) {
if ((newRank >= 2 && oldRank < 2)
|| (newRank < 2 && oldRank >= 2)
|| (newRank >= 255 && oldRank < 255)
|| (newRank < 255 && oldRank >= 255)) {
this.sendUserlist([user]);
}
};
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: user.displayip
}
};
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.realip
}
};
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;
if (users === self.users) {
self.broadcastAll("usercount", self.users.length);
} else {
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.readLog = function (cb) {
const maxLen = 102400;
const file = this.logger.filename;
this.refCounter.ref("Channel::readLog");
const self = this;
fs.stat(file, function (err, data) {
if (err) {
self.refCounter.unref("Channel::readLog");
return cb(err, null);
}
const start = Math.max(data.size - maxLen, 0);
const end = data.size - 1;
const read = fs.createReadStream(file, {
start: start,
end: end
});
var buffer = "";
read.on("data", function (data) {
buffer += data;
});
read.on("end", function () {
cb(null, buffer);
self.refCounter.unref("Channel::readLog");
});
});
};
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;
}
this.readLog(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.broadcastToRoom = function (msg, data, ns) {
sio.instance.in(ns).emit(msg, data);
};
Channel.prototype.broadcastAll = function (msg, data) {
this.broadcastToRoom(msg, data, this.name);
};
Channel.prototype.packInfo = function (isAdmin) {
var data = {
name: this.name,
usercount: this.users.length,
users: [],
registered: this.is(Flags.C_REGISTERED)
};
for (var i = 0; i < this.users.length; i++) {
if (this.users[i].name !== "") {
var name = this.users[i].getName();
var rank = this.users[i].account.effectiveRank;
if (rank >= 255) {
name = "!" + name;
} else if (rank >= 4) {
name = "~" + name;
} else if (rank >= 3) {
name = "&" + name;
} else if (rank >= 2) {
name = "@" + name;
}
data.users.push(name);
}
}
if (isAdmin) {
data.activeLockCount = this.refCounter.refCount;
}
var self = this;
var keys = Object.keys(this.modules);
keys.forEach(function (k) {
self.modules[k].packInfo(data, isAdmin);
});
return data;
};
module.exports = Channel;

688
src/channel/chat.js Normal file
View file

@ -0,0 +1,688 @@
var Config = require("../config");
var XSS = require("../xss");
var ChannelModule = require("./module");
var util = require("../utilities");
var Flags = require("../flags");
import { transformImgTags } from '../camo';
import { Counter } from 'prom-client';
const SHADOW_TAG = "[shadow]";
const LINK = /(\w+:\/\/(?:[^:/[\]\s]+|\[[0-9a-f:]+\])(?::\d+)?(?:\/[^/\s]*)*)/ig;
const LINK_PLACEHOLDER = '\ueeee';
const LINK_PLACEHOLDER_RE = /\ueeee/g;
const TYPE_CHAT = {
msg: "string",
meta: "object,optional"
};
const TYPE_PM = {
msg: "string",
to: "string",
meta: "object,optional"
};
// Limit to 10 messages/sec
const MIN_ANTIFLOOD = {
burst: 20,
sustained: 10
};
function ChatModule(_channel) {
ChannelModule.apply(this, arguments);
this.buffer = [];
this.muted = new Set();
this.commandHandlers = {};
this.supportsDirtyCheck = true;
/* 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("/rcv", 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 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 (i = 0; i < data.chatmuted.length; i++) {
this.muted.add(data.chatmuted[i]);
}
}
this.dirty = false;
};
ChatModule.prototype.save = function (data) {
data.chatbuffer = this.buffer;
data.chatmuted = Array.from(this.muted);
};
ChatModule.prototype.packInfo = function (data, _isAdmin) {
data.chat = Array.prototype.slice.call(this.buffer);
};
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.has(name.toLowerCase()) ||
this.muted.has(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.has(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.anonymousUsers = function () {
return this.channel.users.filter(function (u) {
return u.getName() === "";
});
};
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;
};
const chatIncomingCount = new Counter({
name: 'cytube_chat_incoming_total',
help: 'Number of incoming chatMsg frames'
});
ChatModule.prototype.handleChatMsg = function (user, data) {
var self = this;
chatIncomingCount.inc(1, new Date());
if (!this.channel || !this.channel.modules.permissions.canChat(user)) {
return;
}
data.msg = data.msg.substring(0, 320);
// 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")) {
data.msg = data.msg.replace(/[\x00-\x1f]+/g, " ");
}
// Disallow blankposting
if (!data.msg.trim()) {
return;
}
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("onUserPreChat", [user, data], function (err, result) {
if (result === ChannelModule.PASSTHROUGH) {
self.processChatMsg(user, data);
}
});
};
ChatModule.prototype.handlePm = function (user, data) {
if (!this.channel) {
return;
}
if (!user.is(Flags.U_LOGGED_IN)) {
return user.socket.emit("errorMsg", {
msg: "You must be signed in to send PMs"
});
}
// 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");
user.kick();
this.sendModMessage(user.getName() + " was kicked: blacklisted domain in " +
"private message", 2);
return;
}
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;
}
if (user.chatLimiter.throttle(MIN_ANTIFLOOD)) {
user.socket.emit("cooldown", 1000 / MIN_ANTIFLOOD.sustained);
return;
}
data.msg = data.msg.substring(0, 320);
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 (data.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);
};
const chatSentCount = new Counter({
name: 'cytube_chat_sent_total',
help: 'Number of broadcast chat messages'
});
ChatModule.prototype.processChatMsg = function (user, data) {
if (data.msg.match(Config.get("link-domain-blacklist-regex"))) {
this.channel.logger.log(user.displayip + " (" + user.getName() + ") was kicked for " +
"blacklisted domain");
user.kick();
this.sendModMessage(user.getName() + " was kicked: blacklisted domain in " +
"chat message", 2);
return;
}
if (data.msg.indexOf("/afk") === -1) {
user.setAFK(false);
}
var msgobj = this.formatMessage(user.getName(), data);
var antiflood = MIN_ANTIFLOOD;
if (this.channel.modules.options &&
this.channel.modules.options.get("chat_antiflood") &&
user.account.effectiveRank < 2) {
antiflood = this.channel.modules.options.get("chat_antiflood_params");
}
if (user.chatLimiter.throttle(antiflood)) {
user.socket.emit("cooldown", 1000 / antiflood.sustained);
return;
}
if (data.msg.indexOf(">") === 0) {
msgobj.meta.addClass = "greentext";
}
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);
return;
}
}
if (user.is(Flags.U_SMUTED)) {
this.shadowMutedUsers().forEach(function (u) {
u.socket.emit("chatMsg", msgobj);
});
// This prevents shadowmuted users from easily detecting their state
this.anonymousUsers().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;
}
this.sendMessage(msgobj);
chatSentCount.inc(1, new Date());
};
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;
};
ChatModule.prototype.filterMessage = function (msg) {
var filters = this.channel.modules.filters.filters;
var convertLinks = this.channel.modules.options.get("enable_link_regex");
var links = msg.match(LINK);
var intermediate = msg.replace(LINK, LINK_PLACEHOLDER);
var result = filters.filter(intermediate, false);
result = result.replace(LINK_PLACEHOLDER_RE, function () {
var link = links.shift();
if (!link) {
return '';
}
var filtered = filters.filter(link, true);
if (filtered !== link) {
return filtered;
} else if (convertLinks) {
return "<a href=\"" + link + "\" target=\"_blank\" " +
"rel=\"noopener noreferrer\">" + link + "</a>";
} else {
return link;
}
});
let settings = {};
const camoConfig = Config.getCamoConfig();
if (camoConfig.isEnabled()) {
settings = {
transformTags: {
img: transformImgTags.bind(null, camoConfig)
}
};
}
return XSS.sanitizeHTML(result, settings);
};
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.dirty = true;
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 (!this.channel.modules.permissions.canClearChat(user)) {
return;
}
this.dirty = true;
this.buffer = [];
this.channel.broadcastAll("clearchat", { clearedBy: user.getName() });
this.sendModMessage(user.getName() + " cleared chat.", -1);
this.channel.logger.log("[mod] " + user.getName() + " used /clear");
};
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();
if (typeof name !== "string") {
user.socket.emit("errorMsg", {
msg: "/mute requires a target name"
});
return;
}
name = name.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
|| target.account.globalRank > user.account.globalRank) {
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();
if (typeof name !== "string") {
user.socket.emit("errorMsg", {
msg: "/smute requires a target name"
});
return;
}
name = name.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
|| target.account.globalRank > user.account.globalRank) {
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();
if (typeof name !== "string") {
user.socket.emit("errorMsg", {
msg: "/unmute requires a target name"
});
return;
}
name = name.toLowerCase();
if (name === user.getName().toLowerCase()) {
user.socket.emit("errorMsg", {
msg: "You are not allowed to unmute yourself"
});
return;
}
if (!this.isMuted(name)) {
user.socket.emit("errorMsg", {
msg: name + " is not muted."
});
return;
}
this.muted.delete(name);
this.muted.delete(SHADOW_TAG + name);
this.channel.logger.log("[mod] " + user.getName() + " unmuted " + name);
this.sendModMessage(user.getName() + " unmuted " + name, muteperm);
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);
};
module.exports = ChatModule;

View file

@ -0,0 +1,157 @@
const ChannelModule = require("./module");
const XSS = require("../xss");
const { hash } = require('../util/hash');
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 = "";
this.supportsDirtyCheck = true;
}
CustomizationModule.prototype = Object.create(ChannelModule.prototype);
Object.defineProperty(CustomizationModule.prototype, 'css', {
get() {
return this._css;
},
set(val) {
this._css = val;
this.cssHash = hash('md5', val, 'base64');
}
});
Object.defineProperty(CustomizationModule.prototype, 'js', {
get() {
return this._js;
},
set(val) {
this._js = val;
this.jsHash = hash('md5', val, 'base64');
}
});
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) {
if (typeof data.motd === "object" && data.motd.motd) {
// Old style MOTD, convert to new
this.motd = XSS.sanitizeHTML(data.motd.motd).replace(
/\n/g, "<br>\n");
} else if (typeof data.motd === "string") {
// The MOTD is filtered before it is saved, however it is also
// re-filtered on load in case the filtering rules change
this.motd = XSS.sanitizeHTML(data.motd);
}
}
this.dirty = false;
};
CustomizationModule.prototype.save = function (data) {
data.css = this.css;
data.js = this.js;
data.motd = this.motd;
};
CustomizationModule.prototype.setMotd = function (motd) {
this.motd = XSS.sanitizeHTML(motd);
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,
cssHash: this.cssHash,
js: this.js,
jsHash: this.jsHash
};
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;
}
let oldHash = this.cssHash;
// TODO: consider sending back an error instead of silently truncating
this.css = data.css.substring(0, 20000);
if (oldHash !== this.cssHash) {
this.dirty = true;
this.sendCSSJS(this.channel.users);
this.channel.logger.log("[mod] " + user.getName() + " 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;
}
let oldHash = this.jsHash;
this.js = data.js.substring(0, 20000);
if (oldHash !== this.jsHash) {
this.dirty = true;
this.sendCSSJS(this.channel.users);
this.channel.logger.log("[mod] " + user.getName() + " 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.dirty = true;
this.setMotd(motd);
this.channel.logger.log("[mod] " + user.getName() + " updated the MOTD");
};
module.exports = CustomizationModule;

60
src/channel/drink.js Normal file
View file

@ -0,0 +1,60 @@
// TODO: figure out what to do with this module
// it serves a very niche use case and is only a core module because of
// legacy reasons (early channels requested it before I had criteria
// around what to include in core)
var ChannelModule = require("./module");
function DrinkModule(_channel) {
ChannelModule.apply(this, arguments);
this.drinks = 0;
}
DrinkModule.prototype = Object.create(ChannelModule.prototype);
DrinkModule.prototype.onUserPostJoin = function (user) {
user.socket.emit("drinkCount", this.drinks);
};
DrinkModule.prototype.onUserPreChat = function (user, data, cb) {
var msg = data.msg;
var perms = this.channel.modules.permissions;
if (msg.match(/^\/d-?[0-9]*/) && perms.canCallDrink(user)) {
msg = msg.substring(2);
var m = msg.match(/^(-?[0-9]+)/);
var count;
if (m) {
count = parseInt(m[1]);
if (isNaN(count) || count < -10000 || count > 10000) {
return;
}
msg = msg.replace(m[1], "").trim();
if (msg || count > 0) {
msg += " drink! (x" + count + ")";
} else {
this.drinks += count;
this.channel.broadcastAll("drinkCount", this.drinks);
return cb(null, ChannelModule.DENY);
}
} else {
msg = msg.trim() + " drink!";
count = 1;
}
this.drinks += count;
this.channel.broadcastAll("drinkCount", this.drinks);
data.msg = msg;
data.meta.addClass = "drink";
data.meta.forceShowName = true;
cb(null, ChannelModule.PASSTHROUGH);
} else {
cb(null, ChannelModule.PASSTHROUGH);
}
};
DrinkModule.prototype.onMediaChange = function () {
this.drinks = 0;
this.channel.broadcastAll("drinkCount", 0);
};
module.exports = DrinkModule;

304
src/channel/emotes.js Normal file
View file

@ -0,0 +1,304 @@
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);
},
emoteExists: function (emote){
for (let i = 0; i < this.emotes.length; i++) {
if (this.emotes[i].name === emote.name) {
return true;
}
}
return false;
},
renameEmote: function (emote) {
var found = false;
for (var i = 0; i < this.emotes.length; i++) {
if (this.emotes[i].name === emote.old) {
found = true;
this.emotes[i] = emote;
delete this.emotes[i].old;
break;
}
}
if(found){
return true;
}
return false;
},
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) {
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.looseSanitizeText(f.name).replace(/([\\.?+*$^|()[\]{}])/g, "\\$1");
s = "(^|\\s)" + s + "(?!\\S)";
f.source = s;
if (!f.image || !f.name) {
return false;
}
try {
new RegExp(f.source, "gi");
} catch (e) {
return false;
}
return f;
}
function EmoteModule(_channel) {
ChannelModule.apply(this, arguments);
this.emotes = new EmoteList();
this.supportsDirtyCheck = true;
}
EmoteModule.prototype = Object.create(ChannelModule.prototype);
EmoteModule.prototype.load = function (data) {
if ("emotes" in data) {
this.emotes = new EmoteList(data.emotes);
}
this.dirty = false;
};
EmoteModule.prototype.save = function (data) {
data.emotes = this.emotes.pack();
};
EmoteModule.prototype.packInfo = function (data, isAdmin) {
if (isAdmin) {
data.emoteCount = this.emotes.emotes.length;
}
};
EmoteModule.prototype.onUserPostJoin = function (user) {
user.socket.on("renameEmote", this.handleRenameEmote.bind(this, 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();
users.forEach(function (u) {
u.socket.emit("emoteList", f);
});
};
EmoteModule.prototype.handleRenameEmote = function (user, data) {
if (typeof data !== "object") {
return;
}
/*
** This shouldn't be able to happen,
** but we have idiots that like to send handcrafted frames to fuck with shit
*/
if (typeof data.old !== "string"){
return;
}
if (!this.channel.modules.permissions.canEditEmotes(user)) {
return;
}
var e = this.emotes.emoteExists(data);
var f = validateEmote(data);
if (!f || e) {
var message = "Unable to rename emote '" + JSON.stringify(data) + "'. " +
"Please contact an administrator for assistance.";
if (!data.image || !data.name) {
message = "Emote names and images must not be blank.";
}
if (e) {
message = "Emote already exists.";
}
user.socket.emit("errorMsg", {
msg: message,
alert: true
});
return;
}
// See comment above
var success = this.emotes.renameEmote(Object.assign({}, f));
if(!success){ return; }
this.dirty = true;
var chan = this.channel;
chan.broadcastAll("renameEmote", f);
chan.logger.log(`[mod] ${user.getName()} renamed emote: ${f.old} -> ${f.name}`);
};
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) {
var message = "Unable to update emote '" + JSON.stringify(data) + "'. " +
"Please contact an administrator for assistance.";
if (!data.image || !data.name) {
message = "Emote names and images must not be blank.";
}
user.socket.emit("errorMsg", {
msg: message,
alert: true
});
return;
}
this.emotes.updateEmote(f);
this.dirty = true;
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.dirty = true;
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.dirty = true;
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);
this.dirty = true;
};
module.exports = EmoteModule;

313
src/channel/filters.js Normal file
View file

@ -0,0 +1,313 @@
var FilterList = require("cytubefilters");
var ChannelModule = require("./module");
const LOGGER = require('@calzoneman/jsli')('filters');
/*
* Converts JavaScript-style replacements ($1, $2, etc.) with
* PCRE-style (\1, \2, etc.)
*/
function fixReplace(replace) {
return replace.replace(/\$(\d)/g, "\\$1");
}
function validateFilter(f) {
if (typeof f.source !== "string" || typeof f.flags !== "string" ||
typeof f.replace !== "string") {
return null;
}
if (typeof f.name !== "string") {
f.name = f.source;
}
f.replace = fixReplace(f.replace.substring(0, 1000));
f.flags = f.flags.substring(0, 4);
try {
FilterList.checkValidRegex(f.source);
} catch (e) {
return null;
}
var filter = {
name: f.name,
source: f.source,
replace: fixReplace(f.replace),
flags: f.flags,
active: !!f.active,
filterlinks: !!f.filterlinks
};
return filter;
}
function makeDefaultFilter(name, source, flags, replace) {
return {
name: name,
source: source,
flags: flags,
replace: replace,
active: true,
filterlinks: false
};
}
const DEFAULT_FILTERS = [
makeDefaultFilter("monospace", "`(.+?)`", "g", "<code>\\1</code>"),
makeDefaultFilter("bold", "\\*(.+?)\\*", "g", "<strong>\\1</strong>"),
makeDefaultFilter("italic", "_(.+?)_", "g", "<em>\\1</em>"),
makeDefaultFilter("strike", "~~(.+?)~~", "g", "<s>\\1</s>"),
makeDefaultFilter("inline spoiler", "\\[sp\\](.*?)\\[\\/sp\\]", "ig",
"<span class=\"spoiler\">\\1</span>")
];
function ChatFilterModule(_channel) {
ChannelModule.apply(this, arguments);
this.filters = new FilterList();
this.supportsDirtyCheck = true;
}
ChatFilterModule.prototype = Object.create(ChannelModule.prototype);
ChatFilterModule.prototype.load = function (data) {
if ("filters" in data) {
var filters = data.filters.map(validateFilter).filter(function (f) {
return f !== null;
});
try {
this.filters = new FilterList(filters);
} catch (e) {
LOGGER.error("Filter load failed: " + e + " (channel:" +
this.channel.name);
this.channel.logger.log("Failed to load filters: " + e);
}
} else {
this.filters = new FilterList(DEFAULT_FILTERS);
}
this.dirty = false;
};
ChatFilterModule.prototype.save = function (data) {
data.filters = this.filters.pack();
};
ChatFilterModule.prototype.packInfo = function (data, isAdmin) {
if (isAdmin) {
data.chatFilterCount = this.filters.length;
}
};
ChatFilterModule.prototype.onUserPostJoin = function (user) {
user.socket.on("addFilter", this.handleAddFilter.bind(this, 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.handleAddFilter = function (user, data) {
if (typeof data !== "object") {
return;
}
if (!this.channel.modules.permissions.canEditFilters(user)) {
return;
}
try {
FilterList.checkValidRegex(data.source);
} catch (e) {
user.socket.emit("errorMsg", {
msg: "Invalid regex: " + e.message,
alert: true
});
return;
}
data = validateFilter(data);
if (!data) {
return;
}
try {
this.filters.addFilter(data);
} catch (e) {
user.socket.emit("errorMsg", {
msg: "Filter add failed: " + e.message,
alert: true
});
return;
}
this.dirty = true;
user.socket.emit("addFilterSuccess");
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() + " added filter: " + data.name + " -> " +
"s/" + data.source + "/" + data.replace + "/" + data.flags +
" active: " + data.active + ", filterlinks: " + data.filterlinks);
};
ChatFilterModule.prototype.handleUpdateFilter = function (user, data) {
if (typeof data !== "object") {
return;
}
if (!this.channel.modules.permissions.canEditFilters(user)) {
return;
}
try {
FilterList.checkValidRegex(data.source);
} catch (e) {
user.socket.emit("errorMsg", {
msg: "Invalid regex: " + e.message,
alert: true
});
return;
}
data = validateFilter(data);
if (!data) {
return;
}
try {
this.filters.updateFilter(data);
} catch (e) {
user.socket.emit("errorMsg", {
msg: "Filter update failed: " + e.message,
alert: true
});
return;
}
this.dirty = true;
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: " + data.name + " -> " +
"s/" + data.source + "/" + data.replace + "/" + data.flags +
" active: " + data.active + ", filterlinks: " + data.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;
}
try {
this.filters = new FilterList(data.map(validateFilter).filter(function (f) {
return f !== null;
}));
} catch (e) {
user.socket.emit("errorMsg", {
msg: "Filter import failed: " + e.message,
alert: true
});
return;
}
this.dirty = true;
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;
}
try {
this.filters.removeFilter(data);
} catch (e) {
user.socket.emit("errorMsg", {
msg: "Filter removal failed: " + e.message,
alert: true
});
return;
}
this.dirty = true;
var chan = this.channel;
chan.users.forEach(function (u) {
if (chan.modules.permissions.canEditFilters(u)) {
u.socket.emit("deleteChatFilter", 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;
}
try {
this.filters.moveFilter(data.from, data.to);
} catch (e) {
user.socket.emit("errorMsg", {
msg: "Filter move failed: " + e.message,
alert: true
});
return;
}
this.dirty = true;
};
ChatFilterModule.prototype.handleRequestChatFilters = function (user) {
this.sendChatFilters([user]);
};
module.exports = ChatFilterModule;

452
src/channel/kickban.js Normal file
View file

@ -0,0 +1,452 @@
var ChannelModule = require("./module");
var db = require("../database");
var Flags = require("../flags");
var util = require("../utilities");
var Account = require("../account");
import Promise from 'bluebird';
const dbIsNameBanned = Promise.promisify(db.channels.isNameBanned);
const dbIsIPBanned = Promise.promisify(db.channels.isIPBanned);
const dbAddBan = Promise.promisify(db.channels.ban);
const dbGetIPs = Promise.promisify(db.getIPs);
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("/kickanons", this.handleCmdKickAnons.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);
function checkIPBan(cname, ip, cb) {
db.channels.isIPBanned(cname, ip, function (err, banned) {
if (err) {
cb(false);
} else {
cb(banned);
}
});
}
function checkBan(cname, ip, name, cb) {
db.channels.isBanned(cname, ip, name, function (err, banned) {
if (err) {
cb(false);
} else {
cb(banned);
}
});
}
KickBanModule.prototype.onUserPreJoin = function (user, data, cb) {
if (!this.channel.is(Flags.C_REGISTERED)) {
return cb(null, ChannelModule.PASSTHROUGH);
}
const cname = this.channel.name;
function callback(banned) {
if (banned) {
cb(null, ChannelModule.DENY);
user.kick("You are banned from this channel.");
} else {
cb(null, ChannelModule.PASSTHROUGH);
}
}
if (user.getName() !== '') {
checkBan(cname, user.realip, user.getName(), callback);
} else {
checkIPBan(cname, user.realip, callback);
}
};
KickBanModule.prototype.onUserPostJoin = function (user) {
if (!this.channel.is(Flags.C_REGISTERED)) {
return;
}
const chan = this.channel;
const refCaller = "KickBanModule::onUserPostJoin";
user.waitFlag(Flags.U_LOGGED_IN, function () {
chan.refCounter.ref(refCaller);
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.refCounter.unref(refCaller);
});
});
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.cloakIP(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 */
if (args.length === 0 || args[0].trim() === "") {
return user.socket.emit("errorMsg", {
msg: "No kick target specified. If you're trying to kick " +
"anonymous users, use /kickanons"
});
}
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
|| target.account.globalRank > user.account.globalRank) {
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());
}
};
KickBanModule.prototype.handleCmdKickAnons = function (user, _msg, _meta) {
if (!this.channel.modules.permissions.canKick(user)) {
return;
}
var users = Array.prototype.slice.call(this.channel.users);
users.forEach(function (u) {
if (!u.is(Flags.U_LOGGED_IN)) {
u.kick("anonymous user");
}
});
this.channel.logger.log("[mod] " + user.getName() + " kicked anonymous users.");
if (this.channel.modules.chat) {
this.channel.modules.chat.sendModMessage(user.getName() + " kicked anonymous " +
"users");
}
};
/* /ban - name bans */
KickBanModule.prototype.handleCmdBan = function (user, msg, _meta) {
var args = msg.split(" ");
args.shift(); /* shift off /ban */
if (args.length === 0 || args[0].trim() === "") {
return user.socket.emit("errorMsg", {
msg: "No ban target specified."
});
}
var name = args.shift().toLowerCase();
var reason = args.join(" ");
const chan = this.channel;
chan.refCounter.ref("KickBanModule::handleCmdBan");
this.banName(user, name, reason).catch(error => {
const message = error.message || error;
user.socket.emit("errorMsg", { msg: message });
}).then(() => {
chan.refCounter.unref("KickBanModule::handleCmdBan");
});
};
/* /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 */
if (args.length === 0 || args[0].trim() === "") {
return user.socket.emit("errorMsg", {
msg: "No ban target specified."
});
}
var name = args.shift().toLowerCase();
var range = false;
if (args[0] === "range") {
range = "range";
args.shift();
} else if (args[0] === "wrange") {
range = "wrange";
args.shift();
}
var reason = args.join(" ");
const chan = this.channel;
chan.refCounter.ref("KickBanModule::handleCmdIPBan");
this.banAll(user, name, range, reason).catch(error => {
//console.log('!!!', error.stack);
const message = error.message || error;
user.socket.emit("errorMsg", { msg: message });
}).then(() => {
chan.refCounter.unref("KickBanModule::handleCmdIPBan");
});
};
KickBanModule.prototype.checkChannelAlive = function checkChannelAlive() {
if (!this.channel || this.channel.dead) {
throw new Error("Channel not live");
}
};
KickBanModule.prototype.banName = async function banName(actor, name, reason) {
reason = reason.substring(0, 255);
var chan = this.channel;
if (!chan.modules.permissions.canBan(actor)) {
throw new 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"
});
throw new Error("You cannot ban yourself");
}
const rank = await Account.rankForName(name, chan.name);
this.checkChannelAlive();
if (rank >= actor.account.effectiveRank) {
throw new Error("You don't have permission to ban " + name);
}
const isBanned = await dbIsNameBanned(chan.name, name);
this.checkChannelAlive();
if (isBanned) {
throw new Error(name + " is already banned");
}
await dbAddBan(chan.name, "*", name, reason, actor.getName());
this.checkChannelAlive();
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
);
}
this.kickBanTarget(name, null);
};
KickBanModule.prototype.banIP = async function banIP(actor, ip, name, reason) {
reason = reason.substring(0, 255);
var masked = util.cloakIP(ip);
var chan = this.channel;
if (!chan.modules.permissions.canBan(actor)) {
throw new Error("You do not have ban permissions on this channel");
}
const rank = await Account.rankForIP(ip, chan.name);
this.checkChannelAlive();
if (rank >= actor.account.effectiveRank) {
// TODO: this message should be made friendlier
throw new Error("You don't have permission to ban IP " + masked);
}
const isBanned = await dbIsIPBanned(chan.name, ip);
this.checkChannelAlive();
if (isBanned) {
// TODO: this message should be made friendlier
throw new Error(masked + " is already banned");
}
await dbAddBan(chan.name, ip, name, reason, actor.getName());
this.checkChannelAlive();
var cloaked = util.cloakIP(ip);
chan.logger.log(
"[mod] " + actor.getName() + " banned " + cloaked +
" (" + name + ")"
);
if (chan.modules.chat) {
chan.modules.chat.sendModMessage(
actor.getName() + " banned " + cloaked + " (" + name + ")",
chan.modules.permissions.permissions.ban
);
}
this.kickBanTarget(name, ip);
};
KickBanModule.prototype.banAll = async function banAll(
actor,
name,
range,
reason
) {
reason = reason.substring(0, 255);
var chan = this.channel;
if (!chan.modules.permissions.canBan(actor)) {
throw new Error("You do not have ban permissions on this channel");
}
const ips = await dbGetIPs(name);
this.checkChannelAlive();
const toBan = new Set();
for (let ip of ips) {
switch (range) {
case "range":
toBan.add(util.getIPRange(ip));
break;
case "wrange":
toBan.add(util.getWideIPRange(ip));
break;
default:
toBan.add(ip);
break;
}
}
const promises = Array.from(toBan).map(ip =>
this.banIP(actor, ip, name, reason)
);
if (!await dbIsNameBanned(chan.name, name)) {
promises.push(this.banName(actor, name, reason));
}
await Promise.all(promises);
this.checkChannelAlive();
};
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].realip === 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.refCounter.ref("KickBanModule::handleUnban");
db.channels.unbanId(this.channel.name, data.id, function (err) {
if (err) {
self.channel.refCounter.unref("KickBanModule::handleUnban");
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.refCounter.unref("KickBanModule::handleUnban");
});
};
module.exports = KickBanModule;

131
src/channel/library.js Normal file
View file

@ -0,0 +1,131 @@
var ChannelModule = require("./module");
var Flags = require("../flags");
var util = require("../utilities");
var InfoGetter = require("../get-info");
var db = require("../database");
import { Counter, Summary } from 'prom-client';
const LOGGER = require('@calzoneman/jsli')('channel/library');
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.cacheMediaList = function (list) {
if (this.channel.is(Flags.C_REGISTERED)) {
LOGGER.info(
'Saving %d items to library for %s',
list.length,
this.channel.name
);
db.channels.addListToLibrary(this.channel.name, list).catch(error => {
LOGGER.error('Failed to add list to library: %s', error.stack);
});
}
};
LibraryModule.prototype.handleUncache = function (user, data) {
if (!this.channel.is(Flags.C_REGISTERED)) {
return;
}
if (!this.channel.modules.permissions.canUncache(user)) {
return;
}
const chan = this.channel;
chan.refCounter.ref("LibraryModule::handleUncache");
db.channels.deleteFromLibrary(chan.name, data.id, function (err, _res) {
if (chan.dead) {
return;
} else if (err) {
chan.refCounter.unref("LibraryModule::handleUncache");
return;
}
chan.logger.log("[library] " + user.getName() + " deleted " + data.id +
"from the library");
chan.refCounter.unref("LibraryModule::handleUncache");
});
};
const librarySearchQueryCount = new Counter({
name: 'cytube_library_search_query_count',
help: 'Counter for number of channel library searches',
labelNames: ['source']
});
const librarySearchResultSize = new Summary({
name: 'cytube_library_search_results_size',
help: 'Summary for number of channel library results returned',
labelNames: ['source']
});
LibraryModule.prototype.handleSearchMedia = function (user, data) {
var query = data.query.substring(0, 100);
var searchYT = function () {
librarySearchQueryCount.labels('yt').inc(1, new Date());
InfoGetter.Getters.ytSearch(query, function (e, vids) {
if (!e) {
librarySearchResultSize.labels('yt')
.observe(vids.length, new Date());
user.socket.emit("searchResults", {
source: "yt",
results: vids
});
}
});
};
if (data.source === "yt" || !this.channel.is(Flags.C_REGISTERED) ||
!this.channel.modules.permissions.canSeePlaylist(user)) {
searchYT();
} else {
librarySearchQueryCount.labels('library').inc(1, new Date());
db.channels.searchLibrary(this.channel.name, query, function (err, res) {
if (err) {
res = [];
}
librarySearchResultSize.labels('library')
.observe(res.length, new Date());
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;

View file

@ -0,0 +1,72 @@
var Vimeo = require("@cytube/mediaquery/lib/provider/vimeo");
var ChannelModule = require("./module");
var Config = require("../config");
const LOGGER = require('@calzoneman/jsli')('mediarefresher');
function MediaRefresherModule(channel) {
ChannelModule.apply(this, arguments);
this._interval = false;
this._media = null;
this._playlist = channel.modules.playlist;
}
MediaRefresherModule.prototype = Object.create(ChannelModule.prototype);
MediaRefresherModule.prototype.onPreMediaChange = function (data, cb) {
if (this._interval) clearInterval(this._interval);
this._media = data;
var pl = this._playlist;
switch (data.type) {
case "vi":
pl._refreshing = true;
return this.initVimeo(data, function () {
pl._refreshing = false;
cb(null, ChannelModule.PASSTHROUGH);
});
default:
return cb(null, ChannelModule.PASSTHROUGH);
}
};
MediaRefresherModule.prototype.unload = function () {
try {
clearInterval(this._interval);
this._interval = null;
} catch (error) {
LOGGER.error(error.stack);
}
};
MediaRefresherModule.prototype.initVimeo = function (data, cb) {
if (!Config.get("vimeo-workaround")) {
if (cb) cb();
return;
}
const self = this;
self.channel.refCounter.ref("MediaRefresherModule::initVimeo");
Vimeo.extract(data.id).then(function (direct) {
if (self.dead || self.channel.dead) {
self.unload();
return;
}
if (self._media === data) {
data.meta.direct = direct;
self.channel.logger.log("[mediarefresher] Refreshed vimeo video with ID " +
data.id);
}
if (cb) cb();
}).catch(function (err) {
LOGGER.error("Unexpected vimeo::extract() fail: " + err.stack);
if (cb) cb();
}).finally(() => {
self.channel.refCounter.unref("MediaRefresherModule::initVimeo");
});
};
module.exports = MediaRefresherModule;

83
src/channel/module.js Normal file
View file

@ -0,0 +1,83 @@
function ChannelModule(channel) {
this.channel = channel;
this.dirty = false;
this.supportsDirtyCheck = false;
}
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 to pack info, e.g. for channel detail view
*/
packInfo: function (_data, _isAdmin) {
},
/**
* 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
*/
onUserPreChat: function (_user, _data, cb) {
cb(null, ChannelModule.PASSTHROUGH);
},
/**
* Called before a new video begins playing
*/
onPreMediaChange: function (_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;

397
src/channel/opts.js Normal file
View file

@ -0,0 +1,397 @@
var ChannelModule = require("./module");
var Config = require("../config");
var Utilities = require("../utilities");
var url = require("url");
function realTypeOf(thing) {
return thing === null ? 'null' : typeof thing;
}
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
torbanned: false, // Block connections from Tor exit nodes
block_anonymous_users: false, //Only allow connections from registered users.
allow_ascii_control: false,// Allow ASCII control characters (\x00-\x1f)
playlist_max_per_user: 0, // Maximum number of playlist items per user
new_user_chat_delay: 0, // Minimum account/IP age to chat
new_user_chat_link_delay: 0, // Minimum account/IP age to post links
playlist_max_duration_per_user: 0 // Maximum total playlist time per user
};
this.supportsDirtyCheck = true;
}
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];
}
}
}
this.opts.pagetitle = unzalgo(this.opts.pagetitle);
this.opts.chat_antiflood_params.burst = Math.min(
20,
this.opts.chat_antiflood_params.burst
);
this.opts.chat_antiflood_params.sustained = Math.min(
10,
this.opts.chat_antiflood_params.sustained
);
this.opts.afk_timeout = Math.min(86400 /* one day */, this.opts.afk_timeout);
this.dirty = false;
};
OptionsModule.prototype.save = function (data) {
data.opts = this.opts;
};
OptionsModule.prototype.packInfo = function (data, isAdmin) {
data.pagetitle = this.opts.pagetitle;
data.public = this.opts.show_public;
if (isAdmin) {
data.hasPassword = this.opts.password !== false;
}
};
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;
}
var sendUpdate = false;
if ("allow_voteskip" in data) {
this.opts.allow_voteskip = Boolean(data.allow_voteskip);
sendUpdate = true;
}
if ("voteskip_ratio" in data) {
var ratio = parseFloat(data.voteskip_ratio);
if (isNaN(ratio) || ratio < 0) {
user.socket.emit("validationError", {
target: "#cs-voteskip_ratio",
message: `Input must be a number 0 or greater, not "${data.voteskip_ratio}"`
});
} else {
this.opts.voteskip_ratio = ratio;
sendUpdate = true;
user.socket.emit("validationPassed", {
target: "#cs-voteskip_ratio"
});
}
}
if ("afk_timeout" in data) {
var tm = parseInt(data.afk_timeout);
if (isNaN(tm) || tm < 0 || tm > 86400 /* one day */) {
tm = 0;
user.socket.emit("validationError", {
target: "#cs-afk_timeout",
message: "AFK timeout must be between 1 and 86400 seconds (or 0 to disable)"
});
} else {
user.socket.emit("validationPassed", {
target: "#cs-afk_timeout",
});
var same = tm === this.opts.afk_timeout;
this.opts.afk_timeout = tm;
if (!same) {
this.channel.users.forEach(function (u) {
u.autoAFK();
});
}
sendUpdate = true;
}
}
if ("pagetitle" in data && user.account.effectiveRank >= 3) {
var title = unzalgo((""+data.pagetitle).substring(0, 100));
if (!title.trim().match(Config.get("reserved-names.pagetitles"))) {
this.opts.pagetitle = title;
sendUpdate = true;
} else {
user.socket.emit("errorMsg", {
msg: "That pagetitle is reserved",
alert: true
});
}
}
if ("maxlength" in data) {
var ml = 0;
if (typeof data.maxlength !== "number") {
ml = Utilities.parseTime(data.maxlength);
} else {
ml = parseInt(data.maxlength);
}
if (isNaN(ml) || ml < 0) {
ml = 0;
}
this.opts.maxlength = ml;
sendUpdate = true;
}
if ("playlist_max_duration_per_user" in data) {
const max = data.playlist_max_duration_per_user;
if (typeof max !== "number" || isNaN(max) || max < 0) {
user.socket.emit("errorMsg", {
msg: `Expected number for playlist_max_duration_per_user, not "${max}"`
});
} else {
this.opts.playlist_max_duration_per_user = max;
sendUpdate = true;
}
}
if ("externalcss" in data && user.account.effectiveRank >= 3) {
var prefix = "Invalid URL for external CSS: ";
if (typeof data.externalcss !== "string") {
user.socket.emit("validationError", {
target: "#cs-externalcss",
message: prefix + "URL must be a string, not "
+ realTypeOf(data.externalcss)
});
}
var link = data.externalcss.substring(0, 255).trim();
if (!link) {
sendUpdate = (this.opts.externalcss !== "");
this.opts.externalcss = "";
user.socket.emit("validationPassed", {
target: "#cs-externalcss"
});
} else {
var urldata = url.parse(link);
if (!urldata.protocol || urldata.protocol !== 'https:') {
user.socket.emit("validationError", {
target: "#cs-externalcss",
message: prefix + " URL must begin with 'https://'"
});
} else if (!urldata.host) {
user.socket.emit("validationError", {
target: "#cs-externalcss",
message: prefix + "missing hostname"
});
} else {
user.socket.emit("validationPassed", {
target: "#cs-externalcss"
});
this.opts.externalcss = urldata.href;
sendUpdate = true;
}
}
}
if ("externaljs" in data && user.account.effectiveRank >= 3) {
const prefix = "Invalid URL for external JS: ";
if (typeof data.externaljs !== "string") {
user.socket.emit("validationError", {
target: "#cs-externaljs",
message: prefix + "URL must be a string, not "
+ realTypeOf(data.externaljs)
});
}
const link = data.externaljs.substring(0, 255).trim();
if (!link) {
sendUpdate = (this.opts.externaljs !== "");
this.opts.externaljs = "";
user.socket.emit("validationPassed", {
target: "#cs-externaljs"
});
} else {
const urldata = url.parse(link);
if (!urldata.protocol || urldata.protocol !== 'https:') {
user.socket.emit("validationError", {
target: "#cs-externaljs",
message: prefix + " URL must begin with 'https://'"
});
} else if (!urldata.host) {
user.socket.emit("validationError", {
target: "#cs-externaljs",
message: prefix + "missing hostname"
});
} else {
user.socket.emit("validationPassed", {
target: "#cs-externaljs"
});
this.opts.externaljs = urldata.href;
sendUpdate = true;
}
}
}
if ("chat_antiflood" in data) {
this.opts.chat_antiflood = Boolean(data.chat_antiflood);
sendUpdate = true;
}
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;
}
b = Math.min(20, b);
var s = parseFloat(data.chat_antiflood_params.sustained);
if (isNaN(s) || s <= 0) {
s = 1;
}
s = Math.min(10, s);
var c = b / s;
this.opts.chat_antiflood_params = {
burst: b,
sustained: s,
cooldown: c
};
sendUpdate = true;
}
if ("show_public" in data && user.account.effectiveRank >= 3) {
this.opts.show_public = Boolean(data.show_public);
sendUpdate = true;
}
if ("enable_link_regex" in data) {
this.opts.enable_link_regex = Boolean(data.enable_link_regex);
sendUpdate = true;
}
if ("password" in data && user.account.effectiveRank >= 3) {
var pw = data.password + "";
pw = pw === "" ? false : pw.substring(0, 100);
this.opts.password = pw;
sendUpdate = true;
}
if ("allow_dupes" in data) {
this.opts.allow_dupes = Boolean(data.allow_dupes);
sendUpdate = true;
}
if ("torbanned" in data && user.account.effectiveRank >= 3) {
this.opts.torbanned = Boolean(data.torbanned);
sendUpdate = true;
}
if("block_anonymous_users" in data && user.account.effectiveRank >=3){
this.opts.block_anonymous_users = Boolean(data.block_anonymous_users);
sendUpdate = true;
}
if ("allow_ascii_control" in data && user.account.effectiveRank >= 3) {
this.opts.allow_ascii_control = Boolean(data.allow_ascii_control);
sendUpdate = true;
}
if ("playlist_max_per_user" in data && user.account.effectiveRank >= 3) {
var max = parseInt(data.playlist_max_per_user);
if (!isNaN(max) && max >= 0) {
this.opts.playlist_max_per_user = max;
sendUpdate = true;
}
}
if ("new_user_chat_delay" in data) {
const delay = data.new_user_chat_delay;
if (!isNaN(delay) && delay >= 0) {
this.opts.new_user_chat_delay = delay;
sendUpdate = true;
}
}
if ("new_user_chat_link_delay" in data) {
const delay = data.new_user_chat_link_delay;
if (!isNaN(delay) && delay >= 0) {
this.opts.new_user_chat_link_delay = delay;
sendUpdate = true;
}
}
this.channel.logger.log("[mod] " + user.getName() + " updated channel options");
if (sendUpdate) {
this.dirty = true;
this.sendOpts(this.channel.users);
}
};
// Forgive me
const combiners = /[\u0300-\u036f\u0483-\u0487\u0591-\u05bd\u05bf-\u05bf\u05c1-\u05c2\u05c4-\u05c5\u05c7-\u05c7\u0610-\u061a\u064b-\u065f\u0670-\u0670\u06d6-\u06dc\u06df-\u06e4\u06e7-\u06e8\u06ea-\u06ed\u0711-\u0711\u0730-\u074a\u07a6-\u07b0\u07eb-\u07f3\u0816-\u0819\u081b-\u0823\u0825-\u0827\u0829-\u082d\u0859-\u085b\u08e4-\u0902\u093a-\u093a\u093c-\u093c\u0941-\u0948\u094d-\u094d\u0951-\u0957\u0962-\u0963\u0981-\u0981\u09bc-\u09bc\u09c1-\u09c4\u09cd-\u09cd\u09e2-\u09e3\u0a01-\u0a02\u0a3c-\u0a3c\u0a41-\u0a42\u0a47-\u0a48\u0a4b-\u0a4d\u0a51-\u0a51\u0a70-\u0a71\u0a75-\u0a75\u0a81-\u0a82\u0abc-\u0abc\u0ac1-\u0ac5\u0ac7-\u0ac8\u0acd-\u0acd\u0ae2-\u0ae3\u0b01-\u0b01\u0b3c-\u0b3c\u0b3f-\u0b3f\u0b41-\u0b44\u0b4d-\u0b4d\u0b56-\u0b56\u0b62-\u0b63\u0b82-\u0b82\u0bc0-\u0bc0\u0bcd-\u0bcd\u0c00-\u0c00\u0c3e-\u0c40\u0c46-\u0c48\u0c4a-\u0c4d\u0c55-\u0c56\u0c62-\u0c63\u0c81-\u0c81\u0cbc-\u0cbc\u0cbf-\u0cbf\u0cc6-\u0cc6\u0ccc-\u0ccd\u0ce2-\u0ce3\u0d01-\u0d01\u0d41-\u0d44\u0d4d-\u0d4d\u0d62-\u0d63\u0dca-\u0dca\u0dd2-\u0dd4\u0dd6-\u0dd6\u0e31-\u0e31\u0e34-\u0e3a\u0e47-\u0e4e\u0eb1-\u0eb1\u0eb4-\u0eb9\u0ebb-\u0ebc\u0ec8-\u0ecd\u0f18-\u0f19\u0f35-\u0f35\u0f37-\u0f37\u0f39-\u0f39\u0f71-\u0f7e\u0f80-\u0f84\u0f86-\u0f87\u0f8d-\u0f97\u0f99-\u0fbc\u0fc6-\u0fc6\u102d-\u1030\u1032-\u1037\u1039-\u103a\u103d-\u103e\u1058-\u1059\u105e-\u1060\u1071-\u1074\u1082-\u1082\u1085-\u1086\u108d-\u108d\u109d-\u109d\u135d-\u135f\u1712-\u1714\u1732-\u1734\u1752-\u1753\u1772-\u1773\u17b4-\u17b5\u17b7-\u17bd\u17c6-\u17c6\u17c9-\u17d3\u17dd-\u17dd\u180b-\u180d\u18a9-\u18a9\u1920-\u1922\u1927-\u1928\u1932-\u1932\u1939-\u193b\u1a17-\u1a18\u1a1b-\u1a1b\u1a56-\u1a56\u1a58-\u1a5e\u1a60-\u1a60\u1a62-\u1a62\u1a65-\u1a6c\u1a73-\u1a7c\u1a7f-\u1a7f\u1ab0-\u1abd\u1b00-\u1b03\u1b34-\u1b34\u1b36-\u1b3a\u1b3c-\u1b3c\u1b42-\u1b42\u1b6b-\u1b73\u1b80-\u1b81\u1ba2-\u1ba5\u1ba8-\u1ba9\u1bab-\u1bad\u1be6-\u1be6\u1be8-\u1be9\u1bed-\u1bed\u1bef-\u1bf1\u1c2c-\u1c33\u1c36-\u1c37\u1cd0-\u1cd2\u1cd4-\u1ce0\u1ce2-\u1ce8\u1ced-\u1ced\u1cf4-\u1cf4\u1cf8-\u1cf9\u1dc0-\u1df5\u1dfc-\u1dff\u20d0-\u20dc\u20e1-\u20e1\u20e5-\u20f0\u2cef-\u2cf1\u2d7f-\u2d7f\u2de0-\u2dff\u302a-\u302d\u3099-\u309a\ua66f-\ua66f\ua674-\ua67d\ua69f-\ua69f\ua6f0-\ua6f1\ua802-\ua802\ua806-\ua806\ua80b-\ua80b\ua825-\ua826\ua8c4-\ua8c4\ua8e0-\ua8f1\ua926-\ua92d\ua947-\ua951\ua980-\ua982\ua9b3-\ua9b3\ua9b6-\ua9b9\ua9bc-\ua9bc\ua9e5-\ua9e5\uaa29-\uaa2e\uaa31-\uaa32\uaa35-\uaa36\uaa43-\uaa43\uaa4c-\uaa4c\uaa7c-\uaa7c\uaab0-\uaab0\uaab2-\uaab4\uaab7-\uaab8\uaabe-\uaabf\uaac1-\uaac1\uaaec-\uaaed\uaaf6-\uaaf6\uabe5-\uabe5\uabe8-\uabe8\uabed-\uabed\ufb1e-\ufb1e\ufe00-\ufe0f\ufe20-\ufe2d]/g;
function unzalgo(text) {
// TODO: consider only removing stacked combiners so that legitimate
// single combining characters can be used.
return text.replace(combiners, '');
}
module.exports = OptionsModule;

400
src/channel/permissions.js Normal file
View file

@ -0,0 +1,400 @@
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
playlistaddrawfile: 2, // Add raw file 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
viewvoteskip: 1.5, // View voteskip results
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
chatclear: 2, // Use the /clear command
exceedmaxitems: 2, // Exceed maximum items per user limit
deletefromchannellib: 2, // Delete channel library items
exceedmaxdurationperuser: 2 // Exceed maximum total playlist length per user
};
function PermissionsModule(_channel) {
ChannelModule.apply(this, arguments);
this.permissions = {};
this.openPlaylist = false;
this.supportsDirtyCheck = true;
}
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;
}
this.dirty = false;
};
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.dirty = true;
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 (const key in perms) {
if (typeof perms[key] !== "number") {
perms[key] = parseFloat(perms[key]);
if (isNaN(perms[key])) {
delete perms[key];
}
}
}
for (const 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.dirty = true;
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.canAddRawFile = function (account) {
return this.hasPermission(account, "playlistaddrawfile");
};
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.canExceedMaxDurationPerUser = function (account) {
return this.hasPermission(account, "exceedmaxdurationperuser");
};
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.canSeeVoteskipResults = function (actor) {
return this.hasPermission(actor, "viewvoteskip");
};
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.canClearChat = function (actor) {
return this.hasPermission(actor, "chatclear");
};
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) {
return this.hasPermission(actor, "deletefromchannellib");
};
PermissionsModule.prototype.canExceedMaxItemsPerUser = function (actor) {
return this.hasPermission(actor, "exceedmaxitems");
};
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
viewvoteskip: 1.5, // View voteskip results
playlistlock: 2, // Lock/unlock the playlist
leaderctl: 0, // Give/take leader
drink: 0, // Use the /d command
chat: 0, // Send chat messages
chatclear: 2, // Use the /clear command
exceedmaxitems: 2, // Exceed max items per user
deletefromchannellib: 2
};
for (var key in perms) {
this.permissions[key] = perms[key];
}
this.openPlaylist = true;
};
module.exports = PermissionsModule;

1425
src/channel/playlist.js Normal file

File diff suppressed because it is too large Load diff

278
src/channel/poll.js Normal file
View file

@ -0,0 +1,278 @@
var ChannelModule = require("./module");
var Poll = require("../poll").Poll;
import { ValidationError } from '../errors';
import Config from '../config';
import { ackOrErrorMsg } from '../util/ack';
const TYPE_NEW_POLL = {
title: "string",
timeout: "number,optional",
obscured: "boolean",
retainVotes: "boolean,optional",
opts: "array"
};
const TYPE_VOTE = {
option: "number"
};
const ROOM_VIEW_HIDDEN = ":viewHidden";
const ROOM_NO_VIEW_HIDDEN = ":noViewHidden";
function PollModule(_channel) {
ChannelModule.apply(this, arguments);
this.poll = null;
this.roomViewHidden = this.channel.uniqueName + ROOM_VIEW_HIDDEN;
this.roomNoViewHidden = this.channel.uniqueName + ROOM_NO_VIEW_HIDDEN;
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));
}
this.supportsDirtyCheck = true;
}
PollModule.prototype = Object.create(ChannelModule.prototype);
PollModule.prototype.unload = function () {
if (this.poll && this.poll.timer) {
clearTimeout(this.poll.timer);
}
};
PollModule.prototype.load = function (data) {
if ("poll" in data) {
if (data.poll !== null) {
this.poll = Poll.fromChannelData(data.poll);
}
}
this.dirty = false;
};
PollModule.prototype.save = function (data) {
if (this.poll === null) {
data.poll = null;
return;
}
data.poll = this.poll.toChannelData();
};
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));
this.addUserToPollRoom(user);
const self = this;
user.on("effectiveRankChange", () => {
if (self.channel && !self.channel.dead) {
self.addUserToPollRoom(user);
}
});
};
PollModule.prototype.addUserToPollRoom = function (user) {
const perms = this.channel.modules.permissions;
if (perms.canViewHiddenPoll(user)) {
user.socket.leave(this.roomNoViewHidden);
user.socket.join(this.roomViewHidden);
} else {
user.socket.leave(this.roomViewHidden);
user.socket.join(this.roomNoViewHidden);
}
};
PollModule.prototype.onUserPart = function(user) {
if (this.poll && !this.poll.retainVotes && this.poll.uncountVote(user.realip)) {
this.broadcastPoll(false);
}
};
PollModule.prototype.sendPoll = function (user) {
if (!this.poll) {
return;
}
var perms = this.channel.modules.permissions;
if (perms.canViewHiddenPoll(user)) {
var unobscured = this.poll.toUpdateFrame(true);
user.socket.emit("newPoll", unobscured);
} else {
var obscured = this.poll.toUpdateFrame(false);
user.socket.emit("newPoll", obscured);
}
};
PollModule.prototype.broadcastPoll = function (isNewPoll) {
if (!this.poll) {
return;
}
var obscured = this.poll.toUpdateFrame(false);
var unobscured = this.poll.toUpdateFrame(true);
const event = isNewPoll ? "newPoll" : "updatePoll";
this.channel.broadcastToRoom(event, unobscured, this.roomViewHidden);
this.channel.broadcastToRoom(event, obscured, this.roomNoViewHidden);
};
PollModule.prototype.validatePollInput = function validatePollInput(title, options) {
if (typeof title !== 'string') {
throw new ValidationError('Poll title must be a string.');
}
if (title.length > 255) {
throw new ValidationError('Poll title must be no more than 255 characters long.');
}
if (!Array.isArray(options)) {
throw new ValidationError('Poll options must be an array.');
}
if (options.length > Config.get('poll.max-options')) {
throw new ValidationError(`Polls are limited to a maximum of ${Config.get('poll.max-options')} options.`);
}
for (let i = 0; i < options.length; i++) {
if (typeof options[i] !== 'string') {
throw new ValidationError('Poll options must be strings.');
}
if (options[i].length === 0 || options[i].length > 255) {
throw new ValidationError('Poll options must be 1-255 characters long.');
}
}
};
PollModule.prototype.handleNewPoll = function (user, data, ack) {
if (!this.channel.modules.permissions.canControlPoll(user)) {
return;
}
// Ensure any existing poll is closed
this.handleClosePoll(user);
ack = ackOrErrorMsg(ack, user);
if (typeof data !== 'object' || data === null) {
ack({
error: {
message: 'Invalid data received for poll creation.'
}
});
return;
}
try {
this.validatePollInput(data.title, data.opts);
} catch (error) {
ack({
error: {
message: error.message
}
});
return;
}
if (data.hasOwnProperty("timeout") &&
(isNaN(data.timeout) || data.timeout < 1 || data.timeout > 86400)) {
ack({
error: {
message: "Poll timeout must be between 1 and 86400 seconds"
}
});
return;
}
var poll = Poll.create(
user.getName(),
data.title,
data.opts,
{
hideVotes: data.obscured,
retainVotes: data.retainVotes === undefined ? false : data.retainVotes
}
);
var self = this;
if (data.hasOwnProperty("timeout")) {
poll.timer = setTimeout(function () {
if (self.poll === poll) {
self.handleClosePoll({
getName: function () { return "[poll timer]"; },
effectiveRank: 255
});
}
}, data.timeout * 1000);
}
this.poll = poll;
this.dirty = true;
this.broadcastPoll(true);
this.channel.logger.log("[poll] " + user.getName() + " opened poll: '" + poll.title + "'");
ack({});
};
PollModule.prototype.handleVote = function (user, data) {
if (!this.channel.modules.permissions.canVote(user)) {
return;
}
if (this.poll) {
if (this.poll.countVote(user.realip, data.option)) {
this.dirty = true;
this.broadcastPoll(false);
}
}
};
PollModule.prototype.handleClosePoll = function (user) {
if (!this.channel.modules.permissions.canControlPoll(user)) {
return;
}
if (this.poll) {
if (this.poll.hideVotes) {
this.poll.hideVotes = false;
this.channel.broadcastAll("updatePoll", this.poll.toUpdateFrame(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;
this.dirty = true;
}
};
PollModule.prototype.handlePollCmd = function (obscured, user, msg, _meta) {
if (!this.channel.modules.permissions.canControlPoll(user)) {
return;
}
// Ensure any existing poll is closed
this.handleClosePoll(user);
msg = msg.replace(/^\/h?poll/, "");
var args = msg.split(",");
var title = args.shift();
try {
this.validatePollInput(title, args);
} catch (error) {
user.socket.emit('errorMsg', {
msg: 'Error creating poll: ' + error.message
});
return;
}
var poll = Poll.create(user.getName(), title, args, { hideVotes: obscured });
this.poll = poll;
this.dirty = true;
this.broadcastPoll(true);
this.channel.logger.log("[poll] " + user.getName() + " opened poll: '" + poll.title + "'");
};
module.exports = PollModule;

200
src/channel/ranks.js Normal file
View file

@ -0,0 +1,200 @@
var ChannelModule = require("./module");
var Flags = require("../flags");
var Account = require("../account");
var db = require("../database");
import Promise from 'bluebird';
const dbSetChannelRank = Promise.promisify(db.channels.setRank);
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 <username> <rank>. <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 (!name.match(/^[a-zA-Z0-9_-]{1,20}$/)) {
user.socket.emit("channelRankFail", {
msg: "Invalid target name " + data.name
});
return;
}
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;
}
const oldRank = receiver.account.effectiveRank;
receiver.account.channelRank = rank;
receiver.account.effectiveRank = Math.max(receiver.account.globalRank, rank);
receiver.emit("effectiveRankChange", receiver.account.effectiveRank, oldRank);
receiver.socket.emit("rank", receiver.account.effectiveRank);
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
});
return;
}
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, this.channel.name).then(rank => {
if (rank >= data.userrank && !(rank === 4 && data.userrank === 4)) {
throw new Error(
"You can't promote or demote someone" +
" with equal or higher rank than you."
);
}
return dbSetChannelRank(chan.name, data.name, data.rank);
}).then(() => {
process.nextTick(cb);
}).catch(error => {
process.nextTick(cb, error.message || error);
});
};
module.exports = RankModule;

160
src/channel/voteskip.js Normal file
View file

@ -0,0 +1,160 @@
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.onUserPart = function(user) {
if (!this.poll) {
return;
}
this.unvote(user.realip);
this.update();
};
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 = Poll.create("[server]", "voteskip", ["skip"]);
}
if (!this.poll.countVote(user.realip, 0)) {
// Vote was already recorded for this IP, no update needed
return;
}
var title = "";
if (this.channel.modules.playlist.current) {
title = " " + this.channel.modules.playlist.current.media.title;
}
var name = user.getName() || "(anonymous)";
this.channel.logger.log("[playlist] " + name + " voteskipped " + title);
user.setAFK(false);
this.update();
};
VoteskipModule.prototype.unvote = function(ip) {
if (!this.poll) {
return;
}
this.poll.uncountVote(ip);
};
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;
}
const { counts } = this.poll.toUpdateFrame(false);
const { total, eligible, noPermission, afk } = this.calcUsercounts();
const need = Math.ceil(eligible * this.channel.modules.options.get("voteskip_ratio"));
if (counts[0] >= need) {
const info = `${counts[0]}/${eligible} skipped; ` +
`eligible voters: ${eligible} = total (${total}) - AFK (${afk}) ` +
`- no permission (${noPermission}); ` +
`ratio = ${this.channel.modules.options.get("voteskip_ratio")}`;
this.channel.logger.log(`[playlist] Voteskip passed: ${info}`);
this.channel.broadcastAll(
'chatMsg',
{
username: "[voteskip]",
msg: `Voteskip passed: ${info}`,
meta: {
addClass: "server-whisper",
addClassToNameAndTimestamp: true
},
time: Date.now()
}
);
this.reset();
this.channel.modules.playlist._playNext();
} else {
this.sendVoteskipData(this.channel.users);
}
};
VoteskipModule.prototype.sendVoteskipData = function (users) {
const { eligible } = this.calcUsercounts();
let data;
if (this.poll) {
const { counts } = this.poll.toUpdateFrame(false);
data = {
count: counts[0],
need: Math.ceil(eligible * this.channel.modules.options.get("voteskip_ratio"))
};
} else {
data = {
count: 0,
need: 0
};
}
var perms = this.channel.modules.permissions;
users.forEach(function (u) {
if (perms.canSeeVoteskipResults(u)) {
u.socket.emit("voteskip", data);
}
});
};
VoteskipModule.prototype.calcUsercounts = function () {
const perms = this.channel.modules.permissions;
const counts = { total: 0, noPermission: 0, afk: 0 };
this.channel.users.forEach(u => {
counts.total++;
if (!perms.canVoteskip(u)) counts.noPermission++;
else if (u.is(Flags.U_AFK)) counts.afk++;
});
counts.eligible = counts.total - (counts.noPermission + counts.afk);
return counts;
};
VoteskipModule.prototype.reset = function reset() {
this.poll = false;
this.sendVoteskipData(this.channel.users);
};
VoteskipModule.prototype.onMediaChange = function (_data) {
this.reset();
};
module.exports = VoteskipModule;

501
src/config.js Normal file
View file

@ -0,0 +1,501 @@
var path = require("path");
var net = require("net");
var YAML = require("yamljs");
import { loadFromToml } from './configuration/configloader';
import { CamoConfig } from './configuration/camoconfig';
import { PrometheusConfig } from './configuration/prometheusconfig';
import { EmailConfig } from './configuration/emailconfig';
import { CaptchaConfig } from './configuration/captchaconfig';
const LOGGER = require('@calzoneman/jsli')('config');
var defaults = {
mysql: {
server: "localhost",
port: 3306,
database: "cytube3",
user: "cytube3",
password: "",
"pool-size": 10
},
listen: [
{
ip: "0.0.0.0",
port: 8080,
http: true,
},
{
ip: "0.0.0.0",
port: 1337,
io: true
}
],
http: {
"default-port": 8080,
"root-domain": "localhost",
"alt-domains": ["127.0.0.1"],
minify: false,
"max-age": "7d",
gzip: true,
"gzip-threshold": 1024,
"cookie-secret": "change-me",
index: {
"max-entries": 50
},
"trust-proxies": [
"loopback"
]
},
https: {
enabled: false,
domain: "https://localhost",
"default-port": 8443,
keyfile: "localhost.key",
passphrase: "",
certfile: "localhost.cert",
cafile: "",
ciphers: "HIGH:!DSS:!aNULL@STRENGTH"
},
io: {
domain: "http://localhost",
"default-port": 1337,
"ip-connection-limit": 10,
cors: {
"allowed-origins": []
}
},
"youtube-v3-key": "",
"channel-blacklist": [],
"channel-path": "r",
"channel-save-interval": 5,
"max-channels-per-user": 5,
"max-accounts-per-ip": 5,
"guest-login-delay": 60,
aliases: {
"purge-interval": 3600000,
"max-age": 2592000000
},
"vimeo-workaround": false,
"html-template": {
title: "CyTube Beta", description: "Free, open source synchtube"
},
"reserved-names": {
usernames: ["^(.*?[-_])?admin(istrator)?([-_].*)?$", "^(.*?[-_])?owner([-_].*)?$"],
channels: ["^(.*?[-_])?admin(istrator)?([-_].*)?$", "^(.*?[-_])?owner([-_].*)?$"],
pagetitles: []
},
"contacts": [],
"aggressive-gc": false,
playlist: {
"max-items": 4000,
"update-interval": 5
},
ffmpeg: {
enabled: false,
"ffprobe-exec": "ffprobe"
},
"link-domain-blacklist": [],
setuid: {
enabled: false,
"group": "users",
"user": "nobody",
"timeout": 15
},
"service-socket": {
enabled: false,
socket: "service.sock"
},
"twitch-client-id": null,
poll: {
"max-options": 50
}
};
/**
* Merges a config object with the defaults, warning about missing keys
*/
function merge(obj, def, path) {
for (var key in def) {
if (key in obj) {
if (typeof obj[key] === "object") {
merge(obj[key], def[key], path + "." + key);
}
} else {
LOGGER.warn("Missing config key " + (path + "." + key) +
"; using default: " + JSON.stringify(def[key]));
obj[key] = def[key];
}
}
}
var cfg = defaults;
let camoConfig = new CamoConfig();
let prometheusConfig = new PrometheusConfig();
let emailConfig = new EmailConfig();
let captchaConfig = new CaptchaConfig();
/**
* Initializes the configuration from the given YAML file
*/
exports.load = function (file) {
let absPath = path.join(__dirname, "..", file);
try {
cfg = YAML.load(absPath);
} catch (e) {
if (e.code === "ENOENT") {
throw new Error(`No such file: ${absPath}`);
} else {
throw new Error(`Invalid config file ${absPath}: ${e}`);
}
}
if (cfg == null) {
throw new Error("Configuration parser returned null");
}
if (cfg.mail) {
LOGGER.error(
'Old style mail configuration found in config.yaml. ' +
'Email will not be delivered unless you copy conf/example/email.toml ' +
'to conf/email.toml and edit it to your liking. ' +
'To remove this warning, delete the "mail:" block in config.yaml.'
);
}
merge(cfg, defaults, "config");
preprocessConfig(cfg);
LOGGER.info("Loaded configuration from " + file);
loadCamoConfig();
loadPrometheusConfig();
loadEmailConfig();
loadCaptchaConfig();
};
function checkLoadConfig(configClass, filename) {
try {
return loadFromToml(
configClass,
path.resolve(__dirname, '..', 'conf', filename)
);
} catch (error) {
if (error.code === 'ENOENT') {
return null;
}
if (typeof error.line !== 'undefined') {
LOGGER.error(`Error in conf/${filename}: ${error} (line ${error.line})`);
} else {
LOGGER.error(`Error loading conf/${filename}: ${error.stack}`);
}
}
}
function loadCamoConfig() {
const conf = checkLoadConfig(CamoConfig, 'camo.toml');
if (conf === null) {
LOGGER.info('No camo configuration found, chat images will not be proxied.');
camoConfig = new CamoConfig();
} else {
camoConfig = conf;
const enabled = camoConfig.isEnabled() ? 'ENABLED' : 'DISABLED';
LOGGER.info(`Loaded camo configuration from conf/camo.toml. Camo is ${enabled}`);
}
}
function loadPrometheusConfig() {
const conf = checkLoadConfig(PrometheusConfig, 'prometheus.toml');
if (conf === null) {
LOGGER.info('No prometheus configuration found, defaulting to disabled');
prometheusConfig = new PrometheusConfig();
} else {
prometheusConfig = conf;
const enabled = prometheusConfig.isEnabled() ? 'ENABLED' : 'DISABLED';
LOGGER.info(
'Loaded prometheus configuration from conf/prometheus.toml. ' +
`Prometheus listener is ${enabled}`
);
}
}
function loadEmailConfig() {
const conf = checkLoadConfig(EmailConfig, 'email.toml');
if (conf === null) {
LOGGER.info('No email configuration found, defaulting to disabled');
emailConfig = new EmailConfig();
} else {
emailConfig = conf;
LOGGER.info('Loaded email configuration from conf/email.toml.');
}
}
function loadCaptchaConfig() {
const conf = checkLoadConfig(Object, 'captcha.toml');
if (conf === null) {
LOGGER.info('No captcha configuration found, defaulting to disabled');
captchaConfig.load();
} else {
captchaConfig.load(conf);
LOGGER.info('Loaded captcha configuration from conf/captcha.toml.');
}
}
// I'm sorry
function preprocessConfig(cfg) {
// Root domain should start with a . for cookies
var root = cfg.http["root-domain"];
if (/127\.0\.0\.1|localhost/.test(root)) {
LOGGER.warn(
"Detected 127.0.0.1 or localhost in root-domain '%s'. This server " +
"will not work from other computers! Set root-domain to the domain " +
"the website will be accessed from (e.g. example.com)",
root
);
}
if (/^http/.test(root)) {
LOGGER.warn(
"root-domain '%s' should not contain http:// or https://, removing it",
root
);
root = root.replace(/^https?:\/\//, "");
}
if (/:\d+$/.test(root)) {
LOGGER.warn(
"root-domain '%s' should not contain a trailing port, removing it",
root
);
root = root.replace(/:\d+$/, "");
}
root = root.replace(/^\.*/, "");
cfg.http["root-domain"] = root;
if (root.indexOf(".") !== -1 && !net.isIP(root)) {
root = "." + root;
}
cfg.http["root-domain-dotted"] = root;
// Debug
if (process.env.DEBUG === "1" || process.env.DEBUG === "true") {
cfg.debug = true;
} else {
cfg.debug = false;
}
// Strip trailing slashes from domains
cfg.https.domain = cfg.https.domain.replace(/\/*$/, "");
// 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.ip) {
srv.ip = "0.0.0.0";
}
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"] = cfg.https["domain"] + ":" + srv.port;
}
} else if (!cfg.io["ipv4-nossl"]) {
if (srv.url) {
cfg.io["ipv4-nossl"] = srv.url;
} else {
cfg.io["ipv4-nossl"] = cfg.io["domain"] + ":" + srv.port;
}
}
}
if (net.isIPv6(srv.ip) || srv.ip === "::") {
if (srv.https && !cfg.io["ipv6-ssl"]) {
if (!srv.url) {
LOGGER.error("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.error("(Listener was: " + JSON.stringify(srv) + ")");
} else {
cfg.io["ipv6-ssl"] = srv.url;
}
} else if (!cfg.io["ipv6-nossl"]) {
if (!srv.url) {
LOGGER.error("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.error("(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"];
if (/127\.0\.0\.1|localhost/.test(cfg.io["ipv4-default"])) {
LOGGER.warn(
"socket.io is bound to localhost, this server will be inaccessible " +
"from other computers!"
);
}
// Generate RegExps for reserved names
var reserved = cfg["reserved-names"];
for (var key in reserved) {
if (reserved[key] && reserved[key].length > 0) {
reserved[key] = new RegExp(reserved[key].join("|"), "i");
} else {
reserved[key] = false;
}
}
/* Convert channel blacklist to a hashtable */
var tbl = {};
cfg["channel-blacklist"].forEach(function (c) {
tbl[c.toLowerCase()] = true;
});
cfg["channel-blacklist"] = tbl;
/* Check channel path */
if(!/^[-\w]+$/.test(cfg["channel-path"])){
LOGGER.error("Channel paths may only use the same characters as usernames and channel names.");
process.exit(78); // sysexits.h for bad config
}
if (cfg["link-domain-blacklist"].length > 0) {
cfg["link-domain-blacklist-regex"] = new RegExp(
cfg["link-domain-blacklist"].join("|").replace(/\./g, "\\."), "gi");
} else {
// Match nothing
cfg["link-domain-blacklist-regex"] = new RegExp("$x^", "gi");
}
if (cfg["youtube-v3-key"]) {
require("@cytube/mediaquery/lib/provider/youtube").setApiKey(
cfg["youtube-v3-key"]);
} else {
LOGGER.warn("No YouTube v3 API key set. YouTube links will " +
"not work. See youtube-v3-key in config.template.yaml and " +
"https://developers.google.com/youtube/registering_an_application for " +
"information on registering an API key.");
}
if (cfg["twitch-client-id"]) {
require("@cytube/mediaquery/lib/provider/twitch-vod").setClientID(
cfg["twitch-client-id"]);
require("@cytube/mediaquery/lib/provider/twitch-clip").setClientID(
cfg["twitch-client-id"]);
} else {
LOGGER.warn("No Twitch Client ID set. Twitch VOD links will " +
"not work. See twitch-client-id in config.template.yaml and " +
"https://github.com/justintv/Twitch-API/blob/master/authentication.md#developer-setup" +
"for more information on registering a client ID");
}
// Remove calzoneman from contact config (old default)
cfg.contacts = cfg.contacts.filter(contact => {
return contact.name !== 'calzoneman';
});
if (!cfg.io.throttle) {
cfg.io.throttle = {};
}
cfg.io.throttle = Object.assign({
'in-rate-limit': Infinity
}, cfg.io.throttle);
cfg.io.throttle = Object.assign({
'bucket-capacity': cfg.io.throttle['in-rate-limit']
}, cfg.io.throttle);
if (!cfg['channel-storage']) {
cfg['channel-storage'] = { type: undefined };
}
return cfg;
}
/**
* Retrieves a configuration value with the given key
*
* Accepts a dot-separated key for nested values, e.g. "http.port"
* Throws an error if a nonexistant key is requested
*/
exports.get = function (key) {
var obj = cfg;
var keylist = key.split(".");
var current = keylist.shift();
var path = current;
while (keylist.length > 0) {
if (!(current in obj)) {
throw new Error("Nonexistant config key '" + path + "." + current + "'");
}
obj = obj[current];
current = keylist.shift();
path += "." + current;
}
return obj[current];
};
/**
* Sets a configuration value with the given key
*
* Accepts a dot-separated key for nested values, e.g. "http.port"
* Throws an error if a nonexistant key is requested
*/
exports.set = function (key, value) {
var obj = cfg;
var keylist = key.split(".");
var current = keylist.shift();
var path = current;
while (keylist.length > 0) {
if (!(current in obj)) {
throw new Error("Nonexistant config key '" + path + "." + current + "'");
}
obj = obj[current];
current = keylist.shift();
path += "." + current;
}
obj[current] = value;
};
exports.getCamoConfig = function getCamoConfig() {
return camoConfig;
};
exports.getPrometheusConfig = function getPrometheusConfig() {
return prometheusConfig;
};
exports.getEmailConfig = function getEmailConfig() {
return emailConfig;
};
exports.getCaptchaConfig = function getCaptchaConfig() {
return captchaConfig;
};

View file

@ -0,0 +1,51 @@
const SPECIALCHARS = /([\\.?+*$^|()[\]{}])/g;
class CamoConfig {
constructor(config = { camo: { enabled: false } }) {
this.config = config.camo;
if (this.config.server) {
this.config.server = this.config.server.replace(/\/+$/, '');
}
this.validate();
}
validate() {
if (this.config.encoding
&& !~['url', 'hex'].indexOf(this.config.encoding)) {
throw new Error(`Value for key 'encoding' must be either 'url' or 'hex', not '${this.config.encoding}'`);
}
}
isEnabled() {
return this.config.enabled;
}
getKey() {
return this.config.key;
}
getServer() {
return this.config.server;
}
getWhitelistedDomains() {
return this.config['whitelisted-domains'] || [];
}
getWhitelistedDomainsRegexp() {
const domains = this.getWhitelistedDomains()
.map(d => '\\.' + d.replace(SPECIALCHARS, '\\$1') + '$');
if (domains.length === 0) {
// If no whitelist, match nothing
return new RegExp('$^');
}
return new RegExp(domains.join('|'), 'i');
}
getEncoding() {
return this.config.encoding || 'url';
}
}
export { CamoConfig };

View file

@ -0,0 +1,30 @@
class CaptchaConfig {
constructor() {
this.load();
}
load(config = { hcaptcha: {}, register: { enabled: false } }) {
this.config = config;
const hcaptcha = config.hcaptcha;
this._hcaptcha = {
getSiteKey() {
return hcaptcha['site-key'];
},
getSecret() {
return hcaptcha.secret;
}
};
}
getHcaptcha() {
return this._hcaptcha;
}
isEnabled() {
return this.config.register.enabled;
}
}
export { CaptchaConfig };

View file

@ -0,0 +1,19 @@
import toml from 'toml';
import fs from 'fs';
/** @module cytube-common/configuration/configloader */
/**
* Load a toml file and pass the results to a configuration
* constructor.
*
* @param {function} constructor Constructor to call with the loaded data
* @param {string} filename Path to the toml file to load
* @returns {Object} Configuration object constructed from the provided constructor
* @throws {SyntaxError} Errors propagated from toml.parse()
*/
export function loadFromToml(constructor, filename) {
const rawContents = fs.readFileSync(filename).toString('utf8');
const configData = toml.parse(rawContents);
return new (constructor)(configData);
}

View file

@ -0,0 +1,88 @@
class EmailConfig {
constructor(config = { 'password-reset': { enabled: false }, smtp: {} }) {
this.config = config;
const smtp = config.smtp;
this._smtp = {
getHost() {
return smtp.host;
},
getPort() {
return smtp.port;
},
isSecure() {
return smtp.secure;
},
getUser() {
return smtp.user;
},
getPassword() {
return smtp.password;
}
};
const reset = config['password-reset'];
this._reset = {
isEnabled() {
return reset.enabled;
},
getHTML() {
return reset['html-template'];
},
getText() {
return reset['text-template'];
},
getFrom() {
return reset.from;
},
getSubject() {
return reset.subject;
}
};
const deleteAccount = config['delete-account'];
this._delete = {
isEnabled() {
return deleteAccount != null && deleteAccount.enabled;
},
getHTML() {
return deleteAccount['html-template'];
},
getText() {
return deleteAccount['text-template'];
},
getFrom() {
return deleteAccount.from;
},
getSubject() {
return deleteAccount.subject;
}
};
}
getSmtp() {
return this._smtp;
}
getPasswordReset() {
return this._reset;
}
getDeleteAccount() {
return this._delete;
}
}
export { EmailConfig };

View file

@ -0,0 +1,47 @@
export default class IOConfiguration {
constructor(config) {
this.config = config;
}
getSocketEndpoints() {
return this.config.endpoints.slice();
}
}
IOConfiguration.fromOldConfig = function (oldConfig) {
const config = {
endpoints: []
};
if (oldConfig.get('io.ipv4-ssl')) {
config.endpoints.push({
url: oldConfig.get('io.ipv4-ssl'),
secure: true
});
}
if (oldConfig.get('io.ipv4-nossl')) {
config.endpoints.push({
url: oldConfig.get('io.ipv4-nossl'),
secure: false
});
}
if (oldConfig.get('io.ipv6-ssl')) {
config.endpoints.push({
url: oldConfig.get('io.ipv4-ssl'),
secure: true,
ipv6: true
});
}
if (oldConfig.get('io.ipv6-nossl')) {
config.endpoints.push({
url: oldConfig.get('io.ipv4-nossl'),
secure: false,
ipv6: true
});
}
return new IOConfiguration(config);
};

View file

@ -0,0 +1,23 @@
class PrometheusConfig {
constructor(config = { prometheus: { enabled: false } }) {
this.config = config.prometheus;
}
isEnabled() {
return this.config.enabled;
}
getPort() {
return this.config.port;
}
getHost() {
return this.config.host;
}
getPath() {
return this.config.path;
}
}
export { PrometheusConfig };

View file

@ -0,0 +1,77 @@
import clone from 'clone';
export default class WebConfiguration {
constructor(config) {
this.config = config;
}
getEmailContacts() {
return clone(this.config.contacts);
}
getTrustedProxies() {
return this.config.trustProxies;
}
getCookieSecret() {
return this.config.authCookie.cookieSecret;
}
getCookieDomain() {
return this.config.authCookie.cookieDomain;
}
getEnableGzip() {
return this.config.gzip.enabled;
}
getGzipThreshold() {
return this.config.gzip.threshold;
}
getEnableMinification() {
return this.config.enableMinification;
}
getCacheTTL() {
return this.config.cacheTTL;
}
getMaxIndexEntries() {
return this.config.maxIndexEntries;
}
}
WebConfiguration.fromOldConfig = function (oldConfig) {
const config = {
contacts: []
};
oldConfig.get('contacts').forEach(contact => {
config.contacts.push({
name: contact.name,
email: contact.email,
title: contact.title
});
});
config.gzip = {
enabled: oldConfig.get('http.gzip'),
threshold: oldConfig.get('http.gzip-threshold')
};
config.authCookie = {
cookieSecret: oldConfig.get('http.cookie-secret'),
cookieDomain: oldConfig.get('http.root-domain-dotted')
};
config.enableMinification = oldConfig.get('http.minify');
config.cacheTTL = oldConfig.get('http.max-age');
config.maxIndexEntries = oldConfig.get('http.index.max-entries');
config.trustProxies = oldConfig.get('http.trust-proxies');
return new WebConfiguration(config);
};

102
src/controller/captcha.js Normal file
View file

@ -0,0 +1,102 @@
const https = require('https');
const querystring = require('querystring');
const { Counter } = require('prom-client');
const LOGGER = require('@calzoneman/jsli')('captcha-controller');
const captchaCount = new Counter({
name: 'cytube_captcha_count',
help: 'Count of captcha checks'
});
const captchaFailCount = new Counter({
name: 'cytube_captcha_failed_count',
help: 'Count of rejected captcha responses'
});
class CaptchaController {
constructor(config) {
this.config = config;
}
async verifyToken(token) {
return new Promise((resolve, reject) => {
let params = querystring.stringify({
secret: this.config.getHcaptcha().getSecret(),
response: token
});
let req = https.request(
'https://hcaptcha.com/siteverify',
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': params.length
}
}
);
req.setTimeout(10000, () => {
const error = new Error('Request timed out.');
error.code = 'ETIMEDOUT';
reject(error);
});
req.on('error', error => {
reject(error);
});
req.on('response', res => {
if (res.statusCode !== 200) {
req.abort();
reject(new Error(
`HTTP ${res.statusCode} ${res.statusMessage}`
));
return;
}
let buffer = '';
res.setEncoding('utf8');
res.on('data', data => {
buffer += data;
});
res.on('end', () => {
resolve(buffer);
});
});
req.write(params);
req.end();
}).then(body => {
captchaCount.inc(1);
let res = JSON.parse(body);
if (!res.success) {
captchaFailCount.inc(1);
if (res['error-codes'].length > 0) {
switch (res['error-codes'][0]) {
case 'missing-input-secret':
throw new Error('hCaptcha is misconfigured: missing secret');
case 'invalid-input-secret':
throw new Error('hCaptcha is misconfigured: invalid secret');
case 'sitekey-secret-mismatch':
throw new Error('hCaptcha is misconfigured: secret does not match site-key');
case 'invalid-input-response':
case 'invalid-or-already-seen-response':
throw new Error('Invalid captcha response');
default:
LOGGER.error('Unknown hCaptcha error; response: %j', res);
throw new Error('Unknown hCaptcha error: ' + res['error-codes'][0]);
}
} else {
throw new Error('Captcha verification failed');
}
}
});
}
}
export { CaptchaController };

52
src/controller/email.js Normal file
View file

@ -0,0 +1,52 @@
class EmailController {
constructor(mailer, config) {
this.mailer = mailer;
this.config = config;
}
async sendPasswordReset(params = {}) {
const { address, username, url } = params;
const resetConfig = this.config.getPasswordReset();
const html = resetConfig.getHTML()
.replace(/\$user\$/g, username)
.replace(/\$url\$/g, url);
const text = resetConfig.getText()
.replace(/\$user\$/g, username)
.replace(/\$url\$/g, url);
const result = await this.mailer.sendMail({
from: resetConfig.getFrom(),
to: `${username} <${address}>`,
subject: resetConfig.getSubject(),
html,
text
});
return result;
}
async sendAccountDeletion(params = {}) {
const { address, username } = params;
const deleteConfig = this.config.getDeleteAccount();
const html = deleteConfig.getHTML()
.replace(/\$user\$/g, username);
const text = deleteConfig.getText()
.replace(/\$user\$/g, username);
const result = await this.mailer.sendMail({
from: deleteConfig.getFrom(),
to: `${username} <${address}>`,
subject: deleteConfig.getSubject(),
html,
text
});
return result;
}
}
export { EmailController };

267
src/custom-media.js Normal file
View file

@ -0,0 +1,267 @@
import { ValidationError } from './errors';
import { parse as urlParse } from 'url';
import net from 'net';
import Media from './media';
import { get as httpGet } from 'http';
import { get as httpsGet } from 'https';
const LOGGER = require('@calzoneman/jsli')('custom-media');
const SOURCE_QUALITIES = new Set([
240,
360,
480,
540,
720,
1080,
1440,
2160
]);
const SOURCE_CONTENT_TYPES = new Set([
'application/dash+xml',
'application/x-mpegURL',
'audio/aac',
'audio/ogg',
'audio/mpeg',
'audio/opus',
'video/mp4',
'video/ogg',
'video/webm'
]);
const LIVE_ONLY_CONTENT_TYPES = new Set([
'application/dash+xml'
]);
export function lookup(url, opts) {
if (!opts) opts = {};
if (!opts.hasOwnProperty('timeout')) opts.timeout = 10000;
return new Promise((resolve, reject) => {
const options = {
headers: {
'Accept': 'application/json'
}
};
Object.assign(options, parseURL(url));
if (!/^https?:$/.test(options.protocol)) {
reject(new ValidationError(
`Unacceptable protocol "${options.protocol}". Custom metadata must be`
+ ' retrieved by HTTP or HTTPS'
));
return;
}
LOGGER.info('Looking up %s', url);
// this is fucking stupid
const get = options.protocol === 'https:' ? httpsGet : httpGet;
const req = get(options);
req.setTimeout(opts.timeout, () => {
const error = new Error('Request timed out');
error.code = 'ETIMEDOUT';
reject(error);
});
req.on('error', error => {
LOGGER.warn('Request for %s failed: %s', url, error);
reject(error);
});
req.on('response', res => {
if (res.statusCode !== 200) {
req.abort();
reject(new Error(
`Expected HTTP 200 OK, not ${res.statusCode} ${res.statusMessage}`
));
return;
}
if (!/^application\/json/.test(res.headers['content-type'])) {
req.abort();
reject(new Error(
`Expected content-type application/json, not ${res.headers['content-type']}`
));
return;
}
let buffer = '';
res.setEncoding('utf8');
res.on('data', data => {
buffer += data;
if (buffer.length > 100 * 1024) {
req.abort();
reject(new Error('Response size exceeds 100KB'));
}
});
res.on('end', () => {
resolve(buffer);
});
});
}).then(body => {
return convert(url, JSON.parse(body));
});
}
export function convert(id, data) {
validate(data);
if (data.live) data.duration = 0;
const sources = {};
for (let source of data.sources) {
if (!sources.hasOwnProperty(source.quality))
sources[source.quality] = [];
sources[source.quality].push({
link: source.url,
contentType: source.contentType,
quality: source.quality
});
}
const meta = {
direct: sources,
textTracks: data.textTracks,
thumbnail: data.thumbnail, // Currently ignored by Media
live: !!data.live // Currently ignored by Media
};
return new Media(id, data.title, data.duration, 'cm', meta);
}
export function validate(data) {
if (typeof data.title !== 'string')
throw new ValidationError('title must be a string');
if (!data.title)
throw new ValidationError('title must not be blank');
if (typeof data.duration !== 'number')
throw new ValidationError('duration must be a number');
if (!isFinite(data.duration) || data.duration < 0)
throw new ValidationError('duration must be a non-negative finite number');
if (data.hasOwnProperty('live') && typeof data.live !== 'boolean')
throw new ValidationError('live must be a boolean');
if (data.hasOwnProperty('thumbnail')) {
if (typeof data.thumbnail !== 'string')
throw new ValidationError('thumbnail must be a string');
validateURL(data.thumbnail);
}
validateSources(data.sources, data);
validateTextTracks(data.textTracks);
}
function validateSources(sources, data) {
if (!Array.isArray(sources))
throw new ValidationError('sources must be a list');
if (sources.length === 0)
throw new ValidationError('source list must be nonempty');
for (let source of sources) {
if (typeof source.url !== 'string')
throw new ValidationError('source URL must be a string');
validateURL(source.url);
if (!SOURCE_CONTENT_TYPES.has(source.contentType))
throw new ValidationError(
`unacceptable source contentType "${source.contentType}"`
);
if (LIVE_ONLY_CONTENT_TYPES.has(source.contentType) && !data.live)
throw new ValidationError(
`contentType "${source.contentType}" requires live: true`
);
if (!SOURCE_QUALITIES.has(source.quality))
throw new ValidationError(`unacceptable source quality "${source.quality}"`);
if (source.hasOwnProperty('bitrate')) {
if (typeof source.bitrate !== 'number')
throw new ValidationError('source bitrate must be a number');
if (!isFinite(source.bitrate) || source.bitrate < 0)
throw new ValidationError(
'source bitrate must be a non-negative finite number'
);
}
}
}
function validateTextTracks(textTracks) {
if (typeof textTracks === 'undefined') {
return;
}
if (!Array.isArray(textTracks))
throw new ValidationError('textTracks must be a list');
let default_count = 0;
for (let track of textTracks) {
if (typeof track.url !== 'string')
throw new ValidationError('text track URL must be a string');
validateURL(track.url);
if (track.contentType !== 'text/vtt')
throw new ValidationError(
`unacceptable text track contentType "${track.contentType}"`
);
if (typeof track.name !== 'string')
throw new ValidationError('text track name must be a string');
if (!track.name)
throw new ValidationError('text track name must be nonempty');
if (typeof track.default !== 'undefined') {
if (default_count > 0)
throw new ValidationError('only one default text track is allowed');
else if (typeof track.default !== 'boolean' || track.default !== true)
throw new ValidationError('text default attribute must be set to boolean true');
else
default_count++;
}
}
}
function parseURL(urlstring) {
const url = urlParse(urlstring);
// legacy url.parse doesn't check this
if (url.protocol == null || url.host == null) {
throw new Error(`Invalid URL "${urlstring}"`);
}
return url;
}
function validateURL(urlstring) {
let url;
try {
url = parseURL(urlstring);
} catch (error) {
throw new ValidationError(`invalid URL "${urlstring}"`);
}
if (url.protocol !== 'https:')
throw new ValidationError(`URL protocol must be HTTPS (invalid: "${urlstring}")`);
if (net.isIP(url.hostname))
throw new ValidationError(
'URL hostname must be a domain name, not an IP address'
+ ` (invalid: "${urlstring}")`
);
}

46
src/customembed.js Normal file
View file

@ -0,0 +1,46 @@
var cheerio = require("cheerio");
var crypto = require("crypto");
var Media = require("./media");
function sha256(input) {
var hash = crypto.createHash("sha256");
hash.update(input);
return hash.digest("base64");
}
function filter(input) {
var $ = cheerio.load(input, {
lowerCaseTags: true,
lowerCaseAttributeNames: true
});
var meta = getMeta($);
var id = "cu:" + sha256(input);
return new Media(id, "Custom Media", "--:--", "cu", meta);
}
function getMeta($) {
let tag = $("iframe");
if (tag.length !== 0) {
return filterIframe(tag[0]);
}
throw new Error("Invalid embed. Input must be an <iframe> tag");
}
function filterIframe(tag) {
if (!/^https:/.test(tag.attribs.src)) {
throw new Error("Invalid embed. Embed source must be HTTPS, plain HTTP is not supported.");
}
var meta = {
embed: {
tag: "iframe",
src: tag.attribs.src
}
};
return meta;
}
exports.filter = filter;

446
src/database.js Normal file
View file

@ -0,0 +1,446 @@
var Config = require("./config");
var tables = require("./database/tables");
import knex from 'knex';
import { GlobalBanDB } from './db/globalban';
import { MetadataCacheDB } from './database/metadata_cache';
import { Summary, Counter } from 'prom-client';
const LOGGER = require('@calzoneman/jsli')('database');
const queryLatency = new Summary({
name: 'cytube_db_query_duration_seconds',
help: 'DB query latency (including time spent acquiring connections)'
});
const queryCount = new Counter({
name: 'cytube_db_queries_total',
help: 'DB query count'
});
const queryErrorCount = new Counter({
name: 'cytube_db_query_errors_total',
help: 'DB query error count'
});
setInterval(() => {
queryLatency.reset();
}, 5 * 60 * 1000).unref();
let db = null;
let globalBanDB = null;
class Database {
constructor(knexConfig = null) {
if (knexConfig === null) {
knexConfig = {
client: 'mysql',
connection: {
host: Config.get('mysql.server'),
port: Config.get('mysql.port'),
user: Config.get('mysql.user'),
password: Config.get('mysql.password'),
database: Config.get('mysql.database'),
multipleStatements: true, // Legacy thing
charset: 'utf8mb4'
},
pool: {
min: Config.get('mysql.pool-size'),
max: Config.get('mysql.pool-size')
},
debug: !!process.env.KNEX_DEBUG
};
}
this.knex = knex(knexConfig);
}
runTransaction(fn) {
const end = queryLatency.startTimer();
return this.knex.transaction(fn).catch(error => {
queryErrorCount.inc(1);
throw error;
}).finally(() => {
end();
queryCount.inc(1);
});
}
}
module.exports.Database = Database;
module.exports.users = require("./database/accounts");
module.exports.channels = require("./database/channels");
module.exports.init = function (newDB) {
if (newDB) {
db = newDB;
} else {
db = new Database();
}
db.knex.raw('select 1 from dual')
.catch(error => {
LOGGER.error('Initial database connection failed: %s', error.stack);
process.exit(1);
})
.then(() => tables.initTables())
.then(() => {
require('./database/update').checkVersion();
module.exports.loadAnnouncement();
require('@cytube/mediaquery/lib/provider/youtube').setCache(
new MetadataCacheDB(db)
);
}).catch(error => {
LOGGER.error(error.stack);
process.exit(1);
});
};
module.exports.getDB = function getDB() {
return db;
};
module.exports.getGlobalBanDB = function getGlobalBanDB() {
if (globalBanDB === null) {
globalBanDB = new GlobalBanDB(db);
}
return globalBanDB;
};
/**
* Execute a database query
*/
module.exports.query = function (query, sub, callback) {
// 2nd argument is optional
if (typeof sub === "function") {
callback = sub;
sub = undefined;
}
if (typeof callback !== "function") {
callback = blackHole;
}
if (process.env.SHOW_SQL) {
LOGGER.debug('%s', query);
}
const end = queryLatency.startTimer();
db.knex.raw(query, sub)
.then(res => {
process.nextTick(callback, null, res[0]);
}).catch(error => {
queryErrorCount.inc(1);
if (!sub) {
sub = [];
}
let subs = JSON.stringify(sub);
if (subs.length > 100) {
subs = subs.substring(0, 100) + '...';
}
// Attempt to strip off the beginning of the message which
// contains the entire substituted SQL query (followed by an
// error code)
// Thanks MySQL/MariaDB...
error.message = error.message.replace(/^.* - ER/, 'ER');
LOGGER.error(
'Legacy DB query failed. Query: %s, Substitutions: %s, ' +
'Error: %s',
query,
subs,
error
);
process.nextTick(callback, 'Database failure', null);
}).finally(() => {
end();
queryCount.inc(1);
});
};
/**
* Dummy function to be used as a callback when none is provided
*/
function blackHole() {
}
/* password recovery */
/**
* Deletes recovery rows older than the given time
*/
module.exports.cleanOldPasswordResets = function (callback) {
if (typeof callback === "undefined") {
callback = blackHole;
}
var query = "DELETE FROM password_reset WHERE expire < ?";
module.exports.query(query, [Date.now() - 24*60*60*1000], callback);
};
module.exports.addPasswordReset = function (data, cb) {
if (typeof cb !== "function") {
cb = blackHole;
}
var ip = data.ip || "";
var name = data.name;
var email = data.email;
var hash = data.hash;
var expire = data.expire;
if (!name || !hash) {
cb("Internal error: Must provide name and hash to insert a new password reset", null);
return;
}
module.exports.query("INSERT INTO `password_reset` (`ip`, `name`, `email`, `hash`, `expire`) " +
"VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE ip=?, hash=?, email=?, expire=?",
[ip, name, email, hash, expire, ip, hash, email, expire], cb);
};
module.exports.lookupPasswordReset = function (hash, cb) {
if (typeof cb !== "function") {
return;
}
module.exports.query("SELECT * FROM `password_reset` WHERE hash=?", [hash],
function (err, rows) {
if (err) {
cb(err, null);
} else if (rows.length === 0) {
cb("Invalid password reset link", null);
} else {
cb(null, rows[0]);
}
});
};
module.exports.deletePasswordReset = function (hash) {
module.exports.query("DELETE FROM `password_reset` WHERE hash=?", [hash]);
};
/* user playlists */
/**
* Retrieve all of a user's playlists
*/
module.exports.listUserPlaylists = function (name, callback) {
if (typeof callback !== "function") {
return;
}
var query = "SELECT name, count, duration FROM user_playlists WHERE user=?";
module.exports.query(query, [name], callback);
};
/**
* Retrieve a user playlist by (user, name) pair
*/
module.exports.getUserPlaylist = function (username, plname, callback) {
if (typeof callback !== "function") {
return;
}
var query = "SELECT contents FROM user_playlists WHERE " +
"user=? AND name=?";
module.exports.query(query, [username, plname], function (err, res) {
if (err) {
callback(err, null);
return;
}
if (res.length == 0) {
callback("Playlist does not exist", null);
return;
}
var pl = null;
try {
pl = JSON.parse(res[0].contents);
} catch(e) {
callback("Malformed playlist JSON", null);
return;
}
callback(null, pl);
});
};
/**
* Saves a user playlist. Overwrites if the playlist keyed by
* (user, name) already exists
*/
module.exports.saveUserPlaylist = function (pl, username, plname, callback) {
if (typeof callback !== "function") {
callback = blackHole;
}
var tmp = [], time = 0;
for(var i in pl) {
var e = {
id: pl[i].media.id,
title: pl[i].media.title,
seconds: pl[i].media.seconds || 0,
type: pl[i].media.type,
meta: {
codec: pl[i].media.meta.codec,
bitrate: pl[i].media.meta.bitrate,
scuri: pl[i].media.meta.scuri,
embed: pl[i].media.meta.embed
}
};
time += pl[i].media.seconds || 0;
tmp.push(e);
}
var count = tmp.length;
var plText = JSON.stringify(tmp);
var query = "INSERT INTO user_playlists VALUES (?, ?, ?, ?, ?) " +
"ON DUPLICATE KEY UPDATE contents=?, count=?, duration=?";
var params = [username, plname, plText, count, time,
plText, count, time];
module.exports.query(query, params, callback);
};
/**
* Deletes a user playlist
*/
module.exports.deleteUserPlaylist = function (username, plname, callback) {
if (typeof callback !== "function") {
callback = blackHole;
}
var query = "DELETE FROM user_playlists WHERE user=? AND name=?";
module.exports.query(query, [username, plname], callback);
};
/* aliases */
/**
* Records a user or guest login in the aliases table
*/
module.exports.recordVisit = function (ip, name, callback) {
if (typeof callback !== "function") {
callback = blackHole;
}
var time = Date.now();
var query = "DELETE FROM aliases WHERE ip=? AND name=?;" +
"INSERT INTO aliases VALUES (NULL, ?, ?, ?)";
module.exports.query(query, [ip, name, ip, name, time], callback);
};
/**
* Deletes alias rows older than the given time
*/
module.exports.cleanOldAliases = function (expiration, callback) {
if (typeof callback === "undefined") {
callback = blackHole;
}
var query = "DELETE FROM aliases WHERE time < ?";
module.exports.query(query, [Date.now() - expiration], callback);
};
/**
* Retrieves a list of aliases for an IP address
*/
module.exports.getAliases = function (ip, callback) {
if (typeof callback !== "function") {
return;
}
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+$/) || 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 += "=?";
}
query += " ORDER BY time DESC LIMIT 5";
module.exports.query(query, [ip], function (err, res) {
var names = null;
if(!err) {
names = res.map(function (row) { return row.name; });
}
callback(err, names);
});
};
/**
* Retrieves a list of IPs that a name as logged in from
*/
module.exports.getIPs = function (name, callback) {
if (typeof callback !== "function") {
return;
}
var query = "SELECT ip FROM aliases WHERE name=?";
module.exports.query(query, [name], function (err, res) {
var ips = null;
if(!err) {
ips = res.map(function (row) { return row.ip; });
}
callback(err, ips);
});
};
/* END REGION */
/* Misc */
module.exports.loadAnnouncement = function () {
var query = "SELECT * FROM `meta` WHERE `key`='announcement'";
module.exports.query(query, function (err, rows) {
if (err) {
return;
}
if (rows.length === 0) {
return;
}
var announcement = rows[0].value;
try {
announcement = JSON.parse(announcement);
} catch (e) {
LOGGER.error("Invalid announcement data in database: " +
announcement.value);
module.exports.clearAnnouncement();
return;
}
var Server = require("./server");
if (!Server.getServer || !Server.getServer()) {
return;
}
var sv = Server.getServer();
sv.announcement = announcement;
for (var id in sv.ioServers) {
sv.ioServers[id].emit("announcement", announcement);
}
});
};
module.exports.setAnnouncement = function (data) {
var query = "INSERT INTO `meta` (`key`, `value`) VALUES ('announcement', ?) " +
"ON DUPLICATE KEY UPDATE `value`=?";
var repl = JSON.stringify(data);
module.exports.query(query, [repl, repl]);
};
module.exports.clearAnnouncement = function () {
module.exports.query("DELETE FROM `meta` WHERE `key`='announcement'");
};

588
src/database/accounts.js Normal file
View file

@ -0,0 +1,588 @@
var $util = require("../utilities");
var bcrypt = require("bcrypt");
var db = require("../database");
var Config = require("../config");
import { promisify } from "bluebird";
const LOGGER = require('@calzoneman/jsli')('database/accounts');
var registrationLock = {};
var blackHole = function () { };
function parseProfile(data) {
try {
var profile = JSON.parse(data.profile);
profile.image = profile.image || "";
profile.text = profile.text || "";
data.profile = profile;
} catch (error) {
data.profile = { image: "", text: "" };
}
}
module.exports = {
init: function () {
},
/**
* Convert a username for deduplication purposes.
* Collapses visibily similar characters into a single character.
* @param name
*/
dedupeUsername: function dedupeUsername(name) {
return name.replace(/[Il1]/ig, '1')
.replace(/[o0]/ig, '0')
.replace(/[_-]/g, '_');
},
/**
* Check if a username is taken
*/
isUsernameTaken: function (name, callback) {
db.query("SELECT name FROM `users` WHERE name = ? or name_dedupe = ?",
[name, module.exports.dedupeUsername(name)],
function (err, rows) {
if (err) {
callback(err, true);
return;
}
let matched = null;
rows.forEach(row => {
if (row.name === name) {
matched = name;
} else if (matched === null) {
matched = row.name;
}
});
callback(
null,
rows.length > 0,
matched
);
});
},
/**
* Search for a user by any field
*/
search: function (where, like, fields, callback) {
// Don't allow search to return password hashes
if (fields.indexOf("password") !== -1) {
fields.splice(fields.indexOf("password"));
}
db.query(`SELECT ${fields.join(",")} FROM \`users\` WHERE ${where} LIKE ?`,
["%"+like+"%"],
function (err, rows) {
if (err) {
callback(err, true);
return;
}
callback(null, rows);
});
},
getUser: function (name, callback) {
if (typeof callback !== "function") {
return;
}
db.query("SELECT * FROM `users` WHERE name = ? AND inactive = FALSE",
[name],
function (err, rows) {
if (err) {
callback(err, true);
return;
}
if (rows.length !== 1) {
return callback("User does not exist");
}
parseProfile(rows[0]);
callback(null, rows[0]);
});
},
/**
* Registers a new user account
*/
register: function (name, pw, email, ip, callback) {
// Start off with a boatload of error checking
if (typeof callback !== "function") {
callback = blackHole;
}
if (typeof name !== "string" || typeof pw !== "string") {
callback("You must provide a nonempty username and password", null);
return;
}
var lname = name.toLowerCase();
if (registrationLock[lname]) {
callback("There is already a registration in progress for "+name,
null);
return;
}
if (!$util.isValidUserName(name)) {
callback("Invalid username. Usernames may consist of 1-20 " +
"characters a-z, A-Z, 0-9, -, _, and accented letters.",
null);
return;
}
if (typeof email !== "string") {
email = "";
}
if (typeof ip !== "string") {
ip = "";
}
// From this point forward, actual registration happens
// registrationLock prevents concurrent database activity
// on the same user account
registrationLock[lname] = true;
this.getAccounts(ip, function (err, accts) {
if (err) {
delete registrationLock[lname];
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 "+
"computer.", null);
return;
}
module.exports.isUsernameTaken(name, function (err, taken, matched) {
if (err) {
delete registrationLock[lname];
callback(err, null);
return;
}
if (taken) {
delete registrationLock[lname];
if (matched === name) {
callback(
`Please choose a different username: "${name}" ` +
`is already registered.`,
null
);
} else {
callback(
`Please choose a different username: "${name}" ` +
`too closely matches an existing name. ` +
`For example, "Joe" (lowercase 'o'), and ` +
`"j0e" (zero) are not considered unique.`,
null
);
}
return;
}
bcrypt.hash(pw, 10, function (err, hash) {
if (err) {
delete registrationLock[lname];
callback(err, null);
return;
}
db.query("INSERT INTO `users` " +
"(`name`, `password`, `global_rank`, `email`, `profile`, `ip`, `time`, `name_dedupe`)" +
" VALUES " +
"(?, ?, ?, ?, '', ?, ?, ?)",
[name, hash, 1, email, ip, Date.now(), module.exports.dedupeUsername(name)],
function (err, _res) {
delete registrationLock[lname];
if (err) {
callback(err, null);
} else {
callback(null, {
name: name,
hash: hash
});
}
});
});
});
});
},
/**
* Verify a username/password pair
*/
verifyLogin: function (name, pw, callback) {
if (typeof callback !== "function") {
return;
}
if (typeof name !== "string" || typeof pw !== "string") {
callback("Invalid username/password combination", null);
return;
}
if (!$util.isValidUserName(name)) {
callback(`Invalid username "${name}"`);
return;
}
/* Passwords are capped at 100 characters to prevent a potential
denial of service vector through causing the server to hash
ridiculously long strings.
*/
pw = pw.substring(0, 100);
/* Note: rather than hash the password and then query based on name and
password, I query by name, then use bcrypt.compare() to check that
the hashes match.
*/
db.query("SELECT * FROM `users` WHERE name=? AND inactive = FALSE",
[name],
function (err, rows) {
if (err) {
callback(err, null);
return;
}
if (rows.length === 0) {
callback("User does not exist", null);
return;
}
bcrypt.compare(pw, rows[0].password, function (err, match) {
if (err) {
callback(err, null);
} else if (!match) {
callback("Invalid username/password combination", null);
} else {
parseProfile(rows[0]);
callback(null, rows[0]);
}
});
});
},
/**
* Change a user's password
*/
setPassword: function (name, pw, callback) {
if (typeof callback !== "function") {
callback = blackHole;
}
if (typeof name !== "string" || typeof pw !== "string") {
callback("Invalid username/password combination", null);
return;
}
/* Passwords are capped at 100 characters to prevent a potential
denial of service vector through causing the server to hash
ridiculously long strings.
*/
pw = pw.substring(0, 100);
bcrypt.hash(pw, 10, function (err, hash) {
if (err) {
callback(err, null);
return;
}
db.query("UPDATE `users` SET password=? WHERE name=?",
[hash, name],
function (err, _result) {
callback(err, err ? null : true);
});
});
},
/**
* Lookup a user's global rank
*/
getGlobalRank: function (name, callback) {
if (typeof callback !== "function") {
return;
}
if (typeof name !== "string") {
callback("Invalid username", null);
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(null, 0);
} else {
callback(null, rows[0].global_rank);
}
});
},
/**
* Updates a user's global rank
*/
setGlobalRank: function (name, rank, callback) {
if (typeof callback !== "function") {
callback = blackHole;
}
if (typeof name !== "string") {
callback("Invalid username", null);
return;
}
if (typeof rank !== "number") {
callback("Invalid rank", null);
return;
}
db.query("UPDATE `users` SET global_rank=? WHERE name=?", [rank, name],
function (err, _result) {
callback(err, err ? null : true);
});
},
/**
* Lookup multiple users' global rank in one query
*/
getGlobalRanks: function (names, callback) {
if (typeof callback !== "function") {
return;
}
if (!(names instanceof Array)) {
callback("Expected array of names, got " + typeof names, null);
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,
function (err, rows) {
if (err) {
callback(err, null);
} else if (rows.length === 0) {
callback(null, []);
} else {
callback(null, rows.map(function (x) { return x.global_rank; }));
}
});
},
/**
* Lookup a user's email
*/
getEmail: function (name, callback) {
if (typeof callback !== "function") {
return;
}
if (typeof name !== "string") {
callback("Invalid username", null);
return;
}
db.query("SELECT email FROM `users` WHERE name=? AND inactive = FALSE", [name],
function (err, rows) {
if (err) {
callback(err, null);
} else if (rows.length === 0) {
callback("User does not exist", null);
} else {
callback(null, rows[0].email);
}
});
},
/**
* Updates a user's email
*/
setEmail: function (name, email, callback) {
if (typeof callback !== "function") {
callback = blackHole;
}
if (typeof name !== "string") {
callback("Invalid username", null);
return;
}
if (typeof email !== "string") {
callback("Invalid email", null);
return;
}
db.query("UPDATE `users` SET email=? WHERE name=?", [email, name],
function (err, _result) {
callback(err, err ? null : true);
});
},
/**
* Lookup a user's profile
*/
getProfile: function (name, callback) {
if (typeof callback !== "function") {
return;
}
if (typeof name !== "string") {
callback("Invalid username", null);
return;
}
db.query("SELECT profile FROM `users` WHERE name=?", [name],
function (err, rows) {
if (err) {
callback(err, null);
} else if (rows.length === 0) {
callback("User does not exist", null);
} else {
var userprof = {
image: "",
text: ""
};
if (rows[0].profile === "") {
callback(null, userprof);
return;
}
try {
var profile = JSON.parse(rows[0].profile);
userprof.image = profile.image || "";
userprof.text = profile.text || "";
callback(null, userprof);
} catch (e) {
LOGGER.error("Corrupt profile: " + rows[0].profile +
" (user: " + name + ")");
callback(null, userprof);
}
}
});
},
/**
* Updates a user's profile
*/
setProfile: function (name, profile, callback) {
if (typeof callback !== "function") {
callback = blackHole;
}
if (typeof name !== "string") {
callback("Invalid username", null);
return;
}
if (typeof profile !== "object") {
callback("Invalid profile", null);
return;
}
// Cast to string to guarantee string type
profile.image += "";
profile.text += "";
// Limit size
profile.image = profile.image.substring(0, 255);
profile.text = profile.text.substring(0, 255);
// Stringify the literal to guarantee I only get the keys I want
var profilejson = JSON.stringify({
image: profile.image,
text: profile.text
});
db.query("UPDATE `users` SET profile=? WHERE name=?", [profilejson, name],
function (err, _result) {
callback(err, err ? null : true);
});
},
/**
* Retrieves all names registered from a given IP
*/
getAccounts: function (ip, callback) {
if (typeof callback !== "function") {
return;
}
db.query("SELECT name,global_rank FROM `users` WHERE `ip`=?", [ip],
callback);
},
requestAccountDeletion: id => {
return db.getDB().runTransaction(async tx => {
try {
let user = await tx.table('users').where({ id }).first();
await tx.table('user_deletion_requests')
.insert({
user_id: id
});
await tx.table('users')
.where({ id })
.update({ password: '', inactive: true });
// TODO: ideally password reset should be by user_id and not name
// For now, we need to make sure to clear it
await tx.table('password_reset')
.where({ name: user.name })
.delete();
} catch (error) {
// Ignore unique violation -- probably caused by a duplicate request
if (error.code !== 'ER_DUP_ENTRY') {
throw error;
}
}
});
},
findAccountsPendingDeletion: () => {
return db.getDB().runTransaction(tx => {
let lastWeek = new Date(Date.now() - 7 * 24 * 3600 * 1000);
return tx.table('user_deletion_requests')
.where('user_deletion_requests.created_at', '<', lastWeek)
.join('users', 'user_deletion_requests.user_id', '=', 'users.id')
.select('users.id', 'users.name');
});
},
purgeAccount: id => {
return db.getDB().runTransaction(async tx => {
let user = await tx.table('users').where({ id }).first();
if (!user) {
return false;
}
await tx.table('channel_ranks').where({ name: user.name }).delete();
await tx.table('user_playlists').where({ user: user.name }).delete();
await tx.table('users').where({ id }).delete();
return true;
});
}
};
module.exports.verifyLoginAsync = promisify(module.exports.verifyLogin);

708
src/database/channels.js Normal file
View file

@ -0,0 +1,708 @@
var db = require("../database");
var valid = require("../utilities").isValidChannelName;
var Flags = require("../flags");
var util = require("../utilities");
import { createMySQLDuplicateKeyUpdate } from '../util/on-duplicate-key-update';
import Config from '../config';
const LOGGER = require('@calzoneman/jsli')('database/channels');
var blackHole = function () { };
module.exports = {
init: function () {
},
/**
* Checks if the given channel name is registered
*/
isChannelTaken: function (name, callback) {
if (typeof callback !== "function") {
return;
}
if (!valid(name)) {
callback("Invalid channel name", null);
return;
}
db.query("SELECT name FROM `channels` WHERE name=?",
[name],
function (err, rows) {
if (err) {
callback(err, true);
return;
}
callback(null, rows.length > 0);
});
},
/**
* Looks up a channel
*/
lookup: function (name, callback) {
if (typeof callback !== "function") {
return;
}
if (!valid(name)) {
callback("Invalid channel name", null);
return;
}
db.query("SELECT * FROM `channels` WHERE name=?",
[name],
function (err, rows) {
if (err) {
callback(err, null);
return;
}
if (rows.length === 0) {
callback("No such channel", null);
} else {
callback(null, rows[0]);
}
});
},
/**
* Searches for a channel
*/
search: function (name, callback) {
if (typeof callback !== "function") {
return;
}
db.query("SELECT * FROM `channels` WHERE name LIKE ?",
["%" + name + "%"],
function (err, rows) {
if (err) {
callback(err, null);
return;
}
callback(null, rows);
});
},
/**
* Searches for a channel by owner
*/
searchOwner: function (name, callback) {
if (typeof callback !== "function") {
return;
}
db.query("SELECT * FROM `channels` WHERE owner LIKE ?",
["%" + name + "%"],
function (err, rows) {
if (err) {
callback(err, null);
return;
}
callback(null, rows);
});
},
/**
* Validates and registers a new channel
*/
register: function (name, owner, callback) {
if (typeof callback !== "function") {
callback = blackHole;
}
if (typeof name !== "string" || typeof owner !== "string") {
callback("Name and owner are required for channel registration", null);
return;
}
if (!valid(name)) {
callback("Invalid channel name. Channel names may consist of 1-30 " +
"characters a-z, A-Z, 0-9, -, and _", null);
return;
}
module.exports.isChannelTaken(name, function (err, taken) {
if (err) {
callback(err, null);
return;
}
if (taken) {
callback("Channel name " + name + " is already taken", null);
return;
}
db.query("INSERT INTO `channels` " +
"(`name`, `owner`, `time`, `last_loaded`) VALUES (?, ?, ?, ?)",
[name, owner, Date.now(), new Date()],
function (err, _res) {
if (err) {
callback(err, null);
return;
}
db.users.getGlobalRank(owner, function (err, rank) {
if (err) {
callback(err, null);
return;
}
rank = Math.max(rank, 5);
module.exports.setRank(name, owner, rank, function (err) {
if (err) {
callback(err, null);
return;
}
callback(null, { name: name });
});
});
});
});
},
/**
* Unregisters a channel
*/
drop: function (name, callback) {
if (typeof callback !== "function") {
callback = blackHole;
}
if (!valid(name)) {
callback("Invalid channel name", null);
return;
}
db.query("DELETE FROM `channels` WHERE name=?", [name], function (err) {
module.exports.deleteBans(name, function (err) {
if (err) {
LOGGER.error("Failed to delete bans for " + name + ": " + err);
}
});
module.exports.deleteLibrary(name, function (err) {
if (err) {
LOGGER.error("Failed to delete library for " + name + ": " + err);
}
});
module.exports.deleteAllRanks(name, function (err) {
if (err) {
LOGGER.error("Failed to delete ranks for " + name + ": " + err);
}
});
callback(err, !err);
});
},
/**
* Looks up channels registered by a given user
*/
listUserChannels: function (owner, callback) {
if (typeof callback !== "function") {
return;
}
db.query("SELECT * FROM `channels` WHERE owner=?", [owner],
function (err, res) {
if (err) {
callback(err, []);
return;
}
callback(err, res);
});
},
listUserChannelsAsync: owner => {
return new Promise((resolve, reject) => {
module.exports.listUserChannels(owner, (error, rows) => {
if (error) {
reject(error);
} else {
resolve(rows);
}
});
});
},
/**
* Loads the channel from the database
*/
load: function (chan, callback) {
if (typeof callback !== "function") {
callback = blackHole;
}
if (!valid(chan.name)) {
callback("Invalid channel name", null);
return;
}
db.query("SELECT * FROM `channels` WHERE name=?", chan.name, function (err, res) {
if (err) {
callback(err, null);
return;
}
if (res.length === 0) {
callback("Channel is not registered", null);
return;
}
if (chan.dead) {
callback("Channel is dead", null);
return;
}
// Note that before this line, chan.name might have a different capitalization
// than the database has stored. Update accordingly.
chan.name = res[0].name;
chan.uniqueName = chan.name.toLowerCase();
chan.id = res[0].id;
chan.ownerName = typeof res[0].owner === 'string' ? res[0].owner.toLowerCase() : null;
chan.setFlag(Flags.C_REGISTERED);
chan.logger.log("[init] Loaded channel from database");
callback(null, true);
});
},
/**
* Looks up a user's rank
*/
getRank: function (chan, name, callback) {
if (typeof callback !== "function") {
return;
}
if (!valid(chan)) {
callback("Invalid channel name", null);
return;
}
db.query("SELECT * FROM `channel_ranks` WHERE name=? AND channel=?",
[name, chan],
function (err, rows) {
if (err) {
callback(err, -1);
return;
}
if (rows.length === 0) {
callback(null, 1);
return;
}
callback(null, rows[0].rank);
});
},
/**
* Looks up multiple users' ranks at once
*/
getRanks: function (chan, names, callback) {
if (typeof callback !== "function") {
return;
}
if (!valid(chan)) {
callback("Invalid channel name", null);
return;
}
var replace = "(" + names.map(function () { return "?"; }).join(",") + ")";
/* Last substitution is the channel to select ranks for */
const sub = names.concat([chan]);
db.query("SELECT * FROM `channel_ranks` WHERE name IN " +
replace + " AND channel=?", sub,
function (err, rows) {
if (err) {
callback(err, []);
return;
}
callback(null, rows.map(function (r) { return r.rank; }));
});
},
/**
* Query all user ranks at once
*/
allRanks: function (chan, callback) {
if (typeof callback !== "function") {
return;
}
if (!valid(chan)) {
callback("Invalid channel name", null);
return;
}
db.query("SELECT * FROM `channel_ranks` WHERE channel=?", [chan], callback);
},
/**
* Updates a user's rank
*/
setRank: function (chan, name, rank, callback) {
if (typeof callback !== "function") {
callback = blackHole;
}
if (rank < 2) {
module.exports.deleteRank(chan, name, callback);
return;
}
if (!valid(chan)) {
callback("Invalid channel name", null);
return;
}
db.query("INSERT INTO `channel_ranks` VALUES (?, ?, ?) " +
"ON DUPLICATE KEY UPDATE `rank`=?",
[name, rank, chan, rank], callback);
},
/**
* Removes a user's rank entry
*/
deleteRank: function (chan, name, callback) {
if (typeof callback !== "function") {
callback = blackHole;
}
if (!valid(chan)) {
callback("Invalid channel name", null);
return;
}
db.query("DELETE FROM `channel_ranks` WHERE name=? AND channel=?", [name, chan],
callback);
},
/**
* Removes all ranks for a channel
*/
deleteAllRanks: function (chan, callback) {
if (typeof callback !== "function") {
callback = blackHole;
}
if (!valid(chan)) {
callback("Invalid channel name", null);
return;
}
db.query("DELETE FROM `channel_ranks` WHERE channel=?", [chan], callback);
},
/**
* Adds a media item to the library
*/
addToLibrary: function (chan, media, callback) {
if (typeof callback !== "function") {
callback = blackHole;
}
if (!valid(chan)) {
callback("Invalid channel name", null);
return;
}
var meta = JSON.stringify({
bitrate: media.meta.bitrate,
codec: media.meta.codec,
scuri: media.meta.scuri,
embed: media.meta.embed,
direct: media.meta.direct
});
db.query("INSERT INTO `channel_libraries` " +
"(id, title, seconds, type, meta, channel) " +
"VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE id=id",
[media.id, media.title, media.seconds, media.type, meta, chan], callback);
},
/**
* Adds a list of media items to the library
*/
addListToLibrary: async function addListToLibrary(chan, list) {
if (!valid(chan)) {
throw new Error("Invalid channel name");
}
if (list.length > Config.get("playlist.max-items")) {
throw new Error("Cannot save list to library: exceeds max-items");
}
const items = list.map(item => ({
id: item.id,
title: item.title,
seconds: item.seconds,
type: item.type,
meta: JSON.stringify({
bitrate: item.meta.bitrate,
codec: item.meta.codec,
scuri: item.meta.scuri,
embed: item.meta.embed,
direct: item.meta.direct
}),
channel: chan
}));
await db.getDB().runTransaction(tx => {
const insert = tx.table('channel_libraries')
.insert(items);
const update = tx.raw(createMySQLDuplicateKeyUpdate(
['title', 'seconds', 'meta']
));
return tx.raw(insert.toString() + update.toString());
});
},
/**
* Retrieves a media item from the library by id
*/
getLibraryItem: function (chan, id, callback) {
if (typeof callback !== "function") {
return;
}
if (!valid(chan)) {
callback("Invalid channel name", null);
return;
}
db.query("SELECT * FROM `channel_libraries` WHERE id=? AND channel=?", [id, chan],
function (err, rows) {
if (err) {
callback(err, null);
return;
}
if (rows.length === 0) {
callback("Item not in library", null);
} else {
callback(null, rows[0]);
}
});
},
/**
* Search the library by title
*/
searchLibrary: function (chan, search, callback) {
if (typeof callback !== "function") {
return;
}
db.query("SELECT * FROM `channel_libraries` WHERE title LIKE ? AND channel=?",
["%" + search + "%", chan], callback);
},
/**
* Deletes a media item from the library
*/
deleteFromLibrary: function (chan, id, callback) {
if (typeof callback !== "function") {
callback = blackHole;
}
if (!valid(chan)) {
callback("Invalid channel name", null);
return;
}
db.query("DELETE FROM `channel_libraries` WHERE id=? AND channel=?",
[id, chan], callback);
},
/**
* Deletes all library entries for a channel
*/
deleteLibrary: function (chan, callback) {
if (typeof callback !== "function") {
callback = blackHole;
}
if (!valid(chan)) {
callback("Invalid channel name", null);
return;
}
db.query("DELETE FROM `channel_libraries` WHERE channel=?", [chan], callback);
},
/**
* Add a ban to the banlist
*/
ban: function (chan, ip, name, note, bannedby, callback) {
if (typeof callback !== "function") {
callback = blackHole;
}
if (!valid(chan)) {
callback("Invalid channel name", null);
return;
}
db.query("INSERT INTO `channel_bans` (ip, name, reason, bannedby, channel) " +
"VALUES (?, ?, ?, ?, ?)",
[ip, name, note, bannedby, chan], callback);
},
/**
* Check if an IP address or range is banned
*/
isIPBanned: function (chan, ip, callback) {
if (typeof callback !== "function") {
return;
}
if (!valid(chan)) {
callback("Invalid channel name", null);
return;
}
var range = util.getIPRange(ip);
var wrange = util.getWideIPRange(ip);
db.query("SELECT * FROM `channel_bans` WHERE ip IN (?, ?, ?) AND channel=?",
[ip, range, wrange, chan],
function (err, rows) {
callback(err, err ? false : rows.length > 0);
});
},
/**
* Check if a username is banned
*/
isNameBanned: function (chan, name, callback) {
if (typeof callback !== "function") {
return;
}
if (!valid(chan)) {
callback("Invalid channel name", null);
return;
}
db.query("SELECT * FROM `channel_bans` WHERE name=? AND channel=?", [name, chan],
function (err, rows) {
callback(err, err ? false : rows.length > 0);
});
},
/**
* Check if a user's name or IP is banned
*/
isBanned: function (chan, ip, name, callback) {
if (typeof callback !== "function") {
return;
}
if (!valid(chan)) {
callback("Invalid channel name", null);
return;
}
var range = util.getIPRange(ip);
var wrange = util.getWideIPRange(ip);
db.query("SELECT COUNT(1) AS count FROM `channel_bans` WHERE (ip IN (?, ?, ?) OR name=?) AND channel=?",
[ip, range, wrange, name, chan],
function (err, rows) {
callback(err, err ? false : rows.length > 0 && rows[0].count > 0);
});
},
/**
* Lists all bans
*/
listBans: function (chan, callback) {
if (typeof callback !== "function") {
return;
}
if (!valid(chan)) {
callback("Invalid channel name", null);
return;
}
db.query("SELECT * FROM `channel_bans` WHERE channel=?", [chan], callback);
},
/**
* Removes a ban from the banlist
*/
unbanId: function (chan, id, callback) {
if (typeof callback !== "function") {
callback = blackHole;
}
if (!valid(chan)) {
callback("Invalid channel name", null);
return;
}
db.query("DELETE FROM `channel_bans` WHERE id=? AND channel=?",
[id, chan], callback);
},
/**
* Removes all bans from a channel
*/
deleteBans: function (chan, id, callback) {
if (typeof callback !== "function") {
callback = blackHole;
}
if (!valid(chan)) {
callback("Invalid channel name", null);
return;
}
db.query("DELETE FROM `channel_bans` WHERE channel=?", [chan], callback);
},
/**
* Updates the `last_loaded` column to be the current timestamp
*/
updateLastLoaded: function updateLastLoaded(channelId) {
if (channelId <= 0) {
return;
}
db.query("UPDATE channels SET last_loaded = ? WHERE id = ?", [new Date(), channelId], error => {
if (error) {
LOGGER.error(`Failed to update last_loaded column for channel ID ${channelId}: ${error}`);
}
});
},
/**
* Updates the `owner_last_seen` column to be the current timestamp
*/
updateOwnerLastSeen: function updateOwnerLastSeen(channelId) {
if (channelId <= 0) {
return;
}
db.query("UPDATE channels SET owner_last_seen = ? WHERE id = ?", [new Date(), channelId], error => {
if (error) {
LOGGER.error(`Failed to update owner_last_seen column for channel ID ${channelId}: ${error}`);
}
});
}
};

View file

@ -0,0 +1,81 @@
import { Summary } from 'prom-client';
import { createMySQLDuplicateKeyUpdate } from '../util/on-duplicate-key-update';
const Media = require('@cytube/mediaquery/lib/media');
const LOGGER = require('@calzoneman/jsli')('metadata-cache');
// TODO: these fullname-vs-shortcode hacks really need to be abolished
function mediaquery2cytube(type) {
switch (type) {
case 'youtube':
return 'yt';
default:
throw new Error(`mediaquery2cytube: no mapping for ${type}`);
}
}
function cytube2mediaquery(type) {
switch (type) {
case 'yt':
return 'youtube';
default:
throw new Error(`cytube2mediaquery: no mapping for ${type}`);
}
}
const cachedResultAge = new Summary({
name: 'cytube_yt_cache_result_age_seconds',
help: 'Age (in seconds) of cached record'
});
class MetadataCacheDB {
constructor(db) {
this.db = db;
}
async put(media) {
media = new Media(media);
media.type = mediaquery2cytube(media.type);
return this.db.runTransaction(async tx => {
let insert = tx.table('media_metadata_cache')
.insert({
id: media.id,
type: media.type,
metadata: JSON.stringify(media),
updated_at: tx.raw('CURRENT_TIMESTAMP')
});
let update = tx.raw(createMySQLDuplicateKeyUpdate(
['metadata', 'updated_at']
));
return tx.raw(insert.toString() + update.toString());
});
}
async get(id, type) {
return this.db.runTransaction(async tx => {
let row = await tx.table('media_metadata_cache')
.where({ id, type })
.first();
if (row === undefined || row === null) {
return null;
}
try {
let age = (Date.now() - row.updated_at.getTime())/1000;
if (age > 0) {
cachedResultAge.observe(age);
}
} catch (error) {
LOGGER.error('Failed to record cachedResultAge metric: %s', error.stack);
}
let metadata = JSON.parse(row.metadata);
metadata.type = cytube2mediaquery(metadata.type);
return new Media(metadata);
});
}
}
export { MetadataCacheDB };

159
src/database/tables.js Normal file
View file

@ -0,0 +1,159 @@
const LOGGER = require('@calzoneman/jsli')('database/tables');
export async function initTables() {
const knex = require('../database').getDB().knex;
async function ensureTable(name, structure) {
if (!await knex.schema.hasTable(name)) {
LOGGER.info('Creating table %s', name);
await knex.schema.createTable(name, structure);
}
}
// TODO: consider un-utf8ing columns that are always ASCII
// Leaving for now for backwards compatibility
// TODO: enforce foreign key constraints for tables missing them
await ensureTable('users', t => {
t.charset('utf8');
t.increments('id').notNullable().primary();
t.string('name', 20).notNullable().unique();
t.string('password', 60).notNullable();
t.integer('global_rank').notNullable();
t.string('email', 255);
// UTF8MB4 required for non-BMP Unicode -- Just MySQL things (tm)
t.specificType('profile', 'text character set utf8mb4 not null');
t.string('ip', 39).notNullable();
// Registration time, TODO convert to timestamp
t.bigint('time').notNullable();
t.string('name_dedupe', 20).defaultTo(null);
t.boolean('inactive').defaultTo(false);
});
await ensureTable('channels', t => {
t.charset('utf8');
t.increments('id').notNullable().primary();
t.string('name', 30).notNullable().unique();
t.string('owner', 20).notNullable().index();
// Registration time, TODO convert to timestamp
t.bigInteger('time').notNullable();
t.timestamp('last_loaded').notNullable()
.defaultTo(knex.raw('0'));
t.timestamp('owner_last_seen').notNullable()
.defaultTo(knex.raw('0'));
});
await ensureTable('channel_data', t => {
t.charset('utf8');
t.integer('channel_id').notNullable()
.unsigned()
.references('id').inTable('channels')
.onDelete('cascade');
t.string('key', 20).notNullable();
t.specificType('value', 'mediumtext character set utf8mb4 not null');
t.primary(['channel_id', 'key']);
});
await ensureTable('global_bans', t => {
t.charset('utf8');
t.string('ip', 39).notNullable().primary();
t.string('reason', 255).notNullable();
});
await ensureTable('password_reset', t => {
t.charset('utf8');
t.string('ip', 39).notNullable();
t.string('name', 20).notNullable().primary();
t.string('hash', 64).notNullable();
t.string('email', 255).notNullable();
// TODO consider converting to timestamp
t.bigint('expire').notNullable();
});
await ensureTable('user_playlists', t => {
t.charset('utf8');
t.string('user', 20).notNullable();
t.string('name', 255).notNullable();
t.specificType('contents', 'mediumtext character set utf8mb4 not null');
t.integer('count').notNullable();
t.integer('duration').notNullable();
t.primary(['user', 'name']);
});
await ensureTable('aliases', t => {
t.charset('utf8');
t.increments('visit_id').notNullable().primary();
t.string('ip', 39).notNullable().index();
t.string('name', 20).notNullable();
// TODO consider converting to timestamp
t.bigint('time').notNullable();
});
await ensureTable('meta', t => {
t.charset('utf8');
t.string('key', 255).notNullable().primary();
t.text('value').notNullable();
});
await ensureTable('channel_libraries', t => {
t.charset('utf8');
t.string('id', 255).notNullable();
t.specificType('title', 'varchar(255) character set utf8mb4 not null');
t.integer('seconds').notNullable();
t.string('type', 2).notNullable();
t.text('meta').notNullable();
t.string('channel', 30).notNullable();
t.primary(['id', 'channel']);
// TODO replace title index with FTS or elasticsearch or something
t.index(['channel', knex.raw('`title`(227)')], 'channel_libraries_channel_title');
});
await ensureTable('channel_ranks', t => {
t.charset('utf8');
t.string('name', 20).notNullable();
t.integer('rank').notNullable();
t.string('channel', 30).notNullable();
t.primary(['name', 'channel']);
});
await ensureTable('channel_bans', t => {
t.charset('utf8');
t.increments('id').notNullable().primary();
t.string('ip', 39).notNullable();
t.string('name', 20).notNullable();
t.string('bannedby', 20).notNullable();
t.specificType('reason', 'varchar(255) character set utf8mb4 not null');
t.string('channel', 30).notNullable();
t.unique(['name', 'ip', 'channel']);
t.index(['ip', 'channel']);
t.index(['name', 'channel']);
});
await ensureTable('user_deletion_requests', t => {
t.increments('request_id').notNullable().primary();
t.integer('user_id')
.unsigned()
.notNullable()
.references('id').inTable('users')
.onDelete('cascade')
.unique();
t.timestamps(/* useTimestamps */ true, /* defaultToNow */ true);
t.index('created_at');
});
await ensureTable('media_metadata_cache', t => {
// The types of id and type are chosen for compatibility
// with the existing channel_libraries table.
// TODO in the future schema, revisit the ID layout for different media types.
t.charset('utf8');
t.string('id', 255).notNullable();
t.string('type', 2).notNullable();
t.text('metadata').notNullable();
t.timestamps(/* useTimestamps */ true, /* defaultToNow */ true);
t.primary(['type', 'id']);
t.index('updated_at');
});
}

143
src/database/update.js Normal file
View file

@ -0,0 +1,143 @@
var db = require("../database");
import Promise from 'bluebird';
const LOGGER = require('@calzoneman/jsli')('database/update');
const DB_VERSION = 12;
var hasUpdates = [];
module.exports.checkVersion = function () {
db.query("SELECT `key`,`value` FROM `meta` WHERE `key`=?", ["db_version"], function (err, rows) {
if (err) {
return;
}
if (rows.length === 0) {
LOGGER.warn("db_version key missing from database. Setting " +
"db_version=" + DB_VERSION);
db.query("INSERT INTO `meta` (`key`, `value`) VALUES ('db_version', ?)",
[DB_VERSION],
function (_err) {
});
} else {
var v = parseInt(rows[0].value);
if (v >= DB_VERSION) {
return;
}
var next = function () {
hasUpdates.push(v);
LOGGER.info("Updated database to version " + v);
if (v < DB_VERSION) {
update(v++, next);
} else {
db.query("UPDATE `meta` SET `value`=? WHERE `key`='db_version'",
[DB_VERSION]);
}
};
update(v++, next);
}
});
};
function update(version, cb) {
if (version < 7) {
LOGGER.error('Cannot auto-upgrade: db_version 4 is too old!');
process.exit(1);
} else if (version < 8) {
addUsernameDedupeColumn(cb);
} else if (version < 9) {
populateUsernameDedupeColumn(cb);
} else if (version < 10) {
addChannelLastLoadedColumn(cb);
} else if (version < 11) {
addChannelOwnerLastSeenColumn(cb);
} else if (version < 12) {
addUserInactiveColumn(cb);
}
}
function addUsernameDedupeColumn(cb) {
LOGGER.info("Adding name_dedupe column on the users table");
db.query("ALTER TABLE users ADD COLUMN name_dedupe VARCHAR(20) UNIQUE DEFAULT NULL", (error) => {
if (error) {
LOGGER.error(`Unable to add name_dedupe column: ${error}`);
} else {
cb();
}
});
}
function populateUsernameDedupeColumn(cb) {
const dbUsers = require("./accounts");
LOGGER.info("Populating name_dedupe column on the users table");
db.query("SELECT id, name FROM users WHERE name_dedupe IS NULL", (err, rows) => {
if (err) {
LOGGER.error("Unable to perform database upgrade to add dedupe column: " + err);
return;
}
Promise.map(rows, row => {
const dedupedName = dbUsers.dedupeUsername(row.name);
LOGGER.info(`Deduping [${row.name}] as [${dedupedName}]`);
return db.getDB().knex.raw("UPDATE users SET name_dedupe = ? WHERE id = ?", [dedupedName, row.id])
.catch(error => {
if (error.errno === 1062) {
LOGGER.info(`WARNING: could not set name_dedupe for [${row.name}] due to an existing row for [${dedupedName}]`);
} else {
throw error;
}
});
}, { concurrency: 10 }).then(() => {
cb();
}).catch(error => {
LOGGER.error("Unable to perform database upgrade to add dedupe column: " + (error.stack ? error.stack : error));
});
});
}
function addChannelLastLoadedColumn(cb) {
db.query("ALTER TABLE channels ADD COLUMN last_loaded TIMESTAMP NOT NULL DEFAULT 0", error => {
if (error) {
LOGGER.error(`Failed to add last_loaded column: ${error}`);
return;
}
db.query("ALTER TABLE channels ADD INDEX i_last_loaded (last_loaded)", error => {
if (error) {
LOGGER.error(`Failed to add index on last_loaded column: ${error}`);
return;
}
cb();
});
});
}
function addChannelOwnerLastSeenColumn(cb) {
db.query("ALTER TABLE channels ADD COLUMN owner_last_seen TIMESTAMP NOT NULL DEFAULT 0", error => {
if (error) {
LOGGER.error(`Failed to add owner_last_seen column: ${error}`);
return;
}
db.query("ALTER TABLE channels ADD INDEX i_owner_last_seen (owner_last_seen)", error => {
if (error) {
LOGGER.error(`Failed to add index on owner_last_seen column: ${error}`);
return;
}
cb();
});
});
}
function addUserInactiveColumn(cb) {
db.query("ALTER TABLE users ADD COLUMN inactive BOOLEAN DEFAULT FALSE", error => {
if (error) {
LOGGER.error(`Failed to add inactive column: ${error}`);
cb(error);
} else {
cb();
}
});
}

53
src/db/aliases.js Normal file
View file

@ -0,0 +1,53 @@
import net from 'net';
const LOGGER = require('@calzoneman/jsli')('AliasesDB');
class AliasesDB {
constructor(db) {
this.db = db;
}
async addAlias(ip, name) {
return this.db.runTransaction(async tx => {
try {
await tx.table('aliases')
.where({ ip, name })
.del();
await tx.table('aliases')
.insert({ ip, name, time: Date.now() });
} catch (error) {
LOGGER.error('Failed to save alias: %s (ip=%s, name=%s)',
error.message, ip, name);
}
});
}
async getAliasesByIP(ip) {
return this.db.runTransaction(async tx => {
const query = tx.table('aliases');
if (net.isIP(ip)) {
query.where({ ip: ip });
} else {
const delimiter = /^[0-9]+\./.test(ip) ? '.' : ':';
query.where('ip', 'LIKE', ip + delimiter + '%');
}
const rows = await query.select()
.distinct('name')
.orderBy('time', 'desc')
.limit(5);
return rows.map(row => row.name);
});
}
async getIPsByName(name) {
return this.db.runTransaction(async tx => {
const rows = await tx.table('aliases')
.select('ip')
.where({ name });
return rows.map(row => row.ip);
});
}
}
export { AliasesDB };

48
src/db/globalban.js Normal file
View file

@ -0,0 +1,48 @@
const LOGGER = require('@calzoneman/jsli')('GlobalBanDB');
class GlobalBanDB {
constructor(db) {
this.db = db;
}
listGlobalBans() {
return this.db.runTransaction(tx => {
return tx.table('global_bans').select();
}).catch(error => {
LOGGER.error('Failed to list global IP bans: %s', error.stack);
throw error;
});
}
addGlobalIPBan(ip, reason) {
return this.db.runTransaction(tx => {
return tx.table('global_bans')
.insert({ ip, reason })
.catch(error => {
if (error.code === 'ER_DUP_ENTRY') {
return tx.table('global_bans')
.where({ ip })
.update({ reason });
} else {
throw error;
}
});
}).catch(error => {
LOGGER.error('Failed to add global IP ban for IP %s: %s', ip, error.stack);
throw error;
});
}
removeGlobalIPBan(ip) {
return this.db.runTransaction(tx => {
return tx.table('global_bans')
.where({ ip })
.del();
}).catch(error => {
LOGGER.error('Failed to remove global IP ban for IP %s: %s', ip, error.stack);
throw error;
});
}
}
export { GlobalBanDB };

53
src/db/password-reset.js Normal file
View file

@ -0,0 +1,53 @@
import { createMySQLDuplicateKeyUpdate } from '../util/on-duplicate-key-update';
const ONE_DAY = 24 * 60 * 60 * 1000;
class PasswordResetDB {
constructor(db) {
this.db = db;
}
insert(params) {
// TODO: validate params?
return this.db.runTransaction(tx => {
const insert = tx.table('password_reset').insert(params);
// TODO: Support other DBMS besides MySQL
// Annoyingly, upsert/on duplicate key update are non-standard
// Alternatively, maybe this table shouldn't be an upsert table?
const update = tx.raw(createMySQLDuplicateKeyUpdate(
['ip', 'hash', 'email', 'expire']
));
return tx.raw(insert.toString() + update.toString());
});
}
get(hash) {
return this.db.runTransaction(tx => {
return tx.table('password_reset').where({ hash }).select()
.then(rows => {
if (rows.length === 0) {
throw new Error(`No password reset found for hash ${hash}`);
}
return rows[0];
});
});
}
delete(hash) {
return this.db.runTransaction(tx => {
return tx.table('password_reset').where({ hash }).del();
});
}
cleanup(threshold = ONE_DAY) {
return this.db.runTransaction(tx => {
return tx.table('password_reset')
.where('expire', '<', Date.now() - threshold)
.del();
});
}
}
export { PasswordResetDB };

11
src/errors.js Normal file
View file

@ -0,0 +1,11 @@
import createError from 'create-error';
import * as HTTPStatus from './web/httpstatus';
export const ChannelStateSizeError = createError('ChannelStateSizeError');
export const ChannelNotFoundError = createError('ChannelNotFoundError');
export const CSRFError = createError('CSRFError');
export const HTTPError = createError('HTTPError', {
status: HTTPStatus.INTERNAL_SERVER_ERROR
});
export const ValidationError = createError('ValidationError');
export const InvalidRequestError = createError('InvalidRequestError');

536
src/ffmpeg.js Normal file
View file

@ -0,0 +1,536 @@
var Logger = require("./logger");
var Config = require("./config");
var spawn = require("child_process").spawn;
var https = require("https");
var http = require("http");
var urlparse = require("url");
var path = require("path");
import { callOnce } from './util/call-once';
const CYTUBE_VERSION = require('../package.json').version;
const LOGGER = require('@calzoneman/jsli')('ffmpeg');
const ECODE_MESSAGES = {
ENOTFOUND: e => (
`Unknown host "${e.hostname}". ` +
'Please check that the link is correct.'
),
EPROTO: _e => 'The remote server does not support HTTPS.',
ECONNRESET: _e => 'The remote server unexpectedly closed the connection.',
ECONNREFUSED: _e => (
'The remote server refused the connection. ' +
'Please check that the link is correct and the server is running.'
),
ETIMEDOUT: _e => (
'The connection to the remote server timed out. ' +
'Please check that the link is correct.'
),
ENETUNREACH: _e => (
"The remote server's network is unreachable from this server. " +
"Please contact an administrator for assistance."
),
EHOSTUNREACH: _e => (
"The remote server is unreachable from this server. " +
"Please contact the video server's administrator for assistance."
),
ENOMEM: _e => (
"An out of memory error caused the request to fail. Please contact an " +
"administrator for assistance."
),
DEPTH_ZERO_SELF_SIGNED_CERT: _e => (
'The remote server provided an invalid ' +
'(self-signed) SSL certificate. Raw file support requires a ' +
'trusted certificate. See https://letsencrypt.org/ to get ' +
'a free, trusted certificate.'
),
SELF_SIGNED_CERT_IN_CHAIN: _e => (
'The remote server provided an invalid ' +
'(self-signed) SSL certificate. Raw file support requires a ' +
'trusted certificate. See https://letsencrypt.org/ to get ' +
'a free, trusted certificate.'
),
UNABLE_TO_VERIFY_LEAF_SIGNATURE: _e => (
"The remote server's SSL certificate chain could not be validated. " +
"Please contact the administrator of the server to correct their " +
"SSL certificate configuration."
),
CERT_HAS_EXPIRED: _e => (
"The remote server's SSL certificate has expired. Please contact " +
"the administrator of the server to renew the certificate."
),
ERR_TLS_CERT_ALTNAME_INVALID: _e => (
"The remote server's SSL connection is misconfigured and has served " +
"a certificate invalid for the given link."
),
// node's http parser barfs when careless servers ignore RFC 2616 and send a
// response body in reply to a HEAD request
HPE_INVALID_CONSTANT: _e => (
"The remote server for this link is misconfigured."
)
};
var USE_JSON = true;
var TIMEOUT = 30000;
var acceptedCodecs = {
"mov/h264": true,
"flv/h264": true,
"matroska/vp8": true,
"matroska/vp9": true,
"ogg/theora": true,
"mov/av1": true,
"matroska/av1": true
};
var acceptedAudioCodecs = {
"mp3": true,
"vorbis": true,
"aac": true,
"opus": true
};
var audioOnlyContainers = {
"mp3": true
};
function fflog() { }
/* eslint no-func-assign: off */
function initFFLog() {
if (fflog.initialized) return;
var logger = new Logger.Logger(path.resolve(__dirname, "..", "ffmpeg.log"));
fflog = function () {
logger.log.apply(logger, arguments);
};
fflog.initialized = true;
}
function fixRedirectIfNeeded(urldata, redirect) {
let parsedRedirect = urlparse.parse(redirect);
if (parsedRedirect.host === null) {
// Relative path, munge it to absolute
redirect = urldata.protocol + "//" + urldata.host + redirect;
}
return redirect;
}
function translateStatusCode(statusCode) {
switch (statusCode) {
case 400:
return "The request for the audio/video link was rejected as invalid. " +
"Contact support for troubleshooting assistance.";
case 401:
case 403:
return "Access to the link was denied. Contact the owner of the " +
"website hosting the audio/video file to grant permission for " +
"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 " +
"and was unable to process the request. Try again in a few minutes, " +
"and if the issue persists, contact the owner of the website hosting " +
"the link.";
default:
return "An unknown issue occurred when requesting the audio/video link. " +
"Contact support for troubleshooting assistance.";
}
}
function getCookie(res) {
if (!res.headers['set-cookie']) {
return '';
}
return res.headers['set-cookie'].map(c => c.split(';')[0]).join(';') + ';';
}
function testUrl(url, cb, params = { redirCount: 0, cookie: '' }) {
const { redirCount, cookie } = params;
var data = urlparse.parse(url);
if (!/https:/.test(data.protocol)) {
if (redirCount > 0) {
// If the original URL redirected, the user is probably not aware
// that the link they entered (which was HTTPS) is redirecting to a
// non-HTTPS endpoint
return cb(`Unexpected redirect to a non-HTTPS link: ${url}`);
}
return cb("Only links starting with 'https://' are supported " +
"for raw audio/video support");
}
if (!data.hostname) {
return cb("The link to the file is missing the website address and can't " +
"be processed.");
}
var transport = (data.protocol === "https:") ? https : http;
data.method = "HEAD";
data.headers = {
'User-Agent': `CyTube/${CYTUBE_VERSION}`
};
if (cookie) {
data.headers['Cookie'] = cookie;
}
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);
}
if (res.statusCode !== 200) {
return cb(translateStatusCode(res.statusCode));
}
if (!/^audio|^video/.test(res.headers["content-type"])) {
cb("Could not detect a supported audio/video type. See " +
"https://git.io/fjtOK for a list of supported providers. " +
"(Content-Type was: '" + res.headers["content-type"] + "')");
return;
}
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;
}
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. " +
"If this link is hosted on a server you own, it is likely " +
"misconfigured and you can join community support for assistance. " +
"If you are attempting to add links from third party websites, the " +
"developers do not provide support for this." +
(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.");
}
}
function readOldFormat(buf) {
var lines = buf.split("\n");
var tmp = { tags: {} };
var data = {
streams: []
};
lines.forEach(function (line) {
if (line.match(/\[stream\]|\[format\]/i)) {
return;
} else if (line.match(/\[\/stream\]/i)) {
data.streams.push(tmp);
tmp = { tags: {} };
} else if (line.match(/\[\/format\]/i)) {
data.format = tmp;
tmp = { tags: {} };
} else {
var kv = line.split("=");
var key = kv[0].toLowerCase();
if (key.indexOf("tag:") === 0) {
tmp.tags[key.split(":")[1]] = kv[1];
} else {
tmp[key] = kv[1];
}
}
});
return data;
}
function isAlternateDisposition(stream) {
if (!stream.disposition) {
return false;
}
for (var key in stream) {
if (key !== "default" && stream.disposition[key]) {
return true;
}
}
return false;
}
function reformatData(data) {
var reformatted = {};
var duration = parseInt(data.format.duration, 10);
if (isNaN(duration)) duration = "--:--";
reformatted.duration = Math.ceil(duration);
var bitrate = parseInt(data.format.bit_rate, 10) / 1000;
if (isNaN(bitrate)) bitrate = 0;
reformatted.bitrate = bitrate;
reformatted.title = data.format.tags ? data.format.tags.title : null;
var container = data.format.format_name.split(",")[0];
var isVideo = false;
var audio = null;
for (var i = 0; i < data.streams.length; i++) {
const stream = data.streams[i];
// Trash streams with alternate dispositions, e.g. `attached_pic` for
// embedded album art on MP3s (not a real video stream)
if (isAlternateDisposition(stream)) {
continue;
}
if (stream.codec_type === "video" &&
!audioOnlyContainers.hasOwnProperty(container)) {
isVideo = true;
if (acceptedCodecs.hasOwnProperty(container + "/" + stream.codec_name)) {
reformatted.vcodec = stream.codec_name;
reformatted.medium = "video";
reformatted.type = [container, reformatted.vcodec].join("/");
if (stream.tags && stream.tags.title) {
reformatted.title = stream.tags.title;
}
return reformatted;
}
} else if (stream.codec_type === "audio" && !audio &&
acceptedAudioCodecs.hasOwnProperty(stream.codec_name)) {
audio = {
acodec: stream.codec_name,
medium: "audio"
};
if (stream.tags && stream.tags.title) {
audio.title = stream.tags.title;
}
}
}
// Override to make sure video files with no valid video streams but some
// acceptable audio stream are rejected.
if (isVideo) {
return reformatted;
}
if (audio) {
for (var key in audio) {
reformatted[key] = audio[key];
}
}
return reformatted;
}
exports.ffprobe = function ffprobe(filename, cb) {
fflog("Spawning ffprobe for " + filename);
var childErr;
var args = ["-show_streams", "-show_format", filename];
if (USE_JSON) args = ["-of", "json"].concat(args);
let child;
try {
child = spawn(Config.get("ffmpeg.ffprobe-exec"), args);
} catch (error) {
LOGGER.error("Unable to spawn() ffprobe process: %s", error.stack);
cb(error);
return;
}
var stdout = "";
var stderr = "";
var timer = setTimeout(function () {
LOGGER.warn("Timed out when probing " + filename);
fflog("Killing ffprobe for " + filename + " after " + (TIMEOUT/1000) + " seconds");
childErr = new Error(
"File query exceeded time limit of " + (TIMEOUT/1000) +
" seconds. This can be caused if the remote server is far " +
"away or if you did not encode the video " +
"using the 'faststart' option: " +
"https://trac.ffmpeg.org/wiki/Encode/H.264#faststartforwebvideo"
);
child.kill("SIGKILL");
}, TIMEOUT);
child.on("error", function (err) {
childErr = err;
});
child.stdout.on("data", function (data) {
stdout += data;
});
child.stderr.on("data", function (data) {
stderr += data;
if (stderr.match(/the tls connection was non-properly terminated/i)) {
fflog("Killing ffprobe for " + filename + " due to TLS error");
childErr = new Error("The connection was closed unexpectedly. " +
"If the problem continues, contact support " +
"for troubleshooting assistance.");
child.kill("SIGKILL");
}
});
child.on("close", function (code) {
clearTimeout(timer);
fflog("ffprobe exited with code " + code + " for file " + filename);
if (code !== 0) {
if (stderr.match(/unrecognized option|json/i) && USE_JSON) {
LOGGER.warn("ffprobe does not support -of json. " +
"Assuming it will have old output format.");
USE_JSON = false;
return ffprobe(filename, cb);
}
if (!childErr) childErr = new Error(stderr);
return cb(childErr);
}
var result;
if (USE_JSON) {
try {
result = JSON.parse(stdout);
} catch (e) {
return cb(new Error("Unable to parse ffprobe output: " + e.message));
}
} else {
try {
result = readOldFormat(stdout);
} catch (e) {
return cb(new Error("Unable to parse ffprobe output: " + e.message));
}
}
return cb(null, result);
});
};
exports.query = function (filename, cb) {
if (Config.get("ffmpeg.log") && !fflog.initialized) {
initFFLog();
}
if (!Config.get("ffmpeg.enabled")) {
return cb("Raw file playback is not enabled on this server");
}
if (!filename.match(/^https:\/\//)) {
return cb("Raw file playback is only supported for links accessible via HTTPS. " +
"Ensure that the link begins with 'https://'.");
}
testUrl(filename, callOnce(function (err) {
if (err) {
return cb(err);
}
exports.ffprobe(filename, function (err, data) {
if (err) {
if (err.code && err.code === "ENOENT") {
return cb("Failed to execute `ffprobe`. Set ffmpeg.ffprobe-exec " +
"to the correct name of the executable in config.yaml. " +
"If you are using Debian or Ubuntu, it is probably " +
"avprobe.");
} else if (err.message) {
if (err.message.match(/protocol not found/i))
return cb("Link uses a protocol unsupported by this server's " +
"version of ffmpeg. Some older versions of " +
"ffprobe/avprobe do not support HTTPS.");
if (err.message.match(/exceeded time limit/) ||
err.message.match(/closed unexpectedly/i)) {
return cb(err.message);
}
// Ignore ffprobe error messages, they are common and most often
// indicate a problem with the remote file, not with this code.
if (!/(av|ff)probe/.test(String(err)))
LOGGER.error(err.stack || err);
return cb("An unexpected error occurred while trying to process " +
"the link. Contact support for troubleshooting " +
"assistance.");
} else {
if (!/(av|ff)probe/.test(String(err)))
LOGGER.error(err.stack || err);
return cb("An unexpected error occurred while trying to process " +
"the link. Contact support for troubleshooting " +
"assistance.");
}
}
try {
data = reformatData(data);
} catch (e) {
LOGGER.error(e.stack || e);
return cb("An unexpected error occurred while trying to process " +
"the link. Contact support for troubleshooting " +
"assistance.");
}
if (data.medium === "video") {
data = {
title: data.title || "Raw Video",
duration: data.duration,
bitrate: data.bitrate,
codec: data.type
};
cb(null, data);
} else if (data.medium === "audio") {
data = {
title: data.title || "Raw Audio",
duration: data.duration,
bitrate: data.bitrate,
codec: data.acodec
};
cb(null, data);
} else {
return cb("File did not contain an acceptable codec. See " +
"https://git.io/vrE75 for details.");
}
});
}));
};

15
src/flags.js Normal file
View file

@ -0,0 +1,15 @@
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,
U_HAS_CHANNEL_RANK: 1 << 8
};

462
src/get-info.js Normal file
View file

@ -0,0 +1,462 @@
const https = require("https");
const Media = require("./media");
const CustomEmbedFilter = require("./customembed").filter;
const Config = require("./config");
const ffmpeg = require("./ffmpeg");
const mediaquery = require("@cytube/mediaquery");
const YouTube = require("@cytube/mediaquery/lib/provider/youtube");
const Vimeo = require("@cytube/mediaquery/lib/provider/vimeo");
const Streamable = require("@cytube/mediaquery/lib/provider/streamable");
const TwitchVOD = require("@cytube/mediaquery/lib/provider/twitch-vod");
const TwitchClip = require("@cytube/mediaquery/lib/provider/twitch-clip");
import { Counter } from 'prom-client';
import { lookup as lookupCustomMetadata } from './custom-media';
const LOGGER = require('@calzoneman/jsli')('get-info');
const lookupCounter = new Counter({
name: 'cytube_media_lookups_total',
help: 'Count of media lookups',
labelNames: ['shortCode']
});
var urlRetrieve = function (transport, options, callback) {
var req = transport.request(options, function (res) {
res.on("error", function (err) {
LOGGER.error("HTTP response " + options.host + options.path + " failed: "+
err);
callback(503, "");
});
var buffer = "";
res.setEncoding("utf-8");
res.on("data", function (chunk) {
buffer += chunk;
});
res.on("end", function () {
callback(res.statusCode, buffer);
});
});
req.on("error", function (err) {
LOGGER.error("HTTP request " + options.host + options.path + " failed: " +
err);
callback(503, "");
});
req.end();
};
var mediaTypeMap = {
"youtube": "yt",
"googledrive": "gd",
"google+": "gp"
};
function convertMedia(media) {
return new Media(media.id, media.title, media.duration, mediaTypeMap[media.type],
media.meta);
}
var Getters = {
/* youtube.com */
yt: function (id, callback) {
if (!Config.get("youtube-v3-key")) {
return callback("The YouTube API now requires an API key. Please see the " +
"documentation for youtube-v3-key in config.template.yaml");
}
YouTube.lookup(id).then(function (video) {
var meta = {};
if (video.meta.blocked) {
meta.restricted = video.meta.blocked;
}
if (video.meta.ytRating) {
meta.ytRating = video.meta.ytRating;
}
var media = new Media(video.id, video.title, video.duration, "yt", meta);
callback(false, media);
}).catch(function (err) {
callback(err.message || err, null);
});
},
/* youtube.com playlists */
yp: function (id, callback) {
if (!Config.get("youtube-v3-key")) {
return callback("The YouTube API now requires an API key. Please see the " +
"documentation for youtube-v3-key in config.template.yaml");
}
YouTube.lookupPlaylist(id).then(function (videos) {
videos = videos.map(function (video) {
var meta = {};
if (video.meta.blocked) {
meta.restricted = video.meta.blocked;
}
return new Media(video.id, video.title, video.duration, "yt", meta);
});
callback(null, videos);
}).catch(function (err) {
callback(err.message || err, null);
});
},
/* youtube.com search */
ytSearch: function (query, callback) {
if (!Config.get("youtube-v3-key")) {
return callback("The YouTube API now requires an API key. Please see the " +
"documentation for youtube-v3-key in config.template.yaml");
}
YouTube.search(query).then(function (res) {
var videos = res.results;
videos = videos.map(function (video) {
var meta = {};
if (video.meta.blocked) {
meta.restricted = video.meta.blocked;
}
var media = new Media(video.id, video.title, video.duration, "yt", meta);
media.thumb = { url: video.meta.thumbnail };
return media;
});
callback(null, videos);
}).catch(function (err) {
callback(err.message || err, null);
});
},
/* vimeo.com */
vi: function (id, callback) {
var m = id.match(/([\w-]+)/);
if (m) {
id = m[1];
} else {
callback("Invalid ID", null);
return;
}
Vimeo.lookup(id).then(video => {
video = new Media(video.id, video.title, video.duration, "vi");
callback(null, video);
}).catch(error => {
callback(error.message);
});
},
/* dailymotion.com */
dm: function (id, callback) {
var m = id.match(/([\w-]+)/);
if (m) {
id = m[1].split("_")[0];
} else {
callback("Invalid ID", null);
return;
}
var options = {
host: "api.dailymotion.com",
port: 443,
path: "/video/" + id + "?fields=duration,title",
method: "GET",
dataType: "jsonp",
timeout: 1000
};
urlRetrieve(https, options, function (status, data) {
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;
/**
* 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;
}
var media = new Media(id, title, seconds, "dm");
callback(false, media);
} catch(e) {
callback(e, null);
}
});
},
/* soundcloud.com - see https://github.com/calzoneman/sync/issues/916 */
sc: function (id, callback) {
callback(
"Soundcloud is not supported anymore due to requiring OAuth but not " +
"accepting new API key registrations."
);
},
/* livestream.com */
li: function (id, callback) {
var m = id.match(/([\w-]+)/);
if (m) {
id = m[1];
} else {
callback("Invalid ID", null);
return;
}
var title = "Livestream.com - " + id;
var media = new Media(id, title, "--:--", "li");
callback(false, media);
},
/* twitch.tv */
tw: function (id, callback) {
var m = id.match(/([\w-]+)/);
if (m) {
id = m[1];
} else {
callback("Invalid ID", null);
return;
}
var title = "Twitch.tv - " + id;
var media = new Media(id, title, "--:--", "tw");
callback(false, media);
},
/* twitch VOD */
tv: function (id, callback) {
var m = id.match(/([cv]\d+)/);
if (m) {
id = m[1];
} else {
process.nextTick(callback, "Invalid Twitch VOD ID");
return;
}
TwitchVOD.lookup(id).then(video => {
const media = new Media(video.id, video.title, video.duration,
"tv", video.meta);
process.nextTick(callback, false, media);
}).catch(function (err) {
callback(err.message || err, null);
});
},
/* twitch clip */
tc: function (id, callback) {
var m = id.match(/^([A-Za-z]+)$/);
if (m) {
id = m[1];
} else {
process.nextTick(callback, "Invalid Twitch VOD ID");
return;
}
TwitchClip.lookup(id).then(video => {
const media = new Media(video.id, video.title, video.duration,
"tc", video.meta);
process.nextTick(callback, false, media);
}).catch(function (err) {
callback(err.message || err, null);
});
},
/* ustream.tv */
us: function (id, callback) {
var m = id.match(/(channel\/[^?&#]+)/);
if (m) {
id = m[1];
} else {
callback("Invalid ID", null);
return;
}
var options = {
host: "www.ustream.tv",
port: 443,
path: "/" + id,
method: "GET",
timeout: 1000
};
urlRetrieve(https, options, function (status, data) {
if(status !== 200) {
callback("Ustream HTTP " + status, null);
return;
}
/*
* Yes, regexing this information out of the HTML sucks.
* No, there is not a better solution -- it seems IBM
* deprecated the old API (or at least replaced with an
* enterprise API marked "Contact sales") so fuck it.
*/
var m = data.match(/https:\/\/www\.ustream\.tv\/embed\/(\d+)/);
if (m) {
var title = "Ustream.tv - " + id;
var media = new Media(m[1], title, "--:--", "us");
callback(false, media);
} else {
callback("Channel ID not found", null);
}
});
},
/* rtmp stream */
rt: function (id, callback) {
var title = "Livestream";
var media = new Media(id, title, "--:--", "rt");
callback(false, media);
},
/* HLS stream */
hl: function (id, callback) {
if (!/^https/.test(id)) {
callback(
"HLS links must start with HTTPS due to browser security " +
"policy. See https://git.io/vpDLK for details."
);
return;
}
var title = "Livestream";
var media = new Media(id, title, "--:--", "hl");
callback(false, media);
},
/* custom embed */
cu: function (id, callback) {
var media;
try {
media = CustomEmbedFilter(id);
} catch (e) {
if (/invalid embed/i.test(e.message)) {
return callback(e.message);
} else {
LOGGER.error(e.stack);
return callback("Unknown error processing embed");
}
}
callback(false, media);
},
/* google docs */
gd: function (id, callback) {
if (!/^[a-zA-Z0-9_-]+$/.test(id)) {
callback("Invalid ID: " + id);
return;
}
var data = {
type: "googledrive",
kind: "single",
id: id
};
mediaquery.lookup(data).then(function (video) {
callback(null, convertMedia(video));
}).catch(function (err) {
callback(err.message || err);
});
},
/* ffmpeg for raw files */
fi: function (id, cb) {
ffmpeg.query(id, function (err, data) {
if (err) {
return cb(err);
}
var m = new Media(id, data.title, data.duration, "fi", {
bitrate: data.bitrate,
codec: data.codec
});
cb(null, m);
});
},
/* hitbox.tv / smashcast.tv */
hb: function (id, callback) {
var m = id.match(/([\w-]+)/);
if (m) {
id = m[1];
} else {
callback("Invalid ID", null);
return;
}
var title = "Smashcast - " + id;
var media = new Media(id, title, "--:--", "hb");
callback(false, media);
},
/* vid.me */
vm: function (id, callback) {
process.nextTick(
callback,
"As of December 2017, vid.me is no longer in service."
);
},
/* streamable */
sb: function (id, callback) {
if (!/^[\w-]+$/.test(id)) {
process.nextTick(callback, "Invalid streamable.com ID");
return;
}
Streamable.lookup(id).then(video => {
const media = new Media(video.id, video.title, video.duration,
"sb", video.meta);
process.nextTick(callback, false, media);
}).catch(function (err) {
callback(err.message || err, null);
});
},
/* custom media - https://github.com/calzoneman/sync/issues/655 */
cm: async function (id, callback) {
try {
const media = await lookupCustomMetadata(id);
process.nextTick(callback, false, media);
} catch (error) {
process.nextTick(callback, error.message);
}
},
/* mixer.com */
mx: function (id, callback) {
process.nextTick(
callback,
"As of July 2020, Mixer is no longer in service."
);
}
};
module.exports = {
Getters: Getters,
getMedia: function (id, type, callback) {
if(type in this.Getters) {
LOGGER.info("Looking up %s:%s", type, id);
lookupCounter.labels(type).inc(1, new Date());
this.Getters[type](id, callback);
} else {
callback("Unknown media type '" + type + "'", null);
}
}
};

203
src/google2vtt.js Normal file
View file

@ -0,0 +1,203 @@
var cheerio = require('cheerio');
var https = require('https');
var fs = require('fs');
var path = require('path');
var querystring = require('querystring');
var crypto = require('crypto');
const LOGGER = require('@calzoneman/jsli')('google2vtt');
function md5(input) {
var hash = crypto.createHash('md5');
hash.update(input);
return hash.digest('base64').replace(/\//g, ' ')
.replace(/\+/g, '#')
.replace(/=/g, '-');
}
var slice = Array.prototype.slice;
var subtitleDir = path.resolve(__dirname, '..', 'google-drive-subtitles');
var subtitleLock = {};
var ONE_HOUR = 60 * 60 * 1000;
var ONE_DAY = 24 * ONE_HOUR;
function padZeros(n) {
n = n.toString();
if (n.length < 2) n = '0' + n;
return n;
}
function formatTime(time) {
var hours = Math.floor(time / 3600);
time = time % 3600;
var minutes = Math.floor(time / 60);
time = time % 60;
var seconds = Math.floor(time);
var ms = time - seconds;
var list = [minutes, seconds];
if (hours) {
list.unshift(hours);
}
return list.map(padZeros).join(':') + ms.toFixed(3).substring(1);
}
function fixText(text) {
return text.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/-->/g, '--&gt;');
}
exports.convert = function convertSubtitles(subtitles) {
var $ = cheerio.load(subtitles, { xmlMode: true });
var lines = slice.call($('transcript text').map(function (index, elem) {
var start = parseFloat(elem.attribs.start);
var end = start + parseFloat(elem.attribs.dur);
var text;
if (elem.children.length) {
text = elem.children[0].data;
} else {
text = '';
}
var line = formatTime(start) + ' --> ' + formatTime(end);
line += '\n' + fixText(text) + '\n';
return line;
}));
return 'WEBVTT\n\n' + lines.join('\n');
};
exports.attach = function setupRoutes(app) {
app.get('/gdvtt/:id/:lang/(:name)?.vtt', handleGetSubtitles);
};
function handleGetSubtitles(req, res) {
var id = req.params.id;
var lang = req.params.lang;
var name = req.params.name || '';
var vid = req.query.vid;
if (typeof vid !== 'string' || typeof id !== 'string' || typeof lang !== 'string') {
return res.sendStatus(400);
}
var file = [id, lang, md5(name)].join('_') + '.vtt';
var fileAbsolute = path.join(subtitleDir, file);
takeSubtitleLock(fileAbsolute, function () {
fs.exists(fileAbsolute, function (exists) {
if (exists) {
res.sendFile(file, { root: subtitleDir });
delete subtitleLock[fileAbsolute];
} else {
fetchSubtitles(id, lang, name, vid, fileAbsolute, function (err) {
delete subtitleLock[fileAbsolute];
if (err) {
LOGGER.error(err.stack);
return res.sendStatus(500);
}
res.sendFile(file, { root: subtitleDir });
});
}
});
});
}
function fetchSubtitles(id, lang, name, vid, file, cb) {
var query = {
id: id,
v: id,
vid: vid,
lang: lang,
name: name,
type: 'track',
kind: undefined
};
var url = 'https://drive.google.com/timedtext?' + querystring.stringify(query);
https.get(url, function (res) {
if (res.statusCode !== 200) {
return cb(new Error(res.statusMessage));
}
var buf = '';
res.setEncoding('utf-8');
res.on('data', function (data) {
buf += data;
});
res.on('end', function () {
try {
buf = exports.convert(buf);
} catch (e) {
return cb(e);
}
fs.writeFile(file, buf, function (err) {
if (err) {
cb(err);
} else {
LOGGER.info('Saved subtitle file ' + file);
cb();
}
});
});
}).on('error', function (err) {
cb(err);
});
}
function clearOldSubtitles() {
fs.readdir(subtitleDir, function (err, files) {
if (err) {
LOGGER.error(err.stack);
return;
}
files.forEach(function (file) {
fs.stat(path.join(subtitleDir, file), function (err, stats) {
if (err) {
LOGGER.error(err.stack);
return;
}
if (stats.mtime.getTime() < Date.now() - ONE_DAY) {
LOGGER.info('Deleting old subtitle file: ' + file);
fs.unlink(path.join(subtitleDir, file), error => {
if (error) {
LOGGER.error(
'Failed to remove file %s: %s',
file,
error.stack
);
}
});
}
});
});
});
}
function takeSubtitleLock(filename, cb) {
if (!subtitleLock.hasOwnProperty(filename)) {
subtitleLock[filename] = true;
return setImmediate(cb);
}
var tries = 1;
var interval = setInterval(function () {
tries++;
if (!subtitleLock.hasOwnProperty(filename) || tries >= 5) {
subtitleLock[filename] = true;
clearInterval(interval);
return setImmediate(cb);
}
}, 200);
}
setInterval(clearOldSubtitles, ONE_HOUR);
clearOldSubtitles();

View file

@ -0,0 +1,14 @@
import Promise from 'bluebird';
export default class NullClusterClient {
constructor(ioConfig) {
this.ioConfig = ioConfig;
}
getSocketConfig(_channel) {
const servers = this.ioConfig.getSocketEndpoints();
return Promise.resolve({
servers: servers
});
}
}

View file

@ -0,0 +1,14 @@
import Promise from 'bluebird';
class PartitionClusterClient {
constructor(partitionDecider) {
this.partitionDecider = partitionDecider;
}
getSocketConfig(channel) {
return Promise.resolve(
this.partitionDecider.getPartitionForChannel(channel));
}
}
export { PartitionClusterClient };

35
src/io/globalban.js Normal file
View file

@ -0,0 +1,35 @@
import { getIPRange, getWideIPRange } from '../utilities';
const LOGGER = require('@calzoneman/jsli')('CachingGlobalBanlist');
class CachingGlobalBanlist {
constructor(globalBanDB) {
this.globalBanDB = globalBanDB;
this.cache = new Set();
this.cacheTimer = null;
}
refreshCache() {
return this.globalBanDB.listGlobalBans().then(bans => {
this.cache.clear();
bans.forEach(ban => {
this.cache.add(ban.ip);
});
}).catch(error => {
LOGGER.error('Unable to refresh global banlist cache: %s', error.stack);
});
}
startCacheTimer(interval) {
clearInterval(this.cacheTimer);
this.cacheTimer = setInterval(this.refreshCache.bind(this), interval);
}
isIPGlobalBanned(ip) {
return this.cache.has(ip)
|| this.cache.has(getIPRange(ip))
|| this.cache.has(getWideIPRange(ip));
}
}
export { CachingGlobalBanlist };

537
src/io/ioserver.js Normal file
View file

@ -0,0 +1,537 @@
import sio from 'socket.io';
import db from '../database';
import User from '../user';
import Server from '../server';
import Config from '../config';
const cookieParser = require("cookie-parser")(Config.get("http.cookie-secret"));
import typecheck from 'json-typecheck';
import { isTorExit } from '../tor';
import session from '../session';
import { verifyIPSessionCookie } from '../web/middleware/ipsessioncookie';
import Promise from 'bluebird';
const verifySession = Promise.promisify(session.verifySession);
const getAliases = Promise.promisify(db.getAliases);
import { CachingGlobalBanlist } from './globalban';
import proxyaddr from 'proxy-addr';
import { Counter, Gauge } from 'prom-client';
import { TokenBucket } from '../util/token-bucket';
import http from 'http';
const LOGGER = require('@calzoneman/jsli')('ioserver');
const rateLimitExceeded = new Counter({
name: 'cytube_socketio_rate_limited_total',
help: 'Number of socket.io connections rejected due to exceeding rate limit'
});
const connLimitExceeded = new Counter({
name: 'cytube_socketio_conn_limited_total',
help: 'Number of socket.io connections rejected due to exceeding conn limit'
});
const authFailureCount = new Counter({
name: 'cytube_socketio_auth_error_total',
help: 'Number of failed authentications from session middleware'
});
class IOServer {
constructor(options = {
proxyTrustFn: proxyaddr.compile('127.0.0.1')
}) {
({
proxyTrustFn: this.proxyTrustFn
} = options);
this.ipThrottle = new Map();
this.ipCount = new Map();
}
// Map proxied sockets to the real IP address via X-Forwarded-For
// If the resulting address is a known Tor exit, flag it as such
ipProxyMiddleware(socket, next) {
if (!socket.context) socket.context = {};
try {
socket.handshake.connection = {
remoteAddress: socket.handshake.address
};
socket.context.ipAddress = proxyaddr(
socket.handshake,
this.proxyTrustFn
);
if (!socket.context.ipAddress) {
throw new Error(
`Assertion failed: unexpected IP ${socket.context.ipAddress}`
);
}
} catch (error) {
LOGGER.warn('Rejecting socket - proxyaddr failed: %s', error);
next(new Error('Could not determine IP address'));
return;
}
if (isTorExit(socket.context.ipAddress)) {
socket.context.torConnection = true;
}
next();
}
// Reject global banned IP addresses
ipBanMiddleware(socket, next) {
if (isIPGlobalBanned(socket.context.ipAddress)) {
LOGGER.info('Rejecting %s - banned',
socket.context.ipAddress);
next(new Error('You are banned from the server'));
return;
}
next();
}
// Rate limit connection attempts by IP address
ipThrottleMiddleware(socket, next) {
if (!this.ipThrottle.has(socket.context.ipAddress)) {
this.ipThrottle.set(socket.context.ipAddress, new TokenBucket(5, 0.1));
}
const bucket = this.ipThrottle.get(socket.context.ipAddress);
if (bucket.throttle()) {
rateLimitExceeded.inc(1);
LOGGER.info('Rejecting %s - exceeded connection rate limit',
socket.context.ipAddress);
next(new Error('Rate limit exceeded'));
return;
}
next();
}
checkIPLimit(socket) {
const ip = socket.context.ipAddress;
const count = this.ipCount.get(ip) || 0;
if (count >= Config.get('io.ip-connection-limit')) {
connLimitExceeded.inc(1);
LOGGER.info(
'Rejecting %s - exceeded connection count limit',
ip
);
socket.emit('kick', {
reason: 'Too many connections from your IP address'
});
socket.disconnect(true);
return false;
}
this.ipCount.set(ip, count + 1);
socket.once('disconnect', () => {
const newCount = (this.ipCount.get(ip) || 1) - 1;
if (newCount === 0) {
this.ipCount.delete(ip);
} else {
this.ipCount.set(ip, newCount);
}
});
return true;
}
// Parse cookies
cookieParsingMiddleware(socket, next) {
const req = socket.handshake;
if (req.headers.cookie) {
cookieParser(req, null, () => next());
} else {
req.cookies = {};
req.signedCookies = {};
next();
}
}
// Determine session age from ip-session cookie
// (Used for restricting chat)
ipSessionCookieMiddleware(socket, next) {
const cookie = socket.handshake.signedCookies['ip-session'];
if (!cookie) {
socket.context.ipSessionFirstSeen = new Date();
next();
return;
}
const sessionMatch = verifyIPSessionCookie(socket.context.ipAddress, cookie);
if (sessionMatch) {
socket.context.ipSessionFirstSeen = sessionMatch.date;
} else {
socket.context.ipSessionFirstSeen = new Date();
}
next();
}
// Match login cookie against the DB, look up aliases
authUserMiddleware(socket, next) {
socket.context.aliases = [];
const promises = [];
const auth = socket.handshake.signedCookies.auth;
if (auth) {
promises.push(verifySession(auth).then(user => {
socket.context.user = Object.assign({}, user);
}).catch(_error => {
authFailureCount.inc(1);
LOGGER.warn('Unable to verify session for %s - ignoring auth',
socket.context.ipAddress);
}));
}
promises.push(getAliases(socket.context.ipAddress).then(aliases => {
socket.context.aliases = aliases;
}).catch(_error => {
LOGGER.warn('Unable to load aliases for %s',
socket.context.ipAddress);
}));
Promise.all(promises).then(() => next());
}
handleConnection(socket) {
if (!this.checkIPLimit(socket)) {
//return;
}
patchTypecheckedFunctions(socket);
patchSocketMetrics(socket);
this.setRateLimiter(socket);
emitMetrics(socket);
LOGGER.info('Accepted socket from %s', socket.context.ipAddress);
socket.once('disconnect', (reason, reasonDetail) => {
LOGGER.info(
'%s disconnected (%s%s)',
socket.context.ipAddress,
reason,
reasonDetail ? ` - ${reasonDetail}` : ''
);
});
const user = new User(socket, socket.context.ipAddress, socket.context.user);
if (socket.context.user) {
db.recordVisit(socket.context.ipAddress, user.getName());
}
const announcement = Server.getServer().announcement;
if (announcement !== null) {
socket.emit('announcement', announcement);
}
}
setRateLimiter(socket) {
const refillRate = () => Config.get('io.throttle.in-rate-limit');
const capacity = () => Config.get('io.throttle.bucket-capacity');
socket._inRateLimit = new TokenBucket(capacity, refillRate);
socket.on('cytube:count-event', () => {
if (socket._inRateLimit.throttle()) {
LOGGER.warn(
'Kicking client %s: exceeded in-rate-limit of %d',
socket.context.ipAddress,
refillRate()
);
socket.emit('kick', { reason: 'Rate limit exceeded' });
socket.disconnect();
}
});
}
initSocketIO() {
const io = this.io = sio.instance = sio();
io.use(this.ipProxyMiddleware.bind(this));
io.use(this.ipBanMiddleware.bind(this));
io.use(this.ipThrottleMiddleware.bind(this));
io.use(this.cookieParsingMiddleware.bind(this));
io.use(this.ipSessionCookieMiddleware.bind(this));
io.use(this.authUserMiddleware.bind(this));
io.on('connection', this.handleConnection.bind(this));
}
bindTo(servers) {
if (!this.io) {
throw new Error('Cannot bind: socket.io has not been initialized yet');
}
const engineOpts = {
/*
* Set ping timeout to 2 minutes to avoid spurious reconnects
* during transient network issues. The default of 20 seconds
* is too aggressive.
*
* https://github.com/calzoneman/sync/issues/780
*/
pingTimeout: 120000,
/*
* Per `ws` docs: "Note that Node.js has a variety of issues with
* high-performance compression, where increased concurrency,
* especially on Linux, can lead to catastrophic memory
* fragmentation and slow performance."
*
* CyTube's frames are ordinarily quite small, so there's not much
* point in compressing them.
*/
perMessageDeflate: false,
httpCompression: false,
maxHttpBufferSize: 1 << 20,
/*
* Enable legacy support for socket.io v2 clients (e.g., bots)
*/
allowEIO3: true,
cors: {
origin: getCorsAllowCallback(),
credentials: true // enable cookies for auth
}
};
servers.forEach(server => {
this.io.attach(server, engineOpts);
});
}
}
const incomingEventCount = new Counter({
name: 'cytube_socketio_incoming_events_total',
help: 'Number of received socket.io events from clients'
});
const outgoingPacketCount = new Counter({
name: 'cytube_socketio_outgoing_packets_total',
help: 'Number of outgoing socket.io packets to clients'
});
function patchSocketMetrics(sock) {
const emit = require('events').EventEmitter.prototype.emit;
sock.onAny(() => {
incomingEventCount.inc(1);
emit.call(sock, 'cytube:count-event');
});
let packet = sock.packet;
sock.packet = function patchedPacket() {
packet.apply(this, arguments);
outgoingPacketCount.inc(1);
}.bind(sock);
}
/* TODO: remove this crap */
/* Addendum 2021-08-14: socket.io v4 supports middleware, maybe move type validation to that */
function patchTypecheckedFunctions(sock) {
sock.typecheckedOn = function typecheckedOn(msg, template, cb) {
this.on(msg, (data, ack) => {
typecheck(data, template, (err, data) => {
if (err) {
this.emit("errorMsg", {
msg: "Unexpected error for message " + msg + ": " + err.message
});
} else {
cb(data, ack);
}
});
});
}.bind(sock);
sock.typecheckedOnce = function typecheckedOnce(msg, template, cb) {
this.once(msg, data => {
typecheck(data, template, (err, data) => {
if (err) {
this.emit("errorMsg", {
msg: "Unexpected error for message " + msg + ": " + err.message
});
} else {
cb(data);
}
});
});
}.bind(sock);
}
let globalIPBanlist = null;
function isIPGlobalBanned(ip) {
if (globalIPBanlist === null) {
globalIPBanlist = new CachingGlobalBanlist(db.getGlobalBanDB());
globalIPBanlist.refreshCache();
globalIPBanlist.startCacheTimer(60 * 1000);
}
return globalIPBanlist.isIPGlobalBanned(ip);
}
const promSocketCount = new Gauge({
name: 'cytube_sockets_num_connected',
help: 'Gauge of connected socket.io clients',
labelNames: ['transport']
});
const promSocketAccept = new Counter({
name: 'cytube_sockets_accepts_total',
help: 'Counter for number of connections accepted. Excludes rejected connections.'
});
const promSocketDisconnect = new Counter({
name: 'cytube_sockets_disconnects_total',
help: 'Counter for number of connections disconnected.'
});
const promSocketReconnect = new Counter({
name: 'cytube_sockets_reconnects_total',
help: 'Counter for number of reconnects detected.'
});
function emitMetrics(sock) {
try {
let closed = false;
let transportName = sock.conn.transport.name;
promSocketCount.inc({ transport: transportName });
promSocketAccept.inc(1);
sock.conn.on('upgrade', () => {
try {
let newTransport = sock.conn.transport.name;
// Sanity check
if (!closed && newTransport !== transportName) {
promSocketCount.dec({ transport: transportName });
transportName = newTransport;
promSocketCount.inc({ transport: transportName });
}
} catch (error) {
LOGGER.error('Error emitting transport upgrade metrics for socket (ip=%s): %s',
sock.context.ipAddress, error.stack);
}
});
sock.once('disconnect', () => {
try {
closed = true;
promSocketCount.dec({ transport: transportName });
promSocketDisconnect.inc(1);
} catch (error) {
LOGGER.error('Error emitting disconnect metrics for socket (ip=%s): %s',
sock.context.ipAddress, error.stack);
}
});
sock.once('reportReconnect', () => {
try {
promSocketReconnect.inc(1, new Date());
} catch (error) {
LOGGER.error('Error emitting reconnect metrics for socket (ip=%s): %s',
sock.context.ipAddress, error.stack);
}
});
} catch (error) {
LOGGER.error('Error emitting metrics for socket (ip=%s): %s',
sock.context.ipAddress, error.stack);
}
}
let instance = null;
module.exports = {
init: function (srv, webConfig) {
if (instance !== null) {
throw new Error('ioserver.init: already initialized');
}
const ioServer = instance = new IOServer({
proxyTrustFn: proxyaddr.compile(webConfig.getTrustedProxies())
});
ioServer.initSocketIO();
const uniqueListenAddresses = new Set();
const servers = [];
Config.get("listen").forEach(function (bind) {
if (!bind.io) {
return;
}
const id = bind.ip + ":" + bind.port;
if (uniqueListenAddresses.has(id)) {
LOGGER.warn("Ignoring duplicate listen address %s", id);
return;
}
if (srv.servers.hasOwnProperty(id)) {
servers.push(srv.servers[id]);
} else {
const server = http.createServer().listen(bind.port, bind.ip);
servers.push(server);
server.on("error", error => {
if (error.code === "EADDRINUSE") {
LOGGER.fatal(
"Could not bind %s: address already in use. Check " +
"whether another application has already bound this " +
"port, or whether another instance of this server " +
"is running.",
id
);
process.exit(1);
}
});
}
uniqueListenAddresses.add(id);
});
ioServer.bindTo(servers);
},
IOServer: IOServer
};
/* Clean out old rate limiters */
setInterval(function () {
if (instance == null) return;
let cleaned = 0;
const keys = instance.ipThrottle.keys();
for (const key of keys) {
if (instance.ipThrottle.get(key).lastRefill < Date.now() - 60000) {
const bucket = instance.ipThrottle.delete(key);
for (const k in bucket) delete bucket[k];
cleaned++;
}
}
if (cleaned > 0) {
LOGGER.info('Cleaned up %d stale IP throttle token buckets', cleaned);
}
}, 5 * 60 * 1000);
function getCorsAllowCallback() {
let origins = Array.prototype.slice.call(Config.get('io.cors.allowed-origins'));
origins = origins.concat([
Config.get('io.domain'),
Config.get('https.domain')
]);
return function corsOriginAllowed(origin, callback) {
if (!origin) {
// Non-browser clients might not care about Origin, allow these.
callback(null, true);
return;
}
// Different ports are technically cross-origin; a distinction that does not matter to CyTube.
origin = origin.replace(/:\d+$/, '');
if (origins.includes(origin)) {
callback(null, true);
} else {
LOGGER.warn('Rejecting origin "%s"; allowed origins are %j', origin, origins);
callback(new Error('Invalid origin'));
}
};
}

28
src/legacymodule.js Normal file
View file

@ -0,0 +1,28 @@
import NullClusterClient from './io/cluster/nullclusterclient';
import Config from './config';
import IOConfiguration from './configuration/ioconfig';
import { EventEmitter } from 'events';
class LegacyModule {
getIOConfig() {
if (!this.ioConfig) {
this.ioConfig = IOConfiguration.fromOldConfig(Config);
}
return this.ioConfig;
}
getClusterClient() {
return new NullClusterClient(this.getIOConfig());
}
getGlobalMessageBus() {
return new EventEmitter();
}
onReady() {
}
}
export { LegacyModule };

86
src/logger.js Normal file
View file

@ -0,0 +1,86 @@
var fs = require("fs");
var path = require("path");
import { Logger as JsliLogger, LogLevel } from '@calzoneman/jsli';
import jsli from '@calzoneman/jsli';
function getTimeString() {
var d = new Date();
return d.toDateString() + " " + d.toTimeString().split(" ")[0];
}
var Logger = function(filename) {
this.filename = filename;
this.writer = fs.createWriteStream(filename, {
flags: "a",
encoding: "utf-8"
});
};
Logger.prototype.log = function () {
var msg = "";
for(var i in arguments)
msg += arguments[i];
if(this.dead) {
return;
}
var str = "[" + getTimeString() + "] " + msg + "\n";
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 () {
try {
this.writer.end();
} catch(e) {
errlog.log("Log close failed: " + this.filename);
}
};
function makeConsoleLogger(filename) {
/* eslint no-console: off */
var log = new Logger(filename);
log._log = log.log;
log.log = function () {
console.log.apply(console, arguments);
this._log.apply(this, arguments);
};
return log;
}
var errlog = makeConsoleLogger(path.join(__dirname, "..", "error.log"));
var syslog = makeConsoleLogger(path.join(__dirname, "..", "sys.log"));
var eventlog = makeConsoleLogger(path.join(__dirname, "..", "events.log"));
exports.Logger = Logger;
exports.errlog = errlog;
exports.syslog = syslog;
exports.eventlog = eventlog;
class LegacyLogger extends JsliLogger {
constructor(loggerName, level) {
super(loggerName, level);
}
emitMessage(level, message) {
var output = `[${level.name}] ${this.loggerName}: ${message}`;
if (level.shouldLogAtLevel(LogLevel.ERROR)) {
errlog.log(output);
} else {
syslog.log(output);
}
}
}
// TODO: allow reconfiguration of log level at runtime
const level = process.env.DEBUG ? LogLevel.DEBUG : LogLevel.INFO;
jsli.setLogBackend((loggerName) => {
return new LegacyLogger(loggerName, level);
});

120
src/main.js Normal file
View file

@ -0,0 +1,120 @@
import Config from './config';
import * as Switches from './switches';
import { eventlog } from './logger';
require('source-map-support').install();
const LOGGER = require('@calzoneman/jsli')('main');
try {
Config.load('config.yaml');
} catch (e) {
LOGGER.fatal(
"Failed to load configuration: %s",
e
);
process.exit(1);
}
const sv = require('./server').init();
if (!Config.get('debug')) {
process.on('uncaughtException', error => {
LOGGER.fatal('Uncaught exception: %s', error.stack);
});
process.on('SIGINT', () => {
LOGGER.info('Caught SIGINT; shutting down');
sv.shutdown();
});
}
// TODO: this can probably just be part of servsock.js
// servsock should also be refactored to send replies instead of
// relying solely on tailing logs
function handleLine(line) {
if (line === '/reload') {
LOGGER.info('Reloading config');
try {
Config.load('config.yaml');
} catch (e) {
LOGGER.error(
"Failed to load configuration: %s",
e
);
}
require('./web/pug').clearCache();
} else if (line.indexOf('/switch') === 0) {
const args = line.split(' ');
args.shift();
if (args.length === 1) {
LOGGER.info('Switch ' + args[0] + ' is ' +
(Switches.isActive(args[0]) ? 'ON' : 'OFF'));
} else if (args.length === 2) {
Switches.setActive(args[0], args[1].toLowerCase() === 'on' ? true : false);
LOGGER.info('Switch ' + args[0] + ' is now ' +
(Switches.isActive(args[0]) ? 'ON' : 'OFF'));
}
} else if (line.indexOf('/reload-partitions') === 0) {
sv.reloadPartitionMap();
} else if (line.indexOf('/save') === 0) {
sv.forceSave();
} else if (line.indexOf('/unloadchan') === 0) {
const args = line.split(/\s+/); args.shift();
if (args.length) {
const name = args.shift();
const chan = sv.getChannel(name);
const users = Array.prototype.slice.call(chan.users);
chan.emit('empty');
users.forEach(function (u) {
u.kick('Channel shutting down');
});
eventlog.log('[acp] ' + 'SYSTEM' + ' forced unload of ' + name);
}
} else if (line.indexOf('/reloadcert') === 0) {
sv.reloadCertificateData();
}
}
// Go Go Gadget Service Socket
if (Config.get('service-socket.enabled')) {
LOGGER.info('Opening service socket');
const ServiceSocket = require('./servsock');
const sock = new ServiceSocket();
sock.init(
line => {
try {
handleLine(line);
} catch (error) {
LOGGER.error(
'Error in UNIX socket command handler: %s',
error.stack
);
}
},
Config.get('service-socket.socket')
);
}
let stdinbuf = '';
process.stdin.on('data', function (data) {
stdinbuf += data;
if (stdinbuf.indexOf('\n') !== -1) {
let line = stdinbuf.substring(0, stdinbuf.indexOf('\n'));
stdinbuf = stdinbuf.substring(stdinbuf.indexOf('\n') + 1);
try {
handleLine(line);
} catch (error) {
LOGGER.error('Command line input handler failed: %s', error.stack);
}
}
});
// Hi I'm Mr POSIX! Look at me!
process.on('SIGUSR2', () => {
sv.reloadCertificateData();
});
require('bluebird');
process.on('unhandledRejection', function (reason, _promise) {
LOGGER.error('Unhandled rejection: %s', reason.stack);
});

80
src/media.js Normal file
View file

@ -0,0 +1,80 @@
var util = require("./utilities");
function Media(id, title, seconds, type, meta) {
if (!meta) {
meta = {};
}
this.id = id;
this.setTitle(title);
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 = {
setTitle: function (title) {
this.title = title;
if (this.title.length > 100) {
this.title = this.title.substring(0, 97) + "...";
}
},
pack: function () {
const result = {
id: this.id,
title: this.title,
seconds: this.seconds,
duration: this.duration,
type: this.type,
meta: {
restricted: this.meta.restricted,
codec: this.meta.codec,
bitrate: this.meta.bitrate,
scuri: this.meta.scuri,
embed: this.meta.embed,
gdrive_subtitles: this.meta.gdrive_subtitles,
textTracks: this.meta.textTracks,
mixer: this.meta.mixer
}
};
/*
* 2018-03-05: Remove GDrive metadata from saved playlists to save
* space since this is no longer used.
*
* 2020-01-26: Remove Twitch clip metadata since their API changed
* and no longer uses direct links
*/
if (this.type !== "gd" && this.type !== "tc") {
result.meta.direct = this.meta.direct;
}
return result;
},
getTimeUpdate: function () {
return {
currentTime: this.currentTime,
paused: this.paused
};
},
getFullUpdate: function () {
var packed = this.pack();
packed.currentTime = this.currentTime;
packed.paused = this.paused;
return packed;
},
reset: function () {
this.currentTime = 0;
this.paused = false;
}
};
module.exports = Media;

View file

@ -0,0 +1,57 @@
import { v4 as uuidv4 } from 'uuid';
const LOGGER = require('@calzoneman/jsli')('announcementrefresher');
var SERVER;
class AnnouncementRefresher {
constructor(pubClient, subClient, channel) {
this.pubClient = pubClient;
this.subClient = subClient;
this.channel = channel;
this.uuid = uuidv4();
process.nextTick(this.init.bind(this));
}
init() {
SERVER = require('../server').getServer();
SERVER.on('announcement', this.sendAnnouncement.bind(this));
this.subClient.once('ready', () => {
this.subClient.on('message', this.handleMessage.bind(this));
this.subClient.subscribe(this.channel);
});
}
handleMessage(channel, message) {
if (channel !== this.channel) {
LOGGER.warn('Unexpected message from channel "%s"', channel);
return;
}
var data;
try {
data = JSON.parse(message);
} catch (error) {
LOGGER.error('Unable to unmarshal server announcement: ' + error.stack
+ '\nMessage was: ' + message);
return;
}
if (data.partitionID === this.uuid) {
return;
}
SERVER.setAnnouncement(data.data);
}
sendAnnouncement(data) {
const message = JSON.stringify({
data: data,
partitionID: this.uuid
});
this.pubClient.publish(this.channel, message);
}
}
export { AnnouncementRefresher };

View file

@ -0,0 +1,151 @@
import Promise from 'bluebird';
import { v4 as uuidv4 } from 'uuid';
const LOGGER = require('@calzoneman/jsli')('partitionchannelindex');
var SERVER = null;
const CACHE_REFRESH_INTERVAL = 30 * 1000;
const CACHE_EXPIRE_DELAY = 40 * 1000;
class PartitionChannelIndex {
constructor(pubClient, subClient, channel) {
this.id = uuidv4();
this.pubClient = pubClient;
this.subClient = subClient;
this.channel = channel;
this.id2instance = new Map();
this._cache = [];
this.pubClient.on('error', error => {
LOGGER.error('pubClient error: %s', error.stack);
});
this.subClient.on('error', error => {
LOGGER.error('subClient error: %s', error.stack);
});
this.subClient.once('ready', () => {
this.subClient.on(
'message',
(channel, message) => this._handleMessage(channel, message)
);
this.subClient.subscribe(this.channel);
this._bootstrap();
});
}
_bootstrap() {
LOGGER.info('Bootstrapping partition channel index (id=%s)', this.id);
SERVER = require('../server').getServer();
setInterval(() => this._broadcastMyList(), CACHE_REFRESH_INTERVAL);
const bootstrap = JSON.stringify({
operation: 'bootstrap',
instanceId: this.id,
payload: {}
});
this.pubClient.publishAsync(this.channel, bootstrap).catch(error => {
LOGGER.error('Failed to send bootstrap request: %s', error.stack);
});
this._broadcastMyList();
}
_handleMessage(channel, message) {
if (channel !== this.channel) {
LOGGER.warn('Unexpected message from channel "%s"', channel);
return;
}
try {
const { operation, instanceId, payload } = JSON.parse(message);
if (instanceId === this.id) {
return;
}
switch (operation) {
case 'bootstrap':
LOGGER.info(
'Received bootstrap request from %s',
instanceId
);
this._broadcastMyList();
break;
case 'put-list':
LOGGER.info(
'Received put-list request from %s',
instanceId
);
this._putList(instanceId, payload);
break;
default:
LOGGER.warn(
'Unknown channel index sync operation "%s" from %s',
operation,
instanceId
);
break;
}
} catch (error) {
LOGGER.error('Error handling channel index sync message: %s', error.stack);
}
}
_broadcastMyList() {
const channels = SERVER.packChannelList(true).map(channel => {
return {
name: channel.name,
mediatitle: channel.mediatitle,
pagetitle: channel.pagetitle,
usercount: channel.usercount
};
});
this._putList(this.id, { channels });
const message = JSON.stringify({
operation: 'put-list',
instanceId: this.id,
payload: {
channels
}
});
this.pubClient.publishAsync(this.channel, message).catch(error => {
LOGGER.error('Failed to publish local channel list: %s', error.stack);
});
}
_putList(instanceId, payload) {
const { channels } = payload;
this.id2instance.set(
instanceId,
{
lastUpdated: new Date(),
channels
}
);
this._updateCache();
}
_updateCache() {
let cache = [];
for (let [id, instance] of this.id2instance) {
if (Date.now() - instance.lastUpdated.getTime() > CACHE_EXPIRE_DELAY) {
LOGGER.warn('Removing expired channel list instance: %s', id);
this.id2instance.delete(id);
} else {
cache = cache.concat(instance.channels);
}
}
this._cache = cache;
}
listPublicChannels() {
return Promise.resolve(this._cache);
}
}
export { PartitionChannelIndex };

View file

@ -0,0 +1,85 @@
import { PartitionModule } from './partitionmodule';
import { PartitionMap } from './partitionmap';
import fs from 'fs';
/* eslint no-console: off */
const partitionModule = new PartitionModule();
partitionModule.cliMode = true;
function savePartitionMap(filename) {
const reloader = partitionModule.getPartitionMapReloader();
reloader.once('partitionMapChange', map => {
var toml = 'pool = [\n';
map.getPool().forEach((poolEntry, i) => {
toml += ` '${poolEntry}'`;
if (i < map.getPool().length - 1) {
toml += ',';
}
toml += '\n';
});
toml += ']\n\n';
const partitions = map.getPartitions();
Object.keys(partitions).forEach(identity => {
partitions[identity].servers.forEach(serverDef => {
toml += `[[partitions.${identity}.servers]]\n`;
toml += `url = '${serverDef.url}'\n`;
toml += `secure = ${serverDef.secure}\n`;
toml += '\n';
});
});
toml += '[overrides]\n';
const overrides = map.getOverrides();
Object.keys(overrides).forEach(channel => {
toml += `${channel} = '${overrides[channel]}'\n`;
});
fs.writeFileSync(filename, toml);
console.log(`Wrote partition map to ${filename}`);
process.exit(0);
});
}
function loadPartitionMap(filename) {
var newMap;
try {
newMap = PartitionMap.fromFile(filename);
} catch (error) {
console.error(`Failed to load partition map from ${filename}: ${error}`);
console.error(error.stack);
process.exit(1);
}
const client = partitionModule.getRedisClientProvider().get();
const config = partitionModule.partitionConfig;
client.once('ready', () => {
client.multi()
.set(config.getPartitionMapKey(), JSON.stringify(newMap))
.publish(config.getPublishChannel(), new Date().toISOString())
.execAsync()
.then(result => {
console.log(`Result: ${result}`);
console.log(`Published new partition map from ${filename}`);
process.exit(0);
}).catch(error => {
console.error(`Failed to publish partition map: ${error}`);
console.error(error.stack);
process.exit(1);
});
});
}
if (process.argv[2] === 'save') {
savePartitionMap(process.argv[3]);
} else if (process.argv[2] === 'load') {
loadPartitionMap(process.argv[3]);
} else {
console.error('Usage: ' + process.argv[0] + ' ' + process.argv[1] + ' <load|save> <filename>');
console.error(' "save" downloads the partition map and saves it to the specified file');
console.error(' "load" loads the partition map from the specified file and publishes it');
process.exit(1);
}

View file

@ -0,0 +1,35 @@
class PartitionConfig {
constructor(config) {
this.config = config;
}
getIdentity() {
return this.config.identity;
}
getRedisConfig() {
return this.config.redis;
}
getPublishChannel() {
return this.config.redis.publishChannel;
}
getPartitionMapKey() {
return this.config.redis.partitionMapKey;
}
getAnnouncementChannel() {
return this.config.redis.announcementChannel || 'serverAnnouncements';
}
getGlobalMessageBusChannel() {
return this.config.redis.globalMessageBusChannel || 'globalMessages';
}
getChannelIndexChannel() {
return this.config.redis.channelIndexChannel || 'channelIndexUpdates';
}
}
export { PartitionConfig };

View file

@ -0,0 +1,37 @@
import { murmurHash1 } from '../util/murmur';
class PartitionDecider {
constructor(config, partitionMap) {
this.config = config;
this.partitionMap = partitionMap;
}
getPartitionForChannel(channel) {
return this.partitionMap.getPartitions()[this.getPartitionIdentityForChannel(channel)];
}
getPartitionIdentityForChannel(channel) {
channel = channel.toLowerCase();
const overrideMap = this.partitionMap.getOverrides();
if (overrideMap.hasOwnProperty(channel)) {
return overrideMap[channel];
} else if (this.partitionMap.getPool().length > 0) {
const pool = this.partitionMap.getPool();
const i = murmurHash1(channel) % pool.length;
return pool[i];
} else {
return { servers: [] };
}
}
isChannelOnThisPartition(channel) {
return this.getPartitionIdentityForChannel(channel) ===
this.config.getIdentity();
}
setPartitionMap(newMap) {
this.partitionMap = newMap;
}
}
export { PartitionDecider };

View file

@ -0,0 +1,83 @@
import crypto from 'crypto';
import fs from 'fs';
import toml from 'toml';
function sha256(input) {
var hash = crypto.createHash('sha256');
hash.update(input);
return hash.digest('base64');
}
class PartitionMap {
/**
* @param {Map<string, object>} partitions Map of node ids to io configs
* @param {Array<string>} pool List of available nodes
* @param {Map<string, string>} overrides Overrides for node assignment
*/
constructor(partitions, pool, overrides) {
this.partitions = partitions;
this.pool = pool;
this.overrides = overrides || {};
this._hash = sha256(JSON.stringify(this.partitions)
+ JSON.stringify(this.pool)
+ JSON.stringify(this.overrides));
}
getHash() {
return this._hash;
}
getPartitions() {
return this.partitions;
}
getPool() {
return this.pool;
}
getOverrides() {
return this.overrides;
}
toJSON() {
return {
partitions: this.partitions,
pool: this.pool,
overrides: this.overrides,
hash: this._hash
};
}
static fromJSON(json) {
if (json === null) {
throw new Error('Cannot construct PartitionMap: input is null');
} else if (typeof json !== 'object') {
throw new Error(`Cannot construct PartitionMap from input "${json}" of type `
+ typeof json);
} else if (!json.partitions || typeof json.partitions !== 'object') {
throw new Error('Cannot construct PartitionMap: field partitions must be '
+ `an object but was "${json.partitions}"`);
} else if (!json.overrides || typeof json.overrides !== 'object') {
throw new Error('Cannot construct PartitionMap: field overrides must be '
+ `an object but was "${json.overrides}"`);
} else if (!json.pool || !Array.isArray(json.pool)) {
throw new Error('Cannot construct PartitionMap: field pool must be '
+ `an array but was "${json.pool}"`);
}
return new PartitionMap(json.partitions, json.pool, json.overrides);
}
static fromFile(filename) {
const rawData = fs.readFileSync(filename).toString('utf8');
const parsed = toml.parse(rawData);
return PartitionMap.fromJSON(parsed);
}
static empty() {
return new PartitionMap({}, [], {});
}
}
export { PartitionMap };

View file

@ -0,0 +1,122 @@
import { loadFromToml } from '../configuration/configloader';
import { PartitionConfig } from './partitionconfig';
import { PartitionDecider } from './partitiondecider';
import { PartitionClusterClient } from '../io/cluster/partitionclusterclient';
import RedisClientProvider from '../redis/redisclientprovider';
import path from 'path';
import { AnnouncementRefresher } from './announcementrefresher';
import { RedisPartitionMapReloader } from './redispartitionmapreloader';
import { RedisMessageBus } from '../pubsub/redis';
const PARTITION_CONFIG_PATH = path.resolve(__dirname, '..', '..', 'conf',
'partitions.toml');
const logger = require('@calzoneman/jsli')('PartitionModule');
class PartitionModule {
constructor() {
this.initConfig();
this.cliMode = false;
}
onReady() {
this.getAnnouncementRefresher();
}
initConfig() {
try {
this.partitionConfig = this.loadPartitionConfig();
} catch (error) {
process.exit(1);
}
}
loadPartitionConfig() {
try {
return loadFromToml(PartitionConfig, PARTITION_CONFIG_PATH);
} catch (error) {
if (typeof error.line !== 'undefined') {
logger.error(`Error in ${PARTITION_CONFIG_PATH}: ${error} ` +
`(line ${error.line})`);
} else {
logger.error(`Error loading ${PARTITION_CONFIG_PATH}: ` +
`${error.stack}`);
}
throw error;
}
}
getPartitionMapReloader() {
if (!this.partitionMapReloader) {
const redisProvider = this.getRedisClientProvider();
this.partitionMapReloader = new RedisPartitionMapReloader(
this.partitionConfig,
redisProvider.get(), // Client for GET partitionMap
redisProvider.get()); // Subscribe client
}
return this.partitionMapReloader;
}
getPartitionDecider() {
if (!this.partitionDecider) {
const reloader = this.getPartitionMapReloader();
this.partitionDecider = new PartitionDecider(this.partitionConfig,
reloader.getPartitionMap());
reloader.on('partitionMapChange', newMap => {
this.partitionDecider.setPartitionMap(newMap);
if (!this.cliMode) {
require('../server').getServer().handlePartitionMapChange();
}
});
}
return this.partitionDecider;
}
getClusterClient() {
if (!this.partitionClusterClient) {
this.partitionClusterClient = new PartitionClusterClient(
this.getPartitionDecider());
}
return this.partitionClusterClient;
}
getRedisClientProvider() {
if (!this.redisClientProvider) {
this.redisClientProvider = new RedisClientProvider(
this.partitionConfig.getRedisConfig()
);
}
return this.redisClientProvider;
}
getAnnouncementRefresher() {
if (!this.announcementRefresher) {
const provider = this.getRedisClientProvider();
this.announcementRefresher = new AnnouncementRefresher(
provider.get(),
provider.get(),
this.partitionConfig.getAnnouncementChannel()
);
}
return this.announcementRefresher;
}
getGlobalMessageBus() {
if (!this.globalMessageBus) {
const provider = this.getRedisClientProvider();
this.globalMessageBus = new RedisMessageBus(
provider.get(),
provider.get(),
this.partitionConfig.getGlobalMessageBusChannel()
);
}
return this.globalMessageBus;
}
}
export { PartitionModule };

View file

@ -0,0 +1,57 @@
import { PartitionMap } from './partitionmap';
import { EventEmitter } from 'events';
const logger = require('@calzoneman/jsli')('RedisPartitionMapReloader');
class RedisPartitionMapReloader extends EventEmitter {
constructor(config, redisClient, subClient) {
super();
this.config = config;
this.redisClient = redisClient;
this.subClient = subClient;
this.partitionMap = PartitionMap.empty();
redisClient.once('ready', () => this.reload());
subClient.once('ready', () => this.subscribe());
}
subscribe() {
this.subClient.subscribe(this.config.getPublishChannel());
this.subClient.on('message', (channel, message) => {
if (channel !== this.config.getPublishChannel()) {
logger.warn('RedisPartitionMapReloader received unexpected message '
+ `on redis channel ${channel}`);
return;
}
logger.info(`Received partition map update message published at ${message}`);
this.reload();
});
}
reload() {
this.redisClient.getAsync(this.config.getPartitionMapKey()).then(result => {
var newMap = null;
try {
newMap = PartitionMap.fromJSON(JSON.parse(result));
} catch (error) {
logger.error(`Failed to decode received partition map: ${error}`,
{ payload: result });
return;
}
if (this.partitionMap.getHash() !== newMap.getHash()) {
logger.info(`Partition map changed (hash=${newMap.getHash()})`);
this.partitionMap = newMap;
this.emit('partitionMapChange', newMap);
}
}).catch(error => {
logger.error(`Failed to retrieve partition map from redis: ${error}`);
});
}
getPartitionMap() {
return this.partitionMap;
}
}
export { RedisPartitionMapReloader };

103
src/poll.js Normal file
View file

@ -0,0 +1,103 @@
const link = /(\w+:\/\/(?:[^:/[\]\s]+|\[[0-9a-f:]+\])(?::\d+)?(?:\/[^/\s]*)*)/ig;
const XSS = require('./xss');
function sanitizedWithLinksReplaced(text) {
return XSS.sanitizeText(text)
.replace(link, '<a href="$1" target="_blank" rel="noopener noreferer">$1</a>');
}
class Poll {
static create(createdBy, title, choices, options = { hideVotes: false, retainVotes: false }) {
let poll = new Poll();
poll.createdAt = new Date();
poll.createdBy = createdBy;
poll.title = sanitizedWithLinksReplaced(title);
poll.choices = choices.map(choice => sanitizedWithLinksReplaced(choice));
poll.hideVotes = options.hideVotes;
poll.retainVotes = options.retainVotes;
poll.votes = new Map();
return poll;
}
static fromChannelData({ initiator, title, options, _counts, votes, timestamp, obscured, retainVotes }) {
let poll = new Poll();
if (timestamp === undefined) // Very old polls still in the database lack timestamps
timestamp = Date.now();
poll.createdAt = new Date(timestamp);
poll.createdBy = initiator;
poll.title = title;
poll.choices = options;
poll.votes = new Map();
Object.keys(votes).forEach(key => {
if (votes[key] !== null)
poll.votes.set(key, votes[key]);
});
poll.hideVotes = obscured;
poll.retainVotes = retainVotes || false;
return poll;
}
toChannelData() {
let counts = new Array(this.choices.length);
counts.fill(0);
// TODO: it would be desirable one day to move away from using an Object here.
// This is just for backwards-compatibility with the existing format.
let votes = {};
this.votes.forEach((index, key) => {
votes[key] = index;
counts[index]++;
});
return {
title: this.title,
initiator: this.createdBy,
options: this.choices,
counts,
votes,
obscured: this.hideVotes,
retainVotes: this.retainVotes,
timestamp: this.createdAt.getTime()
};
}
countVote(key, choiceId) {
if (choiceId < 0 || choiceId >= this.choices.length)
return false;
let changed = !this.votes.has(key) || this.votes.get(key) !== choiceId;
this.votes.set(key, choiceId);
return changed;
}
uncountVote(key) {
let changed = this.votes.has(key);
this.votes.delete(key);
return changed;
}
toUpdateFrame(showHiddenVotes) {
let counts = new Array(this.choices.length);
counts.fill(0);
this.votes.forEach(index => counts[index]++);
if (this.hideVotes) {
counts = counts.map(c => {
if (showHiddenVotes) return `${c}?`;
else return '?';
});
}
return {
title: this.title,
options: this.choices,
counts: counts,
initiator: this.createdBy,
timestamp: this.createdAt.getTime()
};
}
}
exports.Poll = Poll;

57
src/prometheus-server.js Normal file
View file

@ -0,0 +1,57 @@
import http from 'http';
import { register, collectDefaultMetrics } from 'prom-client';
import { parse as parseURL } from 'url';
const LOGGER = require('@calzoneman/jsli')('prometheus-server');
let server = null;
export function init(prometheusConfig) {
if (server !== null) {
LOGGER.error('init() called but server is already initialized! %s',
new Error().stack);
return;
}
collectDefaultMetrics();
server = http.createServer((req, res) => {
if (req.method !== 'GET'
|| parseURL(req.url).pathname !== prometheusConfig.getPath()) {
res.writeHead(400, { 'Content-Type': 'text/plain' });
res.end('Bad Request');
return;
}
register.metrics().then(metrics => {
res.writeHead(200, {
'Content-Type': register.contentType
});
res.end(metrics);
}).catch(error => {
LOGGER.error('Error generating prometheus metrics: %s', error.stack);
res.writeHead(500, {
'Content-Type': 'text/plain'
});
res.end('Internal Server Error');
});
});
server.on('error', error => {
LOGGER.error('Server error: %s', error.stack);
});
server.once('listening', () => {
LOGGER.info('Prometheus metrics reporter listening on %s:%s',
prometheusConfig.getHost(),
prometheusConfig.getPort());
});
server.listen(prometheusConfig.getPort(), prometheusConfig.getHost());
return { once: server.once.bind(server) };
}
export function shutdown() {
server.close();
server = null;
}

70
src/pubsub/redis.js Normal file
View file

@ -0,0 +1,70 @@
import { EventEmitter } from 'events';
import { v4 as uuidv4 } from 'uuid';
const LOGGER = require('@calzoneman/jsli')('redis-messagebus');
class RedisMessageBus extends EventEmitter {
constructor(pubClient, subClient, channel) {
super();
this.pubClient = pubClient;
this.subClient = subClient;
this.channel = channel;
this.publisherID = uuidv4();
subClient.once('ready', this.subscribe.bind(this));
}
subscribe() {
this.subClient.subscribe(this.channel);
this.subClient.on('message', this.onMessage.bind(this));
LOGGER.info('Subscribed to Redis messages on channel %s', this.channel);
}
onMessage(channel, message) {
if (channel !== this.channel) {
LOGGER.warn('Ignoring message from mismatched channel "%s"', channel);
return;
}
try {
const { event, payload } = JSON.parse(message);
this._emit(event, payload);
} catch (error) {
if (error instanceof SyntaxError) {
LOGGER.error(
'Malformed message received: %s (message: "%s")',
message,
error
);
} else {
LOGGER.error('Unexpected error decoding message: %s', error.stack);
}
return;
}
}
async emit(event, payload) {
try {
const message = JSON.stringify({
time: new Date(),
publisher: this.publisherID,
event,
payload
});
await this.pubClient.publish(this.channel, message);
} catch (error) {
LOGGER.error('Unable to send event %s: %s', event, error);
}
}
}
Object.assign(RedisMessageBus.prototype, {
_emit: EventEmitter.prototype.emit
});
export { RedisMessageBus };

44
src/redis/lualoader.js Normal file
View file

@ -0,0 +1,44 @@
import fs from 'fs';
const logger = require('@calzoneman/jsli')('redis/lualoader');
const CACHE = {};
const EVALSHA_CACHE = {};
export function loadLuaScript(filename) {
if (CACHE.hasOwnProperty(filename)) {
return CACHE[filename];
}
CACHE[filename] = fs.readFileSync(filename).toString('utf8');
return CACHE[filename];
}
function loadAndExecuteScript(redisClient, filename, args) {
return redisClient.scriptAsync('load', loadLuaScript(filename))
.then(sha => {
EVALSHA_CACHE[filename] = sha;
logger.debug(`Cached ${filename} as ${sha}`);
return runEvalSha(redisClient, filename, args);
});
}
function runEvalSha(redisClient, filename, args) {
const evalInput = args.slice();
evalInput.unshift(EVALSHA_CACHE[filename]);
return redisClient.evalshaAsync.apply(redisClient, evalInput);
}
export function runLuaScript(redisClient, filename, args) {
if (EVALSHA_CACHE.hasOwnProperty(filename)) {
return runEvalSha(redisClient, filename, args).catch(error => {
if (error.code === 'NOSCRIPT') {
logger.warn(`Got NOSCRIPT error for ${filename}, reloading script`);
return loadAndExecuteScript(redisClient, filename, args);
} else {
throw error;
}
});
} else {
return loadAndExecuteScript(redisClient, filename, args);
}
}

View file

@ -0,0 +1,49 @@
import clone from 'clone';
import redis from 'redis';
import Promise from 'bluebird';
Promise.promisifyAll(redis.RedisClient.prototype);
Promise.promisifyAll(redis.Multi.prototype);
/**
* Provider for RedisClients.
*/
class RedisClientProvider {
/**
* Create a new RedisClientProvider.
*
* @param {Object} redisConfig default configuration to use
* @see {@link https://www.npmjs.com/package/redis}
*/
constructor(redisConfig) {
this.redisConfig = redisConfig;
}
/**
* Get a RedisClient.
*
* @param {Object} options optional override configuration for the RedisClient
* @return {RedisClient} redis client using the provided configuration
*/
get(options = {}) {
const config = clone(this.redisConfig);
for (const key in options) {
config[key] = options[key];
}
const client = redis.createClient(config);
client.on('error', this._defaultErrorHandler);
return client;
}
/**
* Handle an <code>'error'</code> event from a provided client.
*
* @param {Error} err error from the client
* @private
*/
_defaultErrorHandler(_err) {
}
}
export default RedisClientProvider;

575
src/server.js Normal file
View file

@ -0,0 +1,575 @@
const VERSION = require("../package.json").version;
var singleton = null;
var Config = require("./config");
var Promise = require("bluebird");
import * as ChannelStore from './channel-storage/channelstore';
import { EventEmitter } from 'events';
const LOGGER = require('@calzoneman/jsli')('server');
module.exports = {
init: function () {
LOGGER.info("Starting CyTube v%s", VERSION);
var chanlogpath = path.join(__dirname, "../chanlogs");
fs.exists(chanlogpath, function (exists) {
exists || fs.mkdirSync(chanlogpath);
});
var gdvttpath = path.join(__dirname, "../google-drive-subtitles");
fs.exists(gdvttpath, function (exists) {
exists || fs.mkdirSync(gdvttpath);
});
singleton = new Server();
return singleton;
},
getServer: function () {
return singleton;
}
};
const path = require("path");
const fs = require("fs");
const http = require("http");
const https = require("https");
const express = require("express");
const Channel = require("./channel/channel");
const db = require("./database");
const Flags = require("./flags");
const sio = require("socket.io");
import LocalChannelIndex from './web/localchannelindex';
import { PartitionChannelIndex } from './partition/partitionchannelindex';
import IOConfiguration from './configuration/ioconfig';
import WebConfiguration from './configuration/webconfig';
import session from './session';
import { LegacyModule } from './legacymodule';
import { PartitionModule } from './partition/partitionmodule';
import { Gauge } from 'prom-client';
import { EmailController } from './controller/email';
import { CaptchaController } from './controller/captcha';
var Server = function () {
var self = this;
self.channels = [],
self.express = null;
self.db = null;
self.api = null;
self.announcement = null;
self.infogetter = null;
self.servers = {};
self.chanPath = Config.get('channel-path');
var initModule;
if (Config.get('enable-partition')) {
initModule = this.initModule = new PartitionModule();
self.partitionDecider = initModule.getPartitionDecider();
} else {
initModule = this.initModule = new LegacyModule();
}
const globalMessageBus = this.initModule.getGlobalMessageBus();
globalMessageBus.on('UserProfileChanged', this.handleUserProfileChange.bind(this));
globalMessageBus.on('ChannelDeleted', this.handleChannelDelete.bind(this));
globalMessageBus.on('ChannelRegistered', this.handleChannelRegister.bind(this));
// database init ------------------------------------------------------
var Database = require("./database");
self.db = Database;
self.db.init();
ChannelStore.init();
let emailTransport;
if (Config.getEmailConfig().getPasswordReset().isEnabled()) {
const smtpConfig = Config.getEmailConfig().getSmtp();
emailTransport = require("nodemailer").createTransport({
host: smtpConfig.getHost(),
port: smtpConfig.getPort(),
secure: smtpConfig.isSecure(),
auth: {
user: smtpConfig.getUser(),
pass: smtpConfig.getPassword()
}
});
} else {
emailTransport = {
sendMail() {
throw new Error('Email is not enabled on this server');
}
};
}
const emailController = new EmailController(
emailTransport,
Config.getEmailConfig()
);
const captchaController = new CaptchaController(
Config.getCaptchaConfig()
);
// webserver init -----------------------------------------------------
const ioConfig = IOConfiguration.fromOldConfig(Config);
const webConfig = WebConfiguration.fromOldConfig(Config);
const clusterClient = initModule.getClusterClient();
var channelIndex;
if (Config.get("enable-partition")) {
channelIndex = new PartitionChannelIndex(
initModule.getRedisClientProvider().get(),
initModule.getRedisClientProvider().get(),
initModule.partitionConfig.getChannelIndexChannel()
);
} else {
channelIndex = new LocalChannelIndex();
}
self.express = express();
require("./web/webserver").init(
self.express,
webConfig,
ioConfig,
clusterClient,
channelIndex,
session,
globalMessageBus,
Config.getEmailConfig(),
emailController,
Config.getCaptchaConfig(),
captchaController
);
// http/https/sio server init -----------------------------------------
var key = "", cert = "", ca = undefined;
if (Config.get("https.enabled")) {
const certData = self.loadCertificateData();
key = certData.key;
cert = certData.cert;
ca = certData.ca;
}
var opts = {
key: key,
cert: cert,
passphrase: Config.get("https.passphrase"),
ca: ca,
ciphers: Config.get("https.ciphers"),
honorCipherOrder: true
};
Config.get("listen").forEach(function (bind) {
var id = bind.ip + ":" + bind.port;
if (id in self.servers) {
LOGGER.warn("Ignoring duplicate listen address %s", id);
return;
}
if (bind.https && Config.get("https.enabled")) {
self.servers[id] = https.createServer(opts, self.express);
// 2 minute default copied from node <= 12.x
self.servers[id].timeout = 120000;
self.servers[id].listen(bind.port, bind.ip);
self.servers[id].on("error", error => {
if (error.code === "EADDRINUSE") {
LOGGER.fatal(
"Could not bind %s: address already in use. Check " +
"whether another application has already bound this " +
"port, or whether another instance of this server " +
"is running.",
id
);
process.exit(1);
}
});
} else if (bind.http) {
self.servers[id] = http.createServer(self.express);
// 2 minute default copied from node <= 12.x
self.servers[id].timeout = 120000;
self.servers[id].listen(bind.port, bind.ip);
self.servers[id].on("error", error => {
if (error.code === "EADDRINUSE") {
LOGGER.fatal(
"Could not bind %s: address already in use. Check " +
"whether another application has already bound this " +
"port, or whether another instance of this server " +
"is running.",
id
);
process.exit(1);
}
});
}
});
require("./io/ioserver").init(self, webConfig);
// background tasks init ----------------------------------------------
require("./bgtask")(self);
// prometheus server
const prometheusConfig = Config.getPrometheusConfig();
if (prometheusConfig.isEnabled()) {
require("./prometheus-server").init(prometheusConfig);
}
// setuid
require("./setuid");
initModule.onReady();
};
Server.prototype = Object.create(EventEmitter.prototype);
Server.prototype.loadCertificateData = function loadCertificateData() {
const data = {
key: fs.readFileSync(path.resolve(__dirname, "..",
Config.get("https.keyfile"))),
cert: fs.readFileSync(path.resolve(__dirname, "..",
Config.get("https.certfile")))
};
if (Config.get("https.cafile")) {
data.ca = fs.readFileSync(path.resolve(__dirname, "..",
Config.get("https.cafile")));
}
return data;
};
Server.prototype.reloadCertificateData = function reloadCertificateData() {
const certData = this.loadCertificateData();
Object.keys(this.servers).forEach(key => {
const server = this.servers[key];
// TODO: Replace with actual node API
// once https://github.com/nodejs/node/issues/4464 is implemented.
if (server._sharedCreds) {
try {
server._sharedCreds.context.setCert(certData.cert);
server._sharedCreds.context.setKey(certData.key, Config.get("https.passphrase"));
LOGGER.info('Reloaded certificate data for %s', key);
} catch (error) {
LOGGER.error('Failed to reload certificate data for %s: %s', key, error.stack);
}
}
});
};
Server.prototype.isChannelLoaded = function (name) {
name = name.toLowerCase();
for (var i = 0; i < this.channels.length; i++) {
if (this.channels[i].uniqueName == name)
return true;
}
return false;
};
const promActiveChannels = new Gauge({
name: 'cytube_channels_num_active',
help: 'Number of channels currently active'
});
Server.prototype.getChannel = function (name) {
var cname = name.toLowerCase();
if (this.partitionDecider &&
!this.partitionDecider.isChannelOnThisPartition(cname)) {
const error = new Error(`Channel '${cname}' is mapped to a different partition`);
error.code = 'EWRONGPART';
throw error;
}
var self = this;
for (var i = 0; i < self.channels.length; i++) {
if (self.channels[i].uniqueName === cname)
return self.channels[i];
}
var c = new Channel(name);
promActiveChannels.inc();
c.on("empty", function () {
//self.unloadChannel(c);
});
c.waitFlag(Flags.C_ERROR, () => {
self.unloadChannel(c, { skipSave: true });
});
self.channels.push(c);
return c;
};
Server.prototype.unloadChannel = function (chan, options) {
var self = this;
if (chan.dead || chan.dying) {
return;
}
chan.dying = true;
if (!options) {
options = {};
}
if (!options.skipSave) {
chan.saveState().catch(error => {
LOGGER.error(`Failed to save /${this.chanPath}/${chan.name} for unload: ${error.stack}`);
}).then(finishUnloading);
} else {
finishUnloading();
}
function finishUnloading() {
chan.logger.log("[init] Channel shutting down");
chan.logger.close();
chan.notifyModules("unload", []);
Object.keys(chan.modules).forEach(function (k) {
chan.modules[k].dead = true;
/*
* Automatically clean up any timeouts/intervals assigned
* to properties of channel modules. Prevents a memory leak
* in case of forgetting to clear the timer on the "unload"
* module event.
*/
Object.keys(chan.modules[k]).forEach(function (prop) {
if (chan.modules[k][prop] && chan.modules[k][prop]._onTimeout) {
LOGGER.warn("Detected non-null timer when unloading " +
"module " + k + ": " + prop);
try {
clearTimeout(chan.modules[k][prop]);
clearInterval(chan.modules[k][prop]);
} catch (error) {
LOGGER.error(error.stack);
}
}
});
});
for (var i = 0; i < self.channels.length; i++) {
if (self.channels[i].uniqueName === chan.uniqueName) {
self.channels.splice(i, 1);
i--;
}
}
LOGGER.info("Unloaded channel " + chan.name);
chan.broadcastUsercount.cancel();
// Empty all outward references from the channel
Object.keys(chan).forEach(key => {
if (key !== "refCounter") {
delete chan[key];
}
});
chan.dead = true;
promActiveChannels.dec();
}
};
Server.prototype.packChannelList = function (publicOnly, isAdmin) {
var channels = this.channels.filter(function (c) {
if (!publicOnly) {
return true;
}
return c.modules.options && c.modules.options.get("show_public");
});
return channels.map(function (c) {
return c.packInfo(isAdmin);
});
};
Server.prototype.announce = function (data) {
this.setAnnouncement(data);
if (data == null) {
db.clearAnnouncement();
} else {
db.setAnnouncement(data);
}
this.emit("announcement", data);
};
Server.prototype.setAnnouncement = function (data) {
if (data == null) {
this.announcement = null;
} else {
this.announcement = data;
sio.instance.emit("announcement", data);
}
};
Server.prototype.forceSave = function () {
Promise.map(this.channels, async channel => {
try {
await channel.saveState();
LOGGER.info(`Saved /${this.chanPath}/${channel.name}`);
} catch (error) {
LOGGER.error(
'Failed to save /%s/%s: %s',
this.chanPath,
channel ? channel.name : '<undefined>',
error.stack
);
}
}, { concurrency: 5 }).then(() => {
LOGGER.info('Finished save');
});
};
Server.prototype.shutdown = function () {
LOGGER.info("Unloading channels");
Promise.map(this.channels, async channel => {
try {
await channel.saveState();
LOGGER.info(`Saved /${this.chanPath}/${channel.name}`);
} catch (error) {
LOGGER.error(
'Failed to save /%s/%s: %s',
this.chanPath,
channel ? channel.name : '<undefined>',
error.stack
);
}
}, { concurrency: 5 }).then(() => {
LOGGER.info("Goodbye");
process.exit(0);
}).catch(err => {
LOGGER.error(`Caught error while saving channels: ${err.stack}`);
process.exit(1);
});
};
Server.prototype.handlePartitionMapChange = function () {
const channels = Array.prototype.slice.call(this.channels);
Promise.map(channels, async channel => {
if (channel.dead) {
return;
}
if (!this.partitionDecider.isChannelOnThisPartition(channel.uniqueName)) {
LOGGER.info("Partition changed for " + channel.uniqueName);
try {
await channel.saveState();
channel.broadcastAll(
"partitionChange",
this.partitionDecider.getPartitionForChannel(
channel.uniqueName
)
);
const users = Array.prototype.slice.call(channel.users);
users.forEach(u => {
try {
u.socket.disconnect();
} catch (error) {
// Ignore
}
});
this.unloadChannel(channel, { skipSave: true });
} catch (error) {
LOGGER.error(
'Failed to unload /%s/%s for partition map flip: %s',
this.chanPath,
channel ? channel.name : '<undefined>',
error.stack
);
}
}
}, { concurrency: 5 }).then(() => {
LOGGER.info("Partition reload complete");
});
};
Server.prototype.reloadPartitionMap = function () {
if (!Config.get("enable-partition")) {
return;
}
this.initModule.getPartitionMapReloader().reload();
};
Server.prototype.handleUserProfileChange = function (event) {
try {
const lname = event.user.toLowerCase();
// Probably not the most efficient thing in the world, but w/e
// profile changes are not high volume
this.channels.forEach(channel => {
if (channel.dead) return;
channel.users.forEach(user => {
if (user.getLowerName() === lname && user.account.user) {
user.account.user.profile = {
image: event.profile.image,
text: event.profile.text
};
user.account.update();
channel.sendUserProfile(channel.users, user);
LOGGER.info(
'Updated profile for user %s in channel %s',
lname,
channel.name
);
}
});
});
} catch (error) {
LOGGER.error('handleUserProfileChange failed: %s', error);
}
};
Server.prototype.handleChannelDelete = function (event) {
try {
const lname = event.channel.toLowerCase();
this.channels.forEach(channel => {
if (channel.dead) return;
if (channel.uniqueName === lname) {
channel.clearFlag(Flags.C_REGISTERED);
const users = Array.prototype.slice.call(channel.users);
users.forEach(u => {
u.kick('Channel deleted');
});
if (!channel.dead && !channel.dying) {
channel.emit('empty');
}
LOGGER.info('Processed deleted channel %s', lname);
}
});
} catch (error) {
LOGGER.error('handleChannelDelete failed: %s', error);
}
};
Server.prototype.handleChannelRegister = function (event) {
try {
const lname = event.channel.toLowerCase();
this.channels.forEach(channel => {
if (channel.dead) return;
if (channel.uniqueName === lname) {
channel.clearFlag(Flags.C_REGISTERED);
const users = Array.prototype.slice.call(channel.users);
users.forEach(u => {
u.kick('Channel reloading');
});
if (!channel.dead && !channel.dying) {
channel.emit('empty');
}
LOGGER.info('Processed registered channel %s', lname);
}
});
} catch (error) {
LOGGER.error('handleChannelRegister failed: %s', error);
}
};

55
src/servsock.js Normal file
View file

@ -0,0 +1,55 @@
var fs = require('fs');
var net = require('net');
/* eslint no-console: off */
export default class ServiceSocket {
constructor() {
this.connections = {};
}
init(handler, socket){
this.handler = handler;
this.socket = socket;
fs.stat(this.socket, (err, _stats) => {
if (err) {
return this.openServiceSocket();
}
fs.unlink(this.socket, (err) => {
if(err){
console.error(err); process.exit(0);
}
return this.openServiceSocket();
});
});
}
openServiceSocket(){
this.server = net.createServer((stream) => {
let id = Date.now();
this.connections[id] = stream;
stream.on('end', () => {
delete this.connections[id];
});
stream.on('data', (msg) => {
this.handler(msg.toString());
});
}).listen(this.socket);
process.on('exit', this.closeServiceSocket.bind(this));
}
closeServiceSocket() {
if(Object.keys(this.connections).length){
let clients = Object.keys(this.connections);
while(clients.length){
let client = clients.pop();
this.connections[client].write('__disconnect');
this.connections[client].end();
}
}
this.server.close();
}
}

51
src/session.js Normal file
View file

@ -0,0 +1,51 @@
var dbAccounts = require("./database/accounts");
var crypto = require("crypto");
function sha256(input) {
var hash = crypto.createHash("sha256");
hash.update(input);
return hash.digest("base64");
}
exports.genSession = function (account, expiration, cb) {
if (expiration instanceof Date) {
expiration = Date.parse(expiration);
}
var salt = crypto.pseudoRandomBytes(24).toString("base64");
var hashInput = [account.name, account.password, expiration, salt].join(":");
var hash = sha256(hashInput);
cb(null, [account.name, expiration, salt, hash, account.global_rank].join(":"));
};
exports.verifySession = function (input, cb) {
if (typeof input !== "string") {
return cb(new Error("Invalid auth string"));
}
var parts = input.split(":");
if (parts.length !== 4 && parts.length !== 5) {
return cb(new Error("Invalid auth string"));
}
const [name, expiration, salt, hash, _global_rank] = parts;
if (Date.now() > parseInt(expiration, 10)) {
return cb(new Error("Session expired"));
}
dbAccounts.getUser(name, function (err, account) {
if (err) {
if (!(err instanceof Error)) err = new Error(err);
return cb(err);
}
var hashInput = [account.name, account.password, expiration, salt].join(":");
if (sha256(hashInput) !== hash) {
return cb(new Error("Invalid auth string"));
}
cb(null, account);
});
};

52
src/setuid.js Normal file
View file

@ -0,0 +1,52 @@
var Config = require("./config");
var fs = require("fs");
var path = require("path");
var execSync = require("child_process").execSync;
const LOGGER = require('@calzoneman/jsli')('setuid');
var needPermissionsFixed = [
path.join(__dirname, "..", "chanlogs"),
path.join(__dirname, "..", "google-drive-subtitles")
];
function fixPermissions(user, group) {
var uid = resolveUid(user);
var gid = resolveGid(group);
needPermissionsFixed.forEach(function (dir) {
if (fs.existsSync(dir)) {
fs.chownSync(dir, uid, gid);
}
});
}
function resolveUid(user) {
return parseInt(execSync('id -u ' + user), 10);
}
function resolveGid(group) {
return parseInt(execSync('id -g ' + group), 10);
}
if (Config.get("setuid.enabled")) {
setTimeout(function() {
try {
fixPermissions(Config.get("setuid.user"), Config.get("setuid.group"));
LOGGER.info(
'Old User ID: %s, Old Group ID: %s',
process.getuid(),
process.getgid()
);
process.setgid(Config.get("setuid.group"));
process.setuid(Config.get("setuid.user"));
LOGGER.info(
'New User ID: %s, New Group ID: %s',
process.getuid(),
process.getgid()
);
} catch (err) {
LOGGER.error('Error setting uid: %s', err.stack);
process.exit(1);
}
}, (Config.get("setuid.timeout")));
}

10
src/switches.js Normal file
View file

@ -0,0 +1,10 @@
const switches = {
};
export function isActive(switchName) {
return switches.hasOwnProperty(switchName) && switches[switchName] === true;
}
export function setActive(switchName, active) {
switches[switchName] = active;
}

86
src/tor.js Normal file
View file

@ -0,0 +1,86 @@
import https from 'https';
import path from 'path';
import fs from 'fs';
import Promise from 'bluebird';
Promise.promisifyAll(fs);
const LOGGER = require('@calzoneman/jsli')('torlist');
const TOR_EXIT_LIST_URL = 'https://check.torproject.org/exit-addresses';
const TOR_EXIT_LIST_FILE = path.join(__dirname, '..', 'tor-exit-list.json');
const ONE_DAY = 24 * 3600 * 1000;
const TOR_EXIT_IPS = new Set();
function loadTorList() {
return fs.statAsync(TOR_EXIT_LIST_FILE).then(stats => {
if (new Date() - stats.mtime > ONE_DAY) {
LOGGER.info('Tor exit node list is older than 24h, re-downloading from %s',
TOR_EXIT_LIST_URL);
return loadTorListFromWebsite();
} else {
return loadTorListFromFile();
}
}).catch(error => {
if (error.code === 'ENOENT') {
LOGGER.info('File %s not found, downloading from %s',
TOR_EXIT_LIST_FILE,
TOR_EXIT_LIST_URL);
return loadTorListFromWebsite();
} else {
throw error;
}
});
}
function loadTorListFromWebsite() {
return new Promise((resolve, reject) => {
https.get(TOR_EXIT_LIST_URL, res => {
if (res.statusCode !== 200) {
reject(new Error(`${res.statusCode} ${res.statusMessage}`));
return;
}
let buffer = '';
res.on('data', data => buffer += data);
res.on('end', () => {
const exitNodes = buffer.split('\n').filter(line => {
return /^ExitAddress/.test(line);
}).map(line => {
return line.split(' ')[1];
});
fs.writeFileAsync(TOR_EXIT_LIST_FILE, JSON.stringify(exitNodes))
.then(() => {
LOGGER.info('Saved %s', TOR_EXIT_LIST_FILE);
}).catch(error => {
LOGGER.error('Unable to save %s: %s',
TOR_EXIT_LIST_FILE,
error.message);
});
resolve(exitNodes);
});
}).on('error', error => {
reject(error);
});
});
}
function loadTorListFromFile() {
LOGGER.info('Loading Tor exit list from %s', TOR_EXIT_LIST_FILE);
return fs.readFileAsync(TOR_EXIT_LIST_FILE).then(contents => {
return JSON.parse(String(contents));
});
}
loadTorList().then(exits => {
TOR_EXIT_IPS.clear();
exits.forEach(exit => {
TOR_EXIT_IPS.add(exit);
});
}).catch(error => {
LOGGER.error('Unable to load Tor exit list: %s', error.stack);
});
export function isTorExit(ip) {
return TOR_EXIT_IPS.has(ip);
}

181
src/ullist.js Normal file
View file

@ -0,0 +1,181 @@
/*
ullist.js
Description: Defines ULList, which represents a doubly linked list
in which each item has a unique identifier stored in the `uid` field.
*/
function ULList() {
this.first = null;
this.last = null;
this.length = 0;
}
/* Add an item to the beginning of the list */
ULList.prototype.prepend = function(item) {
if(this.first !== null) {
item.next = this.first;
this.first.prev = item;
} else {
this.last = item;
}
this.first = item;
this.first.prev = null;
this.length++;
return true;
};
/* Add an item to the end of the list */
ULList.prototype.append = function(item) {
if(this.last !== null) {
item.prev = this.last;
this.last.next = item;
} else {
this.first = item;
}
this.last = item;
this.last.next = null;
this.length++;
return true;
};
/* Insert an item after one which has a specified UID */
ULList.prototype.insertAfter = function(item, uid) {
var after = this.find(uid);
if(!after)
return false;
// Update links
item.next = after.next;
if(item.next)
item.next.prev = item;
item.prev = after;
after.next = item;
// New end of list
if(after == this.last)
this.last = item;
this.length++;
return true;
};
/* Insert an item before one that has a specified UID */
ULList.prototype.insertBefore = function(item, uid) {
var before = this.find(uid);
if(!before)
return false;
// Update links
item.next = before;
item.prev = before.prev;
if(item.prev)
item.prev.next = item;
before.prev = item;
// New beginning of list
if(before == this.first)
this.first = item;
this.length++;
return true;
};
/* Remove an item from the list */
ULList.prototype.remove = function(uid) {
var item = this.find(uid);
if(!item)
return false;
// Boundary conditions
if(item == this.first)
this.first = item.next;
if(item == this.last)
this.last = item.prev;
// General case
if(item.prev)
item.prev.next = item.next;
if(item.next)
item.next.prev = item.prev;
this.length--;
return true;
};
/* Find an element in the list, return false if specified UID not found */
ULList.prototype.find = function(uid) {
// Can't possibly find it in an empty list
if(this.first === null)
return false;
var item = this.first;
var iter = this.first;
while(iter !== null && item.uid != uid) {
item = iter;
iter = iter.next;
}
if(item && item.uid == uid)
return item;
return false;
};
/* Clear all elements from the list */
ULList.prototype.clear = function() {
this.first = null;
this.last = null;
this.length = 0;
};
/* Dump the contents of the list into an array */
ULList.prototype.toArray = function(pack) {
var arr = new Array(this.length);
var item = this.first;
var i = 0;
while(item !== null) {
if(pack !== false && typeof item.pack == "function")
arr[i++] = item.pack();
else
arr[i++] = item;
item = item.next;
}
return arr;
};
/* iterate across the playlist */
ULList.prototype.forEach = function (fn) {
var item = this.first;
while(item !== null) {
fn(item);
item = item.next;
}
};
/* find a media with the given video id */
ULList.prototype.findVideoId = function (id) {
var item = this.first;
while(item !== null) {
if(item.media && item.media.id === id)
return item;
item = item.next;
}
return false;
};
ULList.prototype.findAll = function(fn) {
var result = [];
this.forEach(function(item) {
if( fn(item) ) {
result.push(item);
}
});
return result;
};
module.exports = ULList;

499
src/user.js Normal file
View file

@ -0,0 +1,499 @@
var Server = require("./server");
var util = require("./utilities");
var db = require("./database");
var Config = require("./config");
var ACP = require("./acp");
var Account = require("./account");
var Flags = require("./flags");
import { EventEmitter } from 'events';
import Logger from './logger';
import net from 'net';
const LOGGER = require('@calzoneman/jsli')('user');
function User(socket, ip, loginInfo) {
this.flags = 0;
this.socket = socket;
// Expanding IPv6 addresses shouldn't really be necessary
// At some point, the IPv6 related stuff should be revisited
this.realip = net.isIPv6(ip) ? util.expandIPv6(ip) : ip;
this.displayip = util.cloakIP(this.realip);
this.channel = null;
this.queueLimiter = util.newRateLimiter();
this.chatLimiter = util.newRateLimiter();
this.reqPlaylistLimiter = util.newRateLimiter();
this.awaytimer = false;
if (loginInfo) {
this.account = new Account.Account(this.realip, loginInfo, socket.context.aliases);
this.registrationTime = new Date(this.account.user.time);
this.setFlag(Flags.U_REGISTERED | Flags.U_LOGGED_IN | Flags.U_READY);
socket.emit("login", {
success: true,
name: this.getName(),
guest: false
});
socket.emit("rank", this.account.effectiveRank);
if (this.account.globalRank >= 255) {
this.initAdminCallbacks();
}
this.emit("login", this.account);
LOGGER.info(ip + " logged in as " + this.getName());
} else {
this.account = new Account.Account(this.realip, null, socket.context.aliases);
socket.emit("rank", -1);
this.setFlag(Flags.U_READY);
this.once("login", account => {
if (account.globalRank >= 255) {
this.initAdminCallbacks();
}
});
}
socket.once("joinChannel", data => this.handleJoinChannel(data));
socket.once("initACP", () => this.handleInitACP());
socket.on("login", data => this.handleLogin(data));
}
User.prototype = Object.create(EventEmitter.prototype);
User.prototype.handleJoinChannel = function handleJoinChannel(data) {
if (typeof data !== "object" || typeof data.name !== "string") {
return;
}
if (this.inChannel()) {
return;
}
if (!util.isValidChannelName(data.name)) {
this.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 _"
});
this.kick("Invalid channel name");
return;
}
data.name = data.name.toLowerCase();
if (data.name in Config.get("channel-blacklist")) {
this.kick("This channel is blacklisted.");
return;
}
this.waitFlag(Flags.U_READY, () => {
var chan;
try {
chan = Server.getServer().getChannel(data.name);
} catch (error) {
if (error.code === 'EWRONGPART') {
this.socket.emit("errorMsg", {
msg: "Channel '" + data.name + "' is hosted on another server. " +
"Try refreshing the page to update the connection URL."
});
} else {
LOGGER.error("Unexpected error from getChannel(): %s", error.stack);
this.socket.emit("errorMsg", {
msg: "Unable to join channel due to an internal error"
});
}
return;
}
if (!chan.is(Flags.C_READY)) {
chan.once("loadFail", reason => {
this.socket.emit("errorMsg", {
msg: reason,
alert: true
});
this.kick(`Channel could not be loaded: ${reason}`);
});
}
chan.joinUser(this, data);
});
};
User.prototype.handleInitACP = function handleInitACP() {
this.waitFlag(Flags.U_LOGGED_IN, () => {
if (this.account.globalRank >= 255) {
ACP.init(this);
} else {
this.kick("Attempted initACP from non privileged user. This incident " +
"will be reported.");
Logger.eventlog.log("[acp] Attempted initACP from socket client " +
this.getName() + "@" + this.realip);
}
});
};
User.prototype.handleLogin = function handleLogin(data) {
if (typeof data !== "object") {
this.socket.emit("errorMsg", {
msg: "Invalid login frame"
});
return;
}
var name = data.name;
if (typeof name !== "string") {
return;
}
var pw = data.pw || "";
if (typeof pw !== "string") {
pw = "";
}
if (this.is(Flags.U_LOGGING_IN) || this.is(Flags.U_LOGGED_IN)) {
return;
}
if (!pw) {
this.guestLogin(name);
} else {
this.login(name, pw);
}
};
User.prototype.die = function () {
for (const key in this.socket._events) {
delete this.socket._events[key];
}
delete this.socket.typecheckedOn;
delete this.socket.typecheckedOnce;
for (const 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) {
self.removeListener("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;
};
User.prototype.inRegisteredChannel = function () {
return this.inChannel() && this.channel.is(Flags.C_REGISTERED);
};
/* Called when a user's AFK status changes */
User.prototype.setAFK = function (afk) {
if (!this.inChannel()) {
return;
}
/* No change in AFK status, don't need to change anything */
if (this.is(Flags.U_AFK) === afk) {
this.autoAFK();
return;
}
if (afk) {
this.setFlag(Flags.U_AFK);
if (this.channel.modules.voteskip) {
this.channel.modules.voteskip.unvote(this.realip);
this.socket.emit("clearVoteskipVote");
}
} else {
this.clearFlag(Flags.U_AFK);
this.autoAFK();
}
if (!this.inChannel()) {
/*
* In unusual circumstances, the above emit("clearVoteskipVote")
* can cause the "disconnect" event to be fired synchronously,
* which results in this user no longer being in the channel.
*/
return;
}
/* Number of AFK users changed, voteskip state changes */
if (this.channel.modules.voteskip) {
this.channel.modules.voteskip.update();
}
this.emit('afk', afk);
};
/* 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() || !self.channel.modules.options) {
return;
}
/* 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;
}
self.awaytimer = setTimeout(function () {
self.setAFK(true);
}, timeout * 1000);
};
User.prototype.kick = function (reason) {
LOGGER.info(
'%s (%s) was kicked: "%s"',
this.realip,
this.getName(),
reason
);
this.socket.emit("kick", { reason: reason });
this.socket.disconnect();
};
User.prototype.isAnonymous = function(){
var self = this;
return !self.is(Flags.U_LOGGED_IN);
};
User.prototype.initAdminCallbacks = function () {
var self = this;
self.socket.on("borrow-rank", function (rank) {
if (self.inChannel()) {
if (typeof rank !== "number") {
return;
}
if (rank > self.account.globalRank) {
return;
}
if (rank === 255 && self.account.globalRank > 255) {
rank = self.account.globalRank;
}
self.account.channelRank = rank;
self.account.effectiveRank = rank;
self.socket.emit("rank", rank);
self.channel.broadcastAll("setUserRank", {
name: self.getName(),
rank: rank
});
}
});
};
User.prototype.login = function (name, pw) {
var self = this;
self.setFlag(Flags.U_LOGGING_IN);
db.users.verifyLogin(name, pw, function (err, user) {
if (err) {
if (err === "Invalid username/password combination") {
Logger.eventlog.log("[loginfail] Login failed (bad password): " + name
+ "@" + self.realip);
}
self.socket.emit("login", {
success: false,
error: err
});
self.clearFlag(Flags.U_LOGGING_IN);
return;
}
const oldRank = self.account.effectiveRank;
self.account.user = user;
self.account.update();
self.socket.emit("rank", self.account.effectiveRank);
self.emit("effectiveRankChange", self.account.effectiveRank, oldRank);
self.registrationTime = new Date(user.time);
self.setFlag(Flags.U_REGISTERED);
self.socket.emit("login", {
success: true,
name: user.name
});
db.recordVisit(self.realip, self.getName());
LOGGER.info(self.realip + " logged in as " + user.name);
self.setFlag(Flags.U_LOGGED_IN);
self.clearFlag(Flags.U_LOGGING_IN);
self.emit("login", self.account);
});
};
var lastguestlogin = {};
User.prototype.guestLogin = function (name) {
var self = this;
if (self.realip in lastguestlogin) {
var diff = (Date.now() - lastguestlogin[self.realip]) / 1000;
if (diff < Config.get("guest-login-delay")) {
self.socket.emit("login", {
success: false,
error: "Guest logins are restricted to one per IP address per " +
Config.get("guest-login-delay") + " seconds."
});
return;
}
}
if (!util.isValidUserName(name)) {
self.socket.emit("login", {
success: false,
error: "Invalid username. Usernames must be 1-20 characters long and " +
"consist only of characters a-z, A-Z, 0-9, -, or _."
});
return;
}
if (name.match(Config.get("reserved-names.usernames"))) {
LOGGER.warn(
'Rejecting attempt by %s to use reserved username "%s"',
self.realip,
name
);
self.socket.emit("login", {
success: false,
error: "That username is reserved."
});
return;
}
// Prevent duplicate logins
self.setFlag(Flags.U_LOGGING_IN);
db.users.isUsernameTaken(name, function (err, taken) {
self.clearFlag(Flags.U_LOGGING_IN);
if (err) {
self.socket.emit("login", {
success: false,
error: err
});
return;
}
if (taken) {
self.socket.emit("login", {
success: false,
error: "That username is registered."
});
return;
}
if (self.inChannel()) {
var nameLower = name.toLowerCase();
for (var i = 0; i < self.channel.users.length; i++) {
if (self.channel.users[i].getLowerName() === nameLower) {
self.socket.emit("login", {
success: false,
error: "That name is already in use on this channel."
});
return;
}
}
}
// Login succeeded
lastguestlogin[self.realip] = Date.now();
const oldRank = self.account.effectiveRank;
self.account.guestName = name;
self.account.update();
self.socket.emit("rank", self.account.effectiveRank);
self.emit("effectiveRankChange", self.account.effectiveRank, oldRank);
self.socket.emit("login", {
success: true,
name: name,
guest: true
});
db.recordVisit(self.realip, self.getName());
LOGGER.info(self.realip + " signed in as " + name);
self.setFlag(Flags.U_LOGGED_IN);
self.emit("login", self.account);
});
};
/* Clean out old login throttlers to save memory */
setInterval(function () {
var delay = Config.get("guest-login-delay");
for (var ip in lastguestlogin) {
var diff = (Date.now() - lastguestlogin[ip]) / 1000;
if (diff > delay) {
delete lastguestlogin[ip];
}
}
if (Config.get("aggressive-gc") && global && global.gc) {
global.gc();
}
}, 5 * 60 * 1000);
User.prototype.getFirstSeenTime = function getFirstSeenTime() {
if (this.registrationTime && this.socket.context.ipSessionFirstSeen) {
return Math.min(
this.registrationTime.getTime(),
this.socket.context.ipSessionFirstSeen.getTime()
);
} else if (this.registrationTime) {
return this.registrationTime.getTime();
} else if (this.socket.context.ipSessionFirstSeen) {
return this.socket.context.ipSessionFirstSeen.getTime();
} else {
LOGGER.error(`User "${this.getName()}" (IP: ${this.realip}) has neither ` +
"an IP session first seen time nor a registered account.");
return Date.now();
}
};
User.prototype.setChannelRank = function setRank(rank) {
const oldRank = this.account.effectiveRank;
const changed = oldRank !== rank;
this.account.channelRank = rank;
this.account.update();
this.socket.emit("rank", this.account.effectiveRank);
if (changed) {
this.emit("effectiveRankChange", this.account.effectiveRank, oldRank);
}
};
module.exports = User;

11
src/util/ack.js Normal file
View file

@ -0,0 +1,11 @@
export function ackOrErrorMsg(ack, user) {
if (typeof ack === 'function') {
return ack;
}
return (result) => {
if (result.error) {
user.socket.emit('errorMsg', { msg: result.error.message });
}
};
}

7
src/util/call-once.js Normal file
View file

@ -0,0 +1,7 @@
export function callOnce(fn) {
let called = false;
return (...args) => {
called || fn(...args), called = true;
};
}

7
src/util/hash.js Normal file
View file

@ -0,0 +1,7 @@
import { createHash } from 'crypto';
export function hash(algo, input, digest) {
const h = createHash(algo);
h.update(input);
return h.digest(digest);
}

37
src/util/murmur.js Normal file
View file

@ -0,0 +1,37 @@
const SEED = 0x1234;
const M = 0xc6a4a793;
const R = 16;
/* eslint no-fallthrough: off */
export function murmurHash1(str) {
const buffer = new Buffer(str, 'utf8');
var length = buffer.length;
var h = SEED ^ (length * M);
while (length >= 4) {
var k = buffer.readUInt32LE(buffer.length - length);
h += k;
h *= M;
h ^= h >> 16;
length -= 4;
}
switch (length) {
case 3:
h += buffer[buffer.length - 3] >> 16;
case 2:
h += buffer[buffer.length - 2] >> 8;
case 1:
h += buffer[buffer.length - 1];
h *= M;
h ^= h >> R;
}
h *= M;
h ^= h >> 10;
h *= M;
h ^= h >> 17;
return h;
}

View file

@ -0,0 +1,7 @@
export function createMySQLDuplicateKeyUpdate(columns) {
const prefix = ' on duplicate key update ';
const updates = columns.map(col => `\`${col}\` = values(\`${col}\`)`)
.join(', ');
return prefix + updates;
}

9
src/util/throttle.js Normal file
View file

@ -0,0 +1,9 @@
import lo from 'lodash';
export function throttle(fn, timeout) {
return lo.debounce(fn, timeout, {
leading: true,
trailing: true,
maxWait: timeout
});
}

37
src/util/token-bucket.js Normal file
View file

@ -0,0 +1,37 @@
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.lastRefill = Date.now();
}
throttle() {
const now = Date.now();
const delta = Math.floor(
(now - this.lastRefill) / 1000 * this.refillRate()
);
if (delta > 0) {
this.count = Math.min(this.capacity(), this.count + delta);
this.lastRefill = now;
}
if (this.count === 0) {
return true;
} else {
this.count--;
return false;
}
}
}
export { TokenBucket };

285
src/utilities.js Normal file
View file

@ -0,0 +1,285 @@
(function () {
const root = module.exports;
const net = require("net");
const crypto = require("crypto");
root.isValidChannelName = function (name) {
return name.match(/^[\w-]{1,30}$/);
},
root.isValidUserName = function (name) {
return name.match(/^[\w-]{1,20}$/);
},
root.isValidEmail = function (email) {
if (typeof email !== "string") {
return false;
}
if (email.length > 255) {
return false;
}
if (!email.match(/^[^@]+?@[^@]+$/)) {
return false;
}
if (email.match(/^[^@]+?@(localhost|127\.0\.0\.1)$/)) {
return false;
}
return true;
},
root.randomSalt = function (length) {
var chars = "abcdefgihjklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ "0123456789!@#$%^&*_+=~";
var salt = [];
for(var i = 0; i < length; i++) {
salt.push(chars[parseInt(Math.random()*chars.length)]);
}
return salt.join('');
},
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;
sec = Math.floor(+sec);
var h = "", m = "", s = "";
if(sec >= 3600) {
h = "" + Math.floor(sec / 3600);
if(h.length < 2)
h = "0" + h;
sec %= 3600;
}
m = "" + Math.floor(sec / 60);
if(m.length < 2)
m = "0" + m;
s = "" + (sec % 60);
if(s.length < 2)
s = "0" + s;
if(h === "")
return [m, s].join(":");
return [h, m, s].join(":");
},
root.parseTime = function (time) {
var parts = time.split(":").reverse();
var seconds = 0;
// TODO: consider refactoring to remove this suppression
/* eslint no-fallthrough: off */
switch (parts.length) {
case 3:
seconds += parseInt(parts[2]) * 3600;
case 2:
seconds += parseInt(parts[1]) * 60;
case 1:
seconds += parseInt(parts[0]);
break;
default:
break;
}
return seconds;
},
root.newRateLimiter = function () {
return {
count: 0,
lastTime: 0,
throttle: function (opts) {
if (typeof opts === "undefined")
opts = {};
var burst = +opts.burst,
sustained = +opts.sustained,
cooldown = +opts.cooldown;
if (isNaN(burst))
burst = 10;
if (isNaN(sustained))
sustained = 2;
if (isNaN(cooldown))
cooldown = burst / sustained;
// Cooled down, allow and clear buffer
if (this.lastTime < Date.now() - cooldown*1000) {
this.count = 1;
this.lastTime = Date.now();
return false;
}
// Haven't reached burst cap yet, allow
if (this.count < burst) {
this.count++;
this.lastTime = Date.now();
return false;
}
var diff = Date.now() - this.lastTime;
if (diff < 1000/sustained)
return true;
this.lastTime = Date.now();
return false;
}
};
},
root.formatLink = function (id, type, _meta) {
switch (type) {
case "yt":
return "https://youtu.be/" + id;
case "vi":
return "https://vimeo.com/" + id;
case "dm":
return "https://dailymotion.com/video/" + id;
case "sc":
return id;
case "li":
return "https://livestream.com/" + id;
case "tw":
return "https://twitch.tv/" + id;
case "rt":
return id;
case "us":
return "https://ustream.tv/channel/" + id;
case "gd":
return "https://docs.google.com/file/d/" + id;
case "fi":
return id;
case "hb":
return "https://www.smashcast.tv/" + id;
case "hl":
return id;
case "sb":
return "https://streamable.com/" + id;
case "tc":
return "https://clips.twitch.tv/" + id;
case "cm":
return id;
default:
return "";
}
},
root.isLive = function (type) {
switch (type) {
case "li":
case "tw":
case "us":
case "rt":
case "cu":
case "hb":
case "hl":
return true;
default:
return false;
}
},
root.sha1 = function (data) {
if (!crypto) {
return "";
}
var shasum = crypto.createHash("sha1");
shasum.update(data);
return shasum.digest("hex");
},
root.cloakIP = function (ip) {
if (ip.match(/\d+\.\d+(\.\d+)?(\.\d+)?/)) {
return cloakIPv4(ip);
} else if (ip.match(/([0-9a-f]{1,4}:){1,7}[0-9a-f]{1,4}/)) {
return cloakIPv6(ip);
} else {
return ip;
}
function iphash(data, len) {
var md5 = crypto.createHash("md5");
md5.update(data);
return md5.digest("base64").substring(0, len);
}
function cloakIPv4(ip) {
var parts = ip.split(".");
var accumulator = "";
parts = parts.map(function (segment, i) {
var part = iphash(accumulator + segment + i, 3);
accumulator += segment;
return part;
});
while (parts.length < 4) parts.push("*");
return parts.join(".");
}
function cloakIPv6(ip) {
var parts = ip.split(":");
parts.splice(4, 4);
var accumulator = "";
parts = parts.map(function (segment, i) {
var part = iphash(accumulator + segment + i, 4);
accumulator += segment;
return part;
});
while (parts.length < 4) parts.push("*");
return parts.join(":");
}
};
})();

748
src/web/account.js Normal file
View file

@ -0,0 +1,748 @@
/**
* web/account.js - Webserver details for account management
*
* @author Calvin Montgomery <cyzon@cyzon.us>
*/
var webserver = require("./webserver");
var sendPug = require("./pug").sendPug;
var Logger = require("../logger");
var db = require("../database");
var $util = require("../utilities");
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');
let globalMessageBus;
let emailConfig;
let emailController;
/**
* Handles a GET request for /account/edit
*/
function handleAccountEditPage(req, res) {
sendPug(res, "account-edit", {});
}
function verifyReferrer(req, expected) {
const referrer = req.header('referer');
if (!referrer) {
return true;
}
try {
const parsed = url.parse(referrer);
if (parsed.pathname !== expected) {
LOGGER.warn(
'Possible attempted forgery: %s POSTed to %s',
referrer,
expected
);
return false;
}
return true;
} catch (error) {
return false;
}
}
/**
* Handles a POST request to edit a user"s account
*/
function handleAccountEdit(req, res) {
csrf.verify(req);
if (!verifyReferrer(req, '/account/edit')) {
res.status(403).send('Mismatched referrer');
return;
}
var action = req.body.action;
switch(action) {
case "change_password":
handleChangePassword(req, res);
break;
case "change_email":
handleChangeEmail(req, res);
break;
default:
res.sendStatus(400);
break;
}
}
/**
* Handles a request to change the user"s password
*/
async function handleChangePassword(req, res) {
var name = req.body.name;
var oldpassword = req.body.oldpassword;
var newpassword = req.body.newpassword;
if (typeof name !== "string" ||
typeof oldpassword !== "string" ||
typeof newpassword !== "string") {
res.send(400);
return;
}
if (newpassword.length === 0) {
sendPug(res, "account-edit", {
errorMessage: "New password must not be empty"
});
return;
}
const reqUser = await webserver.authorize(req);
if (!reqUser) {
sendPug(res, "account-edit", {
errorMessage: "You must be logged in to change your password"
});
return;
}
newpassword = newpassword.substring(0, 100);
db.users.verifyLogin(name, oldpassword, function (err, _user) {
if (err) {
sendPug(res, "account-edit", {
errorMessage: err
});
return;
}
db.users.setPassword(name, newpassword, function (err, _dbres) {
if (err) {
sendPug(res, "account-edit", {
errorMessage: err
});
return;
}
Logger.eventlog.log("[account] " + req.realIP +
" changed password for " + name);
db.users.getUser(name, function (err, user) {
if (err) {
return sendPug(res, "account-edit", {
errorMessage: err
});
}
var expiration = new Date(parseInt(req.signedCookies.auth.split(":")[1]));
session.genSession(user, expiration, function (err, auth) {
if (err) {
return sendPug(res, "account-edit", {
errorMessage: err
});
}
webserver.setAuthCookie(req, res, expiration, auth);
sendPug(res, "account-edit", {
successMessage: "Password changed."
});
});
});
});
});
}
/**
* Handles a request to change the user"s email
*/
function handleChangeEmail(req, res) {
var name = req.body.name;
var password = req.body.password;
var email = req.body.email;
if (typeof name !== "string" ||
typeof password !== "string" ||
typeof email !== "string") {
res.send(400);
return;
}
if (!$util.isValidEmail(email) && email !== "") {
sendPug(res, "account-edit", {
errorMessage: "Invalid email address"
});
return;
}
db.users.verifyLogin(name, password, function (err, _user) {
if (err) {
sendPug(res, "account-edit", {
errorMessage: err
});
return;
}
db.users.setEmail(name, email, function (err, _dbres) {
if (err) {
sendPug(res, "account-edit", {
errorMessage: err
});
return;
}
Logger.eventlog.log("[account] " + req.realIP +
" changed email for " + name +
" to " + email);
sendPug(res, "account-edit", {
successMessage: "Email address changed."
});
});
});
}
/**
* Handles a GET request for /account/channels
*/
async function handleAccountChannelPage(req, res) {
const user = await webserver.authorize(req);
// TODO: error message
if (!user) {
return sendPug(res, "account-channels", {
channels: []
});
}
db.channels.listUserChannels(user.name, function (err, channels) {
sendPug(res, "account-channels", {
channels: channels
});
});
}
/**
* Handles a POST request to modify a user"s channels
*/
function handleAccountChannel(req, res) {
csrf.verify(req);
if (!verifyReferrer(req, '/account/channels')) {
res.status(403).send('Mismatched referrer');
return;
}
var action = req.body.action;
switch(action) {
case "new_channel":
handleNewChannel(req, res);
break;
case "delete_channel":
handleDeleteChannel(req, res);
break;
default:
res.send(400);
break;
}
}
/**
* Handles a request to register a new channel
*/
async function handleNewChannel(req, res) {
var name = req.body.name;
if (typeof name !== "string") {
res.send(400);
return;
}
const user = await webserver.authorize(req);
// TODO: error message
if (!user) {
return sendPug(res, "account-channels", {
channels: []
});
}
db.channels.listUserChannels(user.name, function (err, channels) {
if (err) {
sendPug(res, "account-channels", {
channels: [],
newChannelError: err
});
return;
}
if (name.match(Config.get("reserved-names.channels"))) {
sendPug(res, "account-channels", {
channels: channels,
newChannelError: "That channel name is reserved"
});
return;
}
if (channels.length >= Config.get("max-channels-per-user")
&& user.global_rank < 255) {
sendPug(res, "account-channels", {
channels: channels,
newChannelError: "You are not allowed to register more than " +
Config.get("max-channels-per-user") + " channels."
});
return;
}
db.channels.register(name, user.name, function (err, _channel) {
if (!err) {
Logger.eventlog.log("[channel] " + user.name + "@" +
req.realIP +
" registered channel " + name);
globalMessageBus.emit('ChannelRegistered', {
channel: name
});
channels.push({
name: name
});
}
sendPug(res, "account-channels", {
channels: channels,
newChannelError: err ? err : undefined
});
});
});
}
/**
* Handles a request to delete a new channel
*/
async function handleDeleteChannel(req, res) {
var name = req.body.name;
if (typeof name !== "string") {
res.send(400);
return;
}
const user = await webserver.authorize(req);
// TODO: error
if (!user) {
return sendPug(res, "account-channels", {
channels: [],
});
}
db.channels.lookup(name, function (err, channel) {
if (err) {
sendPug(res, "account-channels", {
channels: [],
deleteChannelError: err
});
return;
}
if ((!channel.owner || channel.owner.toLowerCase() !== user.name.toLowerCase()) && user.global_rank < 255) {
db.channels.listUserChannels(user.name, function (err2, channels) {
sendPug(res, "account-channels", {
channels: err2 ? [] : channels,
deleteChannelError: "You do not have permission to delete this channel"
});
});
return;
}
db.channels.drop(name, function (err) {
if (!err) {
Logger.eventlog.log("[channel] " + user.name + "@" +
req.realIP + " deleted channel " +
name);
}
globalMessageBus.emit('ChannelDeleted', {
channel: name
});
db.channels.listUserChannels(user.name, function (err2, channels) {
sendPug(res, "account-channels", {
channels: err2 ? [] : channels,
deleteChannelError: err ? err : undefined
});
});
});
});
}
/**
* Handles a GET request for /account/profile
*/
async function handleAccountProfilePage(req, res) {
const user = await webserver.authorize(req);
// TODO: error message
if (!user) {
return sendPug(res, "account-profile", {
profileImage: "",
profileText: ""
});
}
db.users.getProfile(user.name, function (err, profile) {
if (err) {
sendPug(res, "account-profile", {
profileError: err,
profileImage: "",
profileText: ""
});
return;
}
sendPug(res, "account-profile", {
profileImage: profile.image,
profileText: profile.text,
profileError: false
});
});
}
function validateProfileImage(image, callback) {
var prefix = "Invalid URL for profile image: ";
var link = image.trim();
if (!link) {
process.nextTick(callback, null, link);
} else {
var data = url.parse(link);
if (!data.protocol || data.protocol !== 'https:') {
process.nextTick(callback,
new Error(prefix + " URL must begin with 'https://'"));
} else if (!data.host) {
process.nextTick(callback,
new Error(prefix + "missing hostname"));
} else {
process.nextTick(callback, null, link);
}
}
}
/**
* Handles a POST request to edit a profile
*/
async function handleAccountProfile(req, res) {
csrf.verify(req);
if (!verifyReferrer(req, '/account/profile')) {
res.status(403).send('Mismatched referrer');
return;
}
const user = await webserver.authorize(req);
// TODO: error message
if (!user) {
return sendPug(res, "account-profile", {
profileImage: "",
profileText: "",
profileError: "You must be logged in to edit your profile",
});
}
var rawImage = String(req.body.image).substring(0, 255);
var text = String(req.body.text).substring(0, 255);
validateProfileImage(rawImage, (error, image) => {
if (error) {
db.users.getProfile(user.name, function (err, profile) {
var errorMessage = err || error.message;
sendPug(res, "account-profile", {
profileImage: profile ? profile.image : "",
profileText: profile ? profile.text : "",
profileError: errorMessage
});
});
return;
}
db.users.setProfile(user.name, { image: image, text: text }, function (err) {
if (err) {
sendPug(res, "account-profile", {
profileImage: "",
profileText: "",
profileError: err
});
return;
}
globalMessageBus.emit('UserProfileChanged', {
user: user.name,
profile: {
image,
text
}
});
sendPug(res, "account-profile", {
profileImage: image,
profileText: text,
profileError: false
});
});
});
}
/**
* Handles a GET request for /account/passwordreset
*/
function handlePasswordResetPage(req, res) {
sendPug(res, "account-passwordreset", {
reset: false,
resetEmail: "",
resetErr: false
});
}
/**
* Handles a POST request to reset a user's password
*/
function handlePasswordReset(req, res) {
csrf.verify(req);
if (!verifyReferrer(req, '/account/passwordreset')) {
res.status(403).send('Mismatched referrer');
return;
}
var name = req.body.name,
email = req.body.email;
if (typeof name !== "string" || typeof email !== "string") {
res.send(400);
return;
}
if (!$util.isValidUserName(name)) {
sendPug(res, "account-passwordreset", {
reset: false,
resetEmail: "",
resetErr: "Invalid username '" + name + "'"
});
return;
}
db.users.getEmail(name, function (err, actualEmail) {
if (err) {
sendPug(res, "account-passwordreset", {
reset: false,
resetEmail: "",
resetErr: err
});
return;
}
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;
}
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: email,
resetErr: "Internal error when generating password reset"
});
return;
}
var hash = bytes.toString('hex');
// 24-hour expiration
var expire = Date.now() + 86400000;
var ip = req.realIP;
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;
}
Logger.eventlog.log("[account] " + ip + " requested password recovery for " +
name + " <" + email + ">");
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."
});
});
});
});
});
}
/**
* Handles a request for /account/passwordrecover/<hash>
*/
function handleGetPasswordRecover(req, res) {
var hash = req.params.hash;
if (typeof hash !== "string") {
res.send(400);
return;
}
db.lookupPasswordReset(hash, function (err, row) {
if (err) {
sendPug(res, "account-passwordrecover", {
recovered: false,
recoverErr: err
});
return;
}
if (Date.now() >= row.expire) {
sendPug(res, "account-passwordrecover", {
recovered: false,
recoverErr: "This password recovery link has expired. Password " +
"recovery links are valid only for 24 hours after " +
"submission."
});
return;
}
sendPug(res, "account-passwordrecover", {
confirm: true,
recovered: false
});
});
}
/**
* Handles a POST request for /account/passwordrecover/<hash>
*/
function handlePostPasswordRecover(req, res) {
var hash = req.params.hash;
if (typeof hash !== "string") {
res.send(400);
return;
}
var ip = req.realIP;
db.lookupPasswordReset(hash, function (err, row) {
if (err) {
sendPug(res, "account-passwordrecover", {
recovered: false,
recoverErr: err
});
return;
}
if (Date.now() >= row.expire) {
sendPug(res, "account-passwordrecover", {
recovered: false,
recoverErr: "This password recovery link has expired. Password " +
"recovery links are valid only for 24 hours after " +
"submission."
});
return;
}
var newpw = "";
const avail = "abcdefgihkmnpqrstuvwxyz0123456789";
for (var i = 0; i < 10; i++) {
newpw += avail[Math.floor(Math.random() * avail.length)];
}
db.users.setPassword(row.name, newpw, function (err) {
if (err) {
sendPug(res, "account-passwordrecover", {
recovered: false,
recoverErr: "Database error. Please contact an administrator if " +
"this persists."
});
return;
}
db.deletePasswordReset(hash);
Logger.eventlog.log("[account] " + ip + " recovered password for " + row.name);
sendPug(res, "account-passwordrecover", {
recovered: true,
recoverPw: newpw
});
});
});
}
module.exports = {
/**
* Initialize the module
*/
init: function (app, _globalMessageBus, _emailConfig, _emailController) {
globalMessageBus = _globalMessageBus;
emailConfig = _emailConfig;
emailController = _emailController;
app.get("/account/edit", handleAccountEditPage);
app.post("/account/edit", handleAccountEdit);
app.get("/account/channels", handleAccountChannelPage);
app.post("/account/channels", handleAccountChannel);
app.get("/account/profile", handleAccountProfilePage);
app.post("/account/profile", handleAccountProfile);
app.get("/account/passwordreset", handlePasswordResetPage);
app.post("/account/passwordreset", handlePasswordReset);
app.get("/account/passwordrecover/:hash", handleGetPasswordRecover);
app.post("/account/passwordrecover/:hash", handlePostPasswordRecover);
app.get("/account", function (req, res) {
res.redirect("/login");
});
}
};

118
src/web/acp.js Normal file
View file

@ -0,0 +1,118 @@
var path = require("path");
var fs = require("fs");
var webserver = require("./webserver");
var sendPug = require("./pug").sendPug;
var Logger = require("../logger");
let ioConfig;
function checkAdmin(cb) {
return async function (req, res) {
const user = await webserver.authorize(req);
if (!user) {
return res.send(403);
}
if (user.global_rank < 255) {
res.send(403);
Logger.eventlog.log("[acp] Attempted GET "+req.path+" from non-admin " +
user.name + "@" + req.realIP);
return;
}
cb(req, res, user);
};
}
/**
* Handles a request for the ACP
*/
function handleAcp(req, res, _user) {
const ioServers = ioConfig.getSocketEndpoints();
const chosenServer = ioServers[0];
if (!chosenServer) {
res.status(500).text("No suitable socket.io address for ACP");
return;
}
sendPug(res, "acp", {
ioServers: JSON.stringify(ioServers),
sioSource: `${chosenServer.url}/socket.io/socket.io.js`
});
}
/**
* Streams the last length bytes of file to the given HTTP response
*/
function readLog(res, file, length) {
fs.stat(file, function (err, data) {
if (err) {
res.send(500);
return;
}
var start = Math.max(0, data.size - length);
if (isNaN(start)) {
res.send(500);
}
var end = Math.max(0, data.size - 1);
if (isNaN(end)) {
res.send(500);
}
fs.createReadStream(file, { start: start, end: end })
.pipe(res);
});
}
/**
* Handles a request to read the syslog
*/
function handleReadSyslog(req, res) {
readLog(res, path.join(__dirname, "..", "..", "sys.log"), 1048576);
}
/**
* Handles a request to read the error log
*/
function handleReadErrlog(req, res) {
readLog(res, path.join(__dirname, "..", "..", "error.log"), 1048576);
}
/**
* Handles a request to read the http log
*/
function handleReadHttplog(req, res) {
readLog(res, path.join(__dirname, "..", "..", "http.log"), 1048576);
}
/**
* Handles a request to read the event log
*/
function handleReadEventlog(req, res) {
readLog(res, path.join(__dirname, "..", "..", "events.log"), 1048576);
}
/**
* Handles a request to read a channel log
*/
function handleReadChanlog(req, res) {
if (!req.params.name.match(/^[\w-]{1,30}$/)) {
res.send(400);
return;
}
readLog(res, path.join(__dirname, "..", "..", "chanlogs", req.params.name + ".log"), 1048576);
}
module.exports = {
init: function (app, _ioConfig) {
ioConfig = _ioConfig;
app.get("/acp", checkAdmin(handleAcp));
app.get("/acp/syslog", checkAdmin(handleReadSyslog));
app.get("/acp/errlog", checkAdmin(handleReadErrlog));
app.get("/acp/httplog", checkAdmin(handleReadHttplog));
app.get("/acp/eventlog", checkAdmin(handleReadEventlog));
app.get("/acp/chanlog/:name", checkAdmin(handleReadChanlog));
}
};

297
src/web/auth.js Normal file
View file

@ -0,0 +1,297 @@
/**
* web/auth.js - Webserver functions for user authentication and registration
*
* @author Calvin Montgomery <cyzon@cyzon.us>
*/
var webserver = require("./webserver");
var sendPug = require("./pug").sendPug;
var Logger = require("../logger");
var $util = require("../utilities");
var db = require("../database");
var Config = require("../config");
var url = require("url");
var session = require("../session");
var csrf = require("./csrf");
const LOGGER = require('@calzoneman/jsli')('web/auth');
function getSafeReferrer(req) {
const referrer = req.header('referer');
if (!referrer) {
return null;
}
const { hostname } = url.parse(referrer);
// TODO: come back to this when refactoring http alt domains
if (hostname === Config.get('http.root-domain')
|| Config.get('http.alt-domains').includes(hostname)) {
return referrer;
} else {
return null;
}
}
/**
* Processes a login request. Sets a cookie upon successful authentication
*/
function handleLogin(req, res) {
csrf.verify(req);
var name = req.body.name;
var password = req.body.password;
var rememberMe = req.body.remember;
var dest = req.body.dest || getSafeReferrer(req) || null;
dest = dest && dest.match(/login|logout/) ? null : dest;
if (typeof name !== "string" || typeof password !== "string") {
res.sendStatus(400);
return;
}
var host = req.hostname;
// TODO: remove this check from /login, make it generic middleware
// TODO: separate root-domain and "login domain", e.g. accounts.example.com
if (host !== Config.get("http.root-domain") &&
!host.endsWith("." + Config.get("http.root-domain")) &&
Config.get("http.alt-domains").indexOf(host) === -1) {
LOGGER.warn("Attempted login from non-approved domain " + host);
return res.sendStatus(403);
}
var expiration;
if (rememberMe) {
expiration = new Date("Fri, 31 Dec 9999 23:59:59 GMT");
} else {
expiration = new Date(Date.now() + 7*24*60*60*1000);
}
password = password.substring(0, 100);
db.users.verifyLogin(name, password, function (err, user) {
if (err) {
if (err === "Invalid username/password combination") {
Logger.eventlog.log("[loginfail] Login failed (bad password): " + name
+ "@" + req.realIP);
}
sendPug(res, "login", {
loggedIn: false,
loginError: err
});
return;
}
session.genSession(user, expiration, function (err, auth) {
if (err) {
sendPug(res, "login", {
loggedIn: false,
loginError: err
});
return;
}
webserver.setAuthCookie(req, res, expiration, auth);
if (dest) {
res.redirect(dest);
} else {
sendPug(res, "login", {
loggedIn: true,
loginName: user.name,
superadmin: user.global_rank >= 255
});
}
});
});
}
/**
* Handles a GET request for /login
*/
function handleLoginPage(req, res) {
if (res.locals.loggedIn) {
return sendPug(res, "login", {
wasAlreadyLoggedIn: true
});
}
var redirect = getSafeReferrer(req);
var locals = {};
if (!/\/register/.test(redirect)) {
locals.redirect = redirect;
}
sendPug(res, "login", locals);
}
/**
* Handles a request for /logout. Clears auth cookie
*/
function handleLogout(req, res) {
csrf.verify(req);
res.clearCookie("auth");
res.locals.loggedIn = res.locals.loginName = res.locals.superadmin = false;
// Try to find an appropriate redirect
var dest = req.body.dest || getSafeReferrer(req);
dest = dest && dest.match(/login|logout|account/) ? null : dest;
var host = req.hostname;
if (host.indexOf(Config.get("http.root-domain")) !== -1) {
res.clearCookie("auth", { domain: Config.get("http.root-domain-dotted") });
}
if (dest) {
res.redirect(dest);
} else {
sendPug(res, "logout", {});
}
}
function getHcaptchaSiteKey(captchaConfig) {
if (captchaConfig.isEnabled())
return captchaConfig.getHcaptcha().getSiteKey();
else
return null;
}
/**
* Handles a GET request for /register
*/
function handleRegisterPage(captchaConfig, req, res) {
if (res.locals.loggedIn) {
sendPug(res, "register", {});
return;
}
sendPug(res, "register", {
registered: false,
registerError: false,
hCaptchaSiteKey: getHcaptchaSiteKey(captchaConfig)
});
}
/**
* Processes a registration request.
*/
function handleRegister(captchaConfig, captchaController, req, res) {
csrf.verify(req);
var name = req.body.name;
var password = req.body.password;
var email = req.body.email;
if (typeof email !== "string") {
email = "";
}
var ip = req.realIP;
let captchaToken = req.body['h-captcha-response'];
if (typeof name !== "string" || typeof password !== "string") {
res.sendStatus(400);
return;
}
if (captchaConfig.isEnabled() &&
(typeof captchaToken !== 'string' || captchaToken === '')) {
sendPug(res, "register", {
registerError: "Missing CAPTCHA",
hCaptchaSiteKey: getHcaptchaSiteKey(captchaConfig)
});
return;
}
if (name.length === 0) {
sendPug(res, "register", {
registerError: "Username must not be empty",
hCaptchaSiteKey: getHcaptchaSiteKey(captchaConfig)
});
return;
}
if (name.match(Config.get("reserved-names.usernames"))) {
LOGGER.warn(
'Rejecting attempt by %s to register reserved username "%s"',
ip,
name
);
sendPug(res, "register", {
registerError: "That username is reserved",
hCaptchaSiteKey: getHcaptchaSiteKey(captchaConfig)
});
return;
}
if (password.length === 0) {
sendPug(res, "register", {
registerError: "Password must not be empty",
hCaptchaSiteKey: getHcaptchaSiteKey(captchaConfig)
});
return;
}
password = password.substring(0, 100);
if (email.length > 0 && !$util.isValidEmail(email)) {
sendPug(res, "register", {
registerError: "Invalid email address",
hCaptchaSiteKey: getHcaptchaSiteKey(captchaConfig)
});
return;
}
if (captchaConfig.isEnabled()) {
let captchaSuccess = true;
captchaController.verifyToken(captchaToken)
.catch(error => {
LOGGER.warn('CAPTCHA failed for registration %s: %s', name, error.message);
captchaSuccess = false;
sendPug(res, "register", {
registerError: 'CAPTCHA verification failed: ' + error.message,
hCaptchaSiteKey: getHcaptchaSiteKey(captchaConfig)
});
}).then(() => {
if (captchaSuccess)
doRegister();
});
} else {
doRegister();
}
function doRegister() {
db.users.register(name, password, email, ip, function (err) {
if (err) {
sendPug(res, "register", {
registerError: err,
hCaptchaSiteKey: getHcaptchaSiteKey(captchaConfig)
});
} else {
Logger.eventlog.log("[register] " + ip + " registered account: " + name +
(email.length > 0 ? " <" + email + ">" : ""));
sendPug(res, "register", {
registered: true,
registerName: name,
redirect: req.body.redirect
});
}
});
}
}
module.exports = {
/**
* Initializes auth callbacks
*/
init: function (app, captchaConfig, captchaController) {
app.get("/login", handleLoginPage);
app.post("/login", handleLogin);
app.post("/logout", handleLogout);
app.get("/register", (req, res) => {
handleRegisterPage(captchaConfig, req, res);
});
app.post("/register", (req, res) => {
handleRegister(captchaConfig, captchaController, req, res);
});
}
};

45
src/web/csrf.js Normal file
View file

@ -0,0 +1,45 @@
/*
* Adapted from https://github.com/expressjs/csurf
*/
import { CSRFError } from '../errors';
var csrf = require("csrf");
var tokens = csrf();
exports.init = function csrfInit (domain) {
return function (req, res, next) {
var secret = req.signedCookies._csrf;
if (!secret) {
secret = tokens.secretSync();
res.cookie("_csrf", secret, {
domain: domain,
signed: true,
httpOnly: true
});
}
var token;
req.csrfToken = function csrfToken() {
if (token) {
return token;
}
token = tokens.create(secret);
return token;
};
next();
};
};
exports.verify = function csrfVerify(req) {
var secret = req.signedCookies._csrf;
var token = req.body._csrf || req.query._csrf;
if (!tokens.verify(secret, token)) {
throw new CSRFError('Invalid CSRF token');
}
};

5
src/web/httpstatus.js Normal file
View file

@ -0,0 +1,5 @@
export const OK = 200;
export const BAD_REQUEST = 400;
export const FORBIDDEN = 403;
export const NOT_FOUND = 404;
export const INTERNAL_SERVER_ERROR = 500;

View file

@ -0,0 +1,13 @@
import Promise from 'bluebird';
var SERVER = null;
export default class LocalChannelIndex {
listPublicChannels() {
if (SERVER === null) {
SERVER = require('../server').getServer();
}
return Promise.resolve(SERVER.packChannelList(true));
}
}

View file

@ -0,0 +1,66 @@
import { setAuthCookie } from '../webserver';
const STATIC_RESOURCE = /\..+$/;
export default function initialize(app, session) {
app.use(async (req, res, next) => {
if (STATIC_RESOURCE.test(req.path)) {
return next();
} else if (!req.signedCookies || !req.signedCookies.auth) {
return next();
} else {
const [
name, expiration, salt, hash, global_rank
] = req.signedCookies.auth.split(':');
if (!name || !expiration || !salt || !hash) {
// Invalid auth cookie
return next();
}
let rank;
if (!global_rank) {
try {
rank = await backfillRankIntoAuthCookie(
session,
new Date(parseInt(expiration, 10)),
req,
res
);
} catch (error) {
return next();
}
} else {
rank = parseInt(global_rank, 10);
}
res.locals.loggedIn = true;
res.locals.loginName = name;
res.locals.superadmin = rank >= 255;
next();
}
});
}
async function backfillRankIntoAuthCookie(session, expiration, req, res) {
return new Promise((resolve, reject) => {
session.verifySession(req.signedCookies.auth, (err, account) => {
if (err) {
reject(err);
return;
}
session.genSession(account, expiration, (err2, auth) => {
if (err2) {
// genSession never returns an error, but it still
// has a callback parameter for one, so just in case...
reject(new Error('This should never happen: ' + err2));
return;
}
setAuthCookie(req, res, expiration, auth);
resolve(parseInt(auth.split(':')[4], 10));
});
});
});
}

View file

@ -0,0 +1,50 @@
const NO_EXPIRATION = new Date('Fri, 31 Dec 9999 23:59:59 GMT');
export function createIPSessionCookie(ip, date) {
return [
ip,
date.getTime()
].join(':');
}
export function verifyIPSessionCookie(ip, cookie) {
const parts = cookie.split(':');
if (parts.length !== 2) {
return null;
}
if (parts[0] !== ip) {
return null;
}
const unixtime = parseInt(parts[1], 10);
const date = new Date(unixtime);
if (isNaN(date.getTime())) {
return null;
}
return { date };
}
export function ipSessionCookieMiddleware(req, res, next) {
let firstSeen = new Date();
let hasSession = false;
if (req.signedCookies && req.signedCookies['ip-session']) {
const 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();
}

View file

@ -0,0 +1,29 @@
import proxyaddr from 'proxy-addr';
export function initialize(app, webConfig) {
const trustFn = proxyaddr.compile(webConfig.getTrustedProxies());
app.use(readProxyHeaders.bind(null, trustFn));
}
function getForwardedProto(req) {
const xForwardedProto = req.header('x-forwarded-proto');
if (xForwardedProto && xForwardedProto.match(/^https?$/)) {
return xForwardedProto;
} else {
return req.protocol;
}
}
function readProxyHeaders(trustFn, req, res, next) {
const forwardedIP = proxyaddr(req, trustFn);
if (forwardedIP !== req.ip) {
req.realIP = forwardedIP;
req.realProtocol = getForwardedProto(req);
} else {
req.realIP = req.ip;
req.realProtocol = req.protocol;
}
next();
}

82
src/web/pug.js Normal file
View file

@ -0,0 +1,82 @@
var pug = require("pug");
var fs = require("fs");
var path = require("path");
var Config = require("../config");
var templates = path.join(__dirname, "..", "..", "templates");
const cache = new Map();
const LOGGER = require('@calzoneman/jsli')('web/pug');
/**
* Merges locals with globals for pug rendering
*/
function merge(locals, res) {
var _locals = {
siteTitle: Config.get("html-template.title"),
siteDescription: Config.get("html-template.description"),
csrfToken: typeof res.req.csrfToken === 'function' ? res.req.csrfToken() : '',
baseUrl: getBaseUrl(res),
channelPath: Config.get("channel-path"),
};
if (typeof locals !== "object") {
return _locals;
}
for (var key in locals) {
_locals[key] = locals[key];
}
return _locals;
}
function getBaseUrl(res) {
var req = res.req;
return req.realProtocol + "://" + req.header("host");
}
/**
* Renders and serves a pug template
*/
function sendPug(res, view, locals) {
if (!locals) {
locals = {};
}
locals.loggedIn = nvl(locals.loggedIn, res.locals.loggedIn);
locals.loginName = nvl(locals.loginName, res.locals.loginName);
locals.superadmin = nvl(locals.superadmin, res.locals.superadmin);
let renderFn = cache.get(view);
if (!renderFn || Config.get("debug")) {
LOGGER.debug("Loading template %s", view);
var file = path.join(templates, view + ".pug");
renderFn = pug.compile(fs.readFileSync(file), {
filename: file,
pretty: !Config.get("http.minify")
});
cache.set(view, renderFn);
}
res.send(renderFn(merge(locals, res)));
}
function nvl(a, b) {
if (typeof a === 'undefined') return b;
return a;
}
function clearCache() {
let removed = 0;
for (const key of cache.keys()) {
cache.delete(key);
removed++;
}
LOGGER.info('Removed %d compiled templates from the cache', removed);
}
module.exports = {
sendPug: sendPug,
clearCache: clearCache
};

View file

@ -0,0 +1,161 @@
import { sendPug } from '../../pug';
import Config from '../../../config';
import { eventlog } from '../../../logger';
const verifySessionAsync = require('bluebird').promisify(
require('../../../session').verifySession
);
const LOGGER = require('@calzoneman/jsli')('web/routes/account/delete-account');
export default function initialize(
app,
csrfVerify,
channelDb,
userDb,
emailConfig,
emailController
) {
app.get('/account/delete', async (req, res) => {
if (!await authorize(req, res)) {
return;
}
await showDeletePage(res, {});
});
app.post('/account/delete', async (req, res) => {
if (!await authorize(req, res)) {
return;
}
csrfVerify(req);
if (!req.body.confirmed) {
await showDeletePage(res, { missingConfirmation: true });
return;
}
let user;
try {
user = await userDb.verifyLoginAsync(res.locals.loginName, req.body.password);
} catch (error) {
if (error.message === 'Invalid username/password combination') {
res.status(403);
await showDeletePage(res, { wrongPassword: true });
} else if (error.message === 'User does not exist' ||
error.message.match(/Invalid username/)) {
LOGGER.error('User does not exist after authorization');
res.status(503);
await showDeletePage(res, { internalError: true });
} else {
res.status(503);
LOGGER.error('Unknown error in verifyLogin: %s', error.stack);
await showDeletePage(res, { internalError: true });
}
return;
}
try {
let channels = await channelDb.listUserChannelsAsync(user.name);
if (channels.length > 0) {
await showDeletePage(res, { channelCount: channels.length });
return;
}
} catch (error) {
LOGGER.error('Unknown error in listUserChannels: %s', error.stack);
await showDeletePage(res, { internalError: true });
}
try {
await userDb.requestAccountDeletion(user.id);
eventlog.log(`[account] ${req.realIP} requested account deletion for ${user.name}`);
} catch (error) {
LOGGER.error('Unknown error in requestAccountDeletion: %s', error.stack);
await showDeletePage(res, { internalError: true });
}
if (emailConfig.getDeleteAccount().isEnabled() && user.email) {
await sendEmail(user);
} else {
LOGGER.warn(
'Skipping account deletion email notification for %s',
user.name
);
}
res.clearCookie('auth', { domain: Config.get('http.root-domain-dotted') });
res.locals.loggedIn = false;
res.locals.loginName = null;
sendPug(
res,
'account-deleted',
{}
);
});
async function showDeletePage(res, flags) {
let locals = Object.assign({ channelCount: 0 }, flags);
if (res.locals.loggedIn) {
let channels = await channelDb.listUserChannelsAsync(
res.locals.loginName
);
locals.channelCount = channels.length;
} else {
res.status(401);
}
sendPug(
res,
'account-delete',
locals
);
}
async function authorize(req, res) {
try {
if (!res.locals.loggedIn) {
res.status(401);
await showDeletePage(res, {});
return;
}
if (!req.signedCookies || !req.signedCookies.auth) {
throw new Error('Missing auth cookie');
}
await verifySessionAsync(req.signedCookies.auth);
return true;
} catch (error) {
res.status(401);
sendPug(
res,
'account-delete',
{ authFailed: true, reason: error.message }
);
return false;
}
}
async function sendEmail(user) {
LOGGER.info(
'Sending email notification for account deletion %s <%s>',
user.name,
user.email
);
try {
await emailController.sendAccountDeletion({
username: user.name,
address: user.email
});
} catch (error) {
LOGGER.error(
'Sending email notification failed for %s <%s>: %s',
user.name,
user.email,
error.stack
);
}
}
}

Some files were not shown because too many files have changed in this diff Show more