package: build with babel for ES2015 support

* Rename lib/ -> src/
* Add `postinstall` npm target for compiling src files to lib
* Add `build-watch` npm target for development with babel --watch
* Add `lib/` to .gitignore
* Add `source-map-support` module for babel-generated sourcemaps
This commit is contained in:
calzoneman 2015-09-20 22:06:53 -07:00
parent d042619b21
commit 0109a87e55
55 changed files with 9 additions and 3 deletions

155
src/account.js Normal file
View file

@ -0,0 +1,155 @@
var db = require("./database");
var Q = require("q");
function Account(opts) {
var defaults = {
name: "",
ip: "",
aliases: [],
globalRank: -1,
channelRank: -1,
guest: true,
profile: {
image: "",
text: ""
}
};
this.name = opts.name || defaults.name;
this.lowername = this.name.toLowerCase();
this.ip = opts.ip || defaults.ip;
this.aliases = opts.aliases || defaults.aliases;
this.globalRank = "globalRank" in opts ? opts.globalRank : defaults.globalRank;
this.channelRank = "channelRank" in opts ? opts.channelRank : defaults.channelRank;
this.effectiveRank = Math.max(this.globalRank, this.channelRank);
this.guest = this.globalRank === 0;
this.profile = opts.profile || defaults.profile;
}
module.exports.default = function (ip) {
return new Account({ ip: ip });
};
module.exports.getAccount = function (name, ip, opts, cb) {
if (!cb) {
cb = opts;
opts = {};
}
opts.channel = opts.channel || false;
var data = {};
Q.nfcall(db.getAliases, ip)
.then(function (aliases) {
data.aliases = aliases;
if (name && opts.registered) {
return Q.nfcall(db.users.getGlobalRank, name);
} else if (name) {
return 0;
} else {
return -1;
}
}).then(function (globalRank) {
data.globalRank = globalRank;
if (opts.channel && opts.registered) {
return Q.nfcall(db.channels.getRank, opts.channel, name);
} else {
if (opts.registered) {
return 1;
} else if (name) {
return 0;
} else {
return -1;
}
}
}).then(function (chanRank) {
data.channelRank = chanRank;
/* Look up profile for registered user */
if (data.globalRank >= 1) {
return Q.nfcall(db.users.getProfile, name);
} else {
return { text: "", image: "" };
}
}).then(function (profile) {
setImmediate(function () {
cb(null, new Account({
name: name,
ip: ip,
aliases: data.aliases,
globalRank: data.globalRank,
channelRank: data.channelRank,
profile: profile
}));
});
}).catch(function (err) {
cb(err, null);
}).done();
};
module.exports.rankForName = function (name, opts, cb) {
if (!cb) {
cb = opts;
opts = {};
}
var rank = 0;
Q.fcall(function () {
return Q.nfcall(db.users.getGlobalRank, name);
}).then(function (globalRank) {
rank = globalRank;
if (opts.channel) {
return Q.nfcall(db.channels.getRank, opts.channel, name);
} else {
return globalRank > 0 ? 1 : 0;
}
}).then(function (chanRank) {
setImmediate(function () {
cb(null, Math.max(rank, chanRank));
});
}).catch(function (err) {
cb(err, 0);
}).done();
};
module.exports.rankForIP = function (ip, opts, cb) {
if (!cb) {
cb = opts;
opts = {};
}
var globalRank, rank, names;
var promise = Q.nfcall(db.getAliases, ip)
.then(function (_names) {
names = _names;
return Q.nfcall(db.users.getGlobalRanks, names);
}).then(function (ranks) {
ranks.push(0);
globalRank = Math.max.apply(Math, ranks);
rank = globalRank;
});
if (!opts.channel) {
promise.then(function () {
setImmediate(function () {
cb(null, globalRank);
});
}).catch(function (err) {
cb(err, null);
}).done();
} else {
promise.then(function () {
return Q.nfcall(db.channels.getRanks, opts.channel, names);
}).then(function (ranks) {
ranks.push(globalRank);
rank = Math.max.apply(Math, ranks);
}).then(function () {
setImmediate(function () {
cb(null, rank);
});
}).catch(function (err) {
setImmediate(function () {
cb(err, null);
});
}).done();
}
};

318
src/acp.js Normal file
View file

@ -0,0 +1,318 @@
var Logger = require("./logger");
var Server = require("./server");
var db = require("./database");
var util = require("./utilities");
var Config = require("./config");
var Server = require("./server");
function eventUsername(user) {
return user.getName() + "@" + user.realip;
}
function handleAnnounce(user, data) {
var sv = Server.getServer();
sv.announce({
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) {
db.globalBanIP(data.ip, data.note, function (err, res) {
if (err) {
user.socket.emit("errMessage", {
msg: err
});
return;
}
Logger.eventlog.log("[acp] " + eventUsername(user) + " global banned " + data.ip);
db.listGlobalBans(function (err, bans) {
if (err) {
user.socket.emit("errMessage", {
msg: err
});
return;
}
var flat = [];
for (var ip in bans) {
flat.push({
ip: ip,
note: bans[ip].reason
});
}
user.socket.emit("acp-gbanlist", flat);
});
});
}
function handleGlobalBanDelete(user, data) {
db.globalUnbanIP(data.ip, function (err, res) {
if (err) {
user.socket.emit("errMessage", {
msg: err
});
return;
}
Logger.eventlog.log("[acp] " + eventUsername(user) + " un-global banned " +
data.ip);
db.listGlobalBans(function (err, bans) {
if (err) {
user.socket.emit("errMessage", {
msg: err
});
return;
}
var flat = [];
for (var ip in bans) {
flat.push({
ip: ip,
note: bans[ip].reason
});
}
user.socket.emit("acp-gbanlist", flat);
});
});
}
function handleListUsers(user, data) {
var name = data.name;
if (typeof name !== "string") {
name = "";
}
var fields = ["id", "name", "global_rank", "email", "ip", "time"];
db.users.search(name, 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) {
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) {
user.socket.emit("errMessage", {
msg: err
});
return;
}
Logger.eventlog.log("[acp] " + eventUsername(user) + " initialized a " +
"password recovery for " + name);
user.socket.emit("errMessage", {
msg: "Reset link: " + Config.get("http.domain") +
"/account/passwordrecover/" + 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 handleListStats(user) {
db.listStats(function (err, rows) {
user.socket.emit("acp-list-stats", rows);
});
}
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));
s.on("acp-list-stats", handleListStats.bind(this, user));
db.listGlobalBans(function (err, bans) {
if (err) {
user.socket.emit("errMessage", {
msg: err
});
return;
}
var flat = [];
for (var ip in bans) {
flat.push({
ip: ip,
note: bans[ip].reason
});
}
user.socket.emit("acp-gbanlist", flat);
});
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], tm = item[1];
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;

84
src/bgtask.js Normal file
View file

@ -0,0 +1,84 @@
/*
bgtask.js
Registers background jobs to run periodically while the server is
running.
*/
var Logger = require("./logger");
var Config = require("./config");
var db = require("./database");
var init = null;
/* Stats */
function initStats(Server) {
var STAT_INTERVAL = parseInt(Config.get("stats.interval"));
var STAT_EXPIRE = parseInt(Config.get("stats.max-age"));
setInterval(function () {
var chancount = Server.channels.length;
var usercount = 0;
Server.channels.forEach(function (chan) {
usercount += chan.users.length;
});
var mem = process.memoryUsage().rss;
db.addStatPoint(Date.now(), usercount, chancount, mem, function () {
db.pruneStats(Date.now() - STAT_EXPIRE);
});
}, STAT_INTERVAL);
}
/* Alias cleanup */
function initAliasCleanup(Server) {
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.syslog.log("Cleaned old aliases");
if (err)
Logger.errlog.log(err);
});
}, CLEAN_INTERVAL);
}
/* Password reset cleanup */
function initPasswordResetCleanup(Server) {
var CLEAN_INTERVAL = 8*60*60*1000;
setInterval(function () {
db.cleanOldPasswordResets(function (err) {
if (err)
Logger.errlog.log(err);
});
}, CLEAN_INTERVAL);
}
function initChannelDumper(Server) {
var CHANNEL_SAVE_INTERVAL = parseInt(Config.get("channel-save-interval"))
* 60000;
setInterval(function () {
for (var i = 0; i < Server.channels.length; i++) {
var chan = Server.channels[i];
if (!chan.dead && chan.users && chan.users.length > 0) {
chan.saveState();
}
}
}, CHANNEL_SAVE_INTERVAL);
}
module.exports = function (Server) {
if (init === Server) {
Logger.errlog.log("WARNING: Attempted to re-init background tasks");
return;
}
init = Server;
initStats(Server);
initAliasCleanup(Server);
initChannelDumper(Server);
initPasswordResetCleanup(Server);
};

View file

@ -0,0 +1,70 @@
var Account = require("../account");
var ChannelModule = require("./module");
var Flags = require("../flags");
function AccessControlModule(channel) {
ChannelModule.apply(this, arguments);
}
AccessControlModule.prototype = Object.create(ChannelModule.prototype);
var pending = 0;
AccessControlModule.prototype.onUserPreJoin = function (user, data, cb) {
var chan = this.channel,
opts = this.channel.modules.options;
var self = this;
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_LOGGED_IN, function () {
user.refreshAccount({ channel: self.channel.name }, function (err, account) {
/* Already joined the channel by some other condition */
if (user.is(Flags.U_IN_CHANNEL)) {
return;
}
if (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;

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

@ -0,0 +1,689 @@
var MakeEmitter = require("../emitter");
var Logger = require("../logger");
var ChannelModule = require("./module");
var Flags = require("../flags");
var Account = require("../account");
var util = require("../utilities");
var fs = require("graceful-fs");
var path = require("path");
var sio = require("socket.io");
var db = require("../database");
const SIZE_LIMIT = 1048576;
/**
* Previously, async channel functions were riddled with race conditions due to
* an event causing the channel to be unloaded while a pending callback still
* needed to reference it.
*
* This solution should be better than constantly checking whether the channel
* has been unloaded in nested callbacks. The channel won't be unloaded until
* nothing needs it anymore. Conceptually similar to a reference count.
*/
function ActiveLock(channel) {
this.channel = channel;
this.count = 0;
}
ActiveLock.prototype = {
lock: function () {
this.count++;
},
release: function () {
this.count--;
if (this.count === 0) {
/* sanity check */
if (this.channel.users.length > 0) {
Logger.errlog.log("Warning: ActiveLock count=0 but users.length > 0 (" +
"channel: " + this.channel.name + ")");
this.count = this.channel.users.length;
} else {
this.channel.emit("empty");
}
}
}
};
function Channel(name) {
MakeEmitter(this);
this.name = name;
this.uniqueName = name.toLowerCase();
this.modules = {};
this.logger = new Logger.Logger(path.join(__dirname, "..", "..", "chanlogs",
this.uniqueName + ".log"));
this.users = [];
this.activeLock = new ActiveLock(this);
this.flags = 0;
var self = this;
db.channels.load(this, function (err) {
if (err && err !== "Channel is not registered") {
return;
} else {
self.initModules();
self.loadState();
}
});
}
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.unbind("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"
};
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.getDiskSize = function (cb) {
if (this._getDiskSizeTimeout > Date.now()) {
return cb(null, this._cachedDiskSize);
}
var self = this;
var file = path.join(__dirname, "..", "..", "chandump", self.uniqueName);
fs.stat(file, function (err, stats) {
if (err) {
return cb(err);
}
self._cachedDiskSize = stats.size;
cb(null, self._cachedDiskSize);
});
};
Channel.prototype.loadState = function () {
var self = this;
var file = path.join(__dirname, "..", "..", "chandump", self.uniqueName);
/* Don't load from disk if not registered */
if (!self.is(Flags.C_REGISTERED)) {
self.modules.permissions.loadUnregistered();
self.setFlag(Flags.C_READY);
return;
}
var errorLoad = function (msg) {
if (self.modules.customization) {
self.modules.customization.load({
motd: msg
});
}
self.setFlag(Flags.C_READY | Flags.C_ERROR);
};
fs.stat(file, function (err, stats) {
if (!err) {
var mb = stats.size / 1048576;
mb = Math.floor(mb * 100) / 100;
if (mb > SIZE_LIMIT / 1048576) {
Logger.errlog.log("Large chandump detected: " + self.uniqueName +
" (" + mb + " MiB)");
var msg = "This channel's state size has exceeded the memory limit " +
"enforced by this server. Please contact an administrator " +
"for assistance.";
errorLoad(msg);
return;
}
}
continueLoad();
});
var continueLoad = function () {
fs.readFile(file, function (err, data) {
if (err) {
/* ENOENT means the file didn't exist. This is normal for new channels */
if (err.code === "ENOENT") {
self.setFlag(Flags.C_READY);
Object.keys(self.modules).forEach(function (m) {
self.modules[m].load({});
});
} else {
Logger.errlog.log("Failed to open channel dump " + self.uniqueName);
Logger.errlog.log(err);
errorLoad("Unknown error occurred when loading channel state. " +
"Contact an administrator for assistance.");
}
return;
}
self.logger.log("[init] Loading channel state from disk");
try {
data = JSON.parse(data);
Object.keys(self.modules).forEach(function (m) {
self.modules[m].load(data);
});
self.setFlag(Flags.C_READY);
} catch (e) {
Logger.errlog.log("Channel dump for " + self.uniqueName + " is not " +
"valid");
Logger.errlog.log(e);
errorLoad("Unknown error occurred when loading channel state. Contact " +
"an administrator for assistance.");
}
});
};
};
Channel.prototype.saveState = function () {
var self = this;
var file = path.join(__dirname, "..", "..", "chandump", self.uniqueName);
/**
* Don't overwrite saved state data if the current state is dirty,
* or if this channel is unregistered
*/
if (self.is(Flags.C_ERROR) || !self.is(Flags.C_REGISTERED)) {
return;
}
self.logger.log("[init] Saving channel state to disk");
var data = {};
Object.keys(this.modules).forEach(function (m) {
self.modules[m].save(data);
});
var json = JSON.stringify(data);
/**
* Synchronous on purpose.
* When the server is shutting down, saveState() is called on all channels and
* then the process terminates. Async writeFile causes a race condition that wipes
* channels.
*/
var err = fs.writeFileSync(file, json);
// Check for large chandump and warn moderators/admins
self.getDiskSize(function (err, size) {
if (!err && size > SIZE_LIMIT && self.users) {
self.users.forEach(function (u) {
if (u.account.effectiveRank >= 2) {
u.socket.emit("warnLargeChandump", {
limit: SIZE_LIMIT,
actual: size
});
}
});
}
});
};
Channel.prototype.checkModules = function (fn, args, cb) {
var self = this;
this.waitFlag(Flags.C_READY, function () {
self.activeLock.lock();
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.activeLock.release();
return;
}
var m = keys.shift();
if (m === undefined) {
/* No more modules to check */
cb(null, ChannelModule.PASSTHROUGH);
self.activeLock.release();
return;
}
var module = self.modules[m];
module[fn].apply(module, args);
};
args.push(next);
next(null, ChannelModule.PASSTHROUGH);
});
};
Channel.prototype.notifyModules = function (fn, args) {
var self = this;
this.waitFlag(Flags.C_READY, function () {
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) {
var self = this;
self.activeLock.lock();
self.waitFlag(Flags.C_READY, function () {
/* User closed the connection before the channel finished loading */
if (user.socket.disconnected) {
self.activeLock.release();
return;
}
if (self.is(Flags.C_REGISTERED)) {
user.refreshAccount({ channel: self.name }, function (err, account) {
if (err) {
Logger.errlog.log("user.refreshAccount failed at Channel.joinUser");
Logger.errlog.log(err.stack);
self.activeLock.release();
return;
}
afterAccount();
});
} else {
afterAccount();
}
function afterAccount() {
if (self.dead || user.socket.disconnected) {
if (self.activeLock) self.activeLock.release();
return;
}
self.checkModules("onUserPreJoin", [user, data], function (err, result) {
if (result === ChannelModule.PASSTHROUGH) {
if (user.account.channelRank !== user.account.globalRank) {
user.socket.emit("rank", user.account.effectiveRank);
}
self.acceptUser(user);
} else {
user.account.channelRank = 0;
user.account.effectiveRank = user.account.globalRank;
self.activeLock.release();
}
});
}
});
};
Channel.prototype.acceptUser = function (user) {
user.channel = this;
user.setFlag(Flags.U_IN_CHANNEL);
user.socket.join(this.name);
user.autoAFK();
user.socket.on("readChanLog", this.handleReadLog.bind(this, user));
Logger.syslog.log(user.realip + " joined " + this.name);
if (user.socket._isUsingTor) {
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);
});
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]);
this.sendUsercount(this.users);
if (!this.is(Flags.C_REGISTERED)) {
user.socket.emit("channelNotRegistered");
}
};
Channel.prototype.partUser = function (user) {
if (!this.logger) {
Logger.errlog.log("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.sendUsercount(this.users);
this.activeLock.release();
user.die();
};
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;
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) {
var maxLen = 102400;
var file = this.logger.filename;
this.activeLock.lock();
var self = this;
fs.stat(file, function (err, data) {
if (err) {
self.activeLock.release();
return cb(err, null);
}
var start = Math.max(data.size - maxLen, 0);
var end = data.size - 1;
var 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.activeLock.release();
});
});
};
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;
}
var shouldMaskIP = user.account.globalRank < 255;
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._broadcast = function (msg, data, ns) {
sio.instance.in(ns).emit(msg, data);
};
Channel.prototype.broadcastAll = function (msg, data) {
this._broadcast(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.activeLock.count;
}
var self = this;
var keys = Object.keys(this.modules);
keys.forEach(function (k) {
self.modules[k].packInfo(data, isAdmin);
});
return data;
};
module.exports = Channel;

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

@ -0,0 +1,615 @@
var Config = require("../config");
var User = require("../user");
var XSS = require("../xss");
var ChannelModule = require("./module");
var util = require("../utilities");
var Flags = require("../flags");
var url = require("url");
var counters = require("../counters");
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 util.Set();
this.commandHandlers = {};
/* 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 util.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 (var i = 0; i < data.chatmuted.length; i++) {
this.muted.add(data.chatmuted[i]);
}
}
};
ChatModule.prototype.save = function (data) {
data.chatbuffer = this.buffer;
data.chatmuted = Array.prototype.slice.call(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.contains(name.toLowerCase()) ||
this.muted.contains(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.contains(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.handleChatMsg = function (user, data) {
var self = this;
counters.add("chat:incoming");
if (!this.channel || !this.channel.modules.permissions.canChat(user)) {
return;
}
// Limit to 240 characters
data.msg = data.msg.substring(0, 240);
// 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"
});
}
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;
}
var reallyTo = data.to;
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, 240);
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);
};
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);
});
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);
counters.add("chat:sent");
};
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 chan = this.channel;
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\">" + link + "</a>";
} else {
return link;
}
});
return XSS.sanitizeHTML(result);
};
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.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.buffer = [];
this.channel.broadcastAll("clearchat");
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 (!this.isMuted(name)) {
user.socket.emit("errorMsg", {
msg: name + " is not muted."
});
return;
}
this.muted.remove(name);
this.muted.remove(SHADOW_TAG + name);
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);
this.channel.logger.log("[mod] " + user.getName() + " unmuted " + target.getName());
this.sendModMessage(user.getName() + " unmuted " + target.getName(), muteperm);
};
module.exports = ChatModule;

View file

@ -0,0 +1,119 @@
var ChannelModule = require("./module");
var XSS = require("../xss");
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 = "";
}
CustomizationModule.prototype = Object.create(ChannelModule.prototype);
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);
}
}
};
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,
js: this.js
};
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;
}
this.css = data.css.substring(0, 20000);
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;
}
this.js = data.js.substring(0, 20000);
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.setMotd(motd);
this.channel.logger.log("[mod] " + user.getName() + " updated the MOTD");
};
module.exports = CustomizationModule;

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

@ -0,0 +1,56 @@
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;

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

@ -0,0 +1,205 @@
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);
},
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) {
var found = false;
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.sanitizeText(f.name).replace(/([\\\.\?\+\*\$\^\|\(\)\[\]\{\}])/g, "\\$1");
s = "(^|\\s)" + s + "(?!\\S)";
f.source = s;
try {
new RegExp(f.source, "gi");
} catch (e) {
return false;
}
return f;
};
function EmoteModule(channel) {
ChannelModule.apply(this, arguments);
this.emotes = new EmoteList();
}
EmoteModule.prototype = Object.create(ChannelModule.prototype);
EmoteModule.prototype.load = function (data) {
if ("emotes" in data) {
for (var i = 0; i < data.emotes.length; i++) {
this.emotes.updateEmote(data.emotes[i]);
}
}
};
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("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();
var chan = this.channel;
users.forEach(function (u) {
u.socket.emit("emoteList", f);
});
};
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) {
return;
}
this.emotes.updateEmote(f);
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.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.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);
};
module.exports = EmoteModule;

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

@ -0,0 +1,300 @@
var FilterList = require("cytubefilters");
var ChannelModule = require("./module");
var XSS = require("../xss");
var Logger = require("../logger");
/*
* 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.replace = XSS.sanitizeHTML(f.replace);
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();
}
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.errlog.log("Filter load failed: " + e + " (channel:" +
this.channel.name);
this.channel.logger.log("Failed to load filters: " + e);
}
} else {
this.filters = new FilterList(DEFAULT_FILTERS);
}
};
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;
}
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;
}
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.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;
}
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;
}
};
ChatFilterModule.prototype.handleRequestChatFilters = function (user) {
this.sendChatFilters([user]);
};
module.exports = ChatFilterModule;

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

@ -0,0 +1,429 @@
var ChannelModule = require("./module");
var db = require("../database");
var Flags = require("../flags");
var util = require("../utilities");
var Account = require("../account");
var Q = require("q");
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 checkNameBan(cname, name, cb) {
db.channels.isNameBanned(cname, 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);
}
var cname = this.channel.name;
checkIPBan(cname, user.realip, function (banned) {
if (banned) {
cb(null, ChannelModule.DENY);
user.kick("Your IP address is banned from this channel.");
} else {
checkNameBan(cname, user.getName(), function (banned) {
if (banned) {
cb(null, ChannelModule.DENY);
user.kick("Your username is banned from this channel.");
} else {
cb(null, ChannelModule.PASSTHROUGH);
}
});
}
});
};
KickBanModule.prototype.onUserPostJoin = function (user) {
if (!this.channel.is(Flags.C_REGISTERED)) {
return;
}
var chan = this.channel;
user.waitFlag(Flags.U_LOGGED_IN, function () {
chan.activeLock.lock();
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.activeLock.release();
});
});
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(" ");
var chan = this.channel;
chan.activeLock.lock();
this.banName(user, name, reason, function (err) {
chan.activeLock.release();
});
};
/* /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(" ");
var chan = this.channel;
chan.activeLock.lock();
this.banAll(user, name, range, reason, function (err) {
chan.activeLock.release();
});
};
KickBanModule.prototype.banName = function (actor, name, reason, cb) {
var self = this;
reason = reason.substring(0, 255);
var chan = this.channel;
var error = function (what) {
actor.socket.emit("errorMsg", { msg: what });
cb(what);
};
if (!chan.modules.permissions.canBan(actor)) {
return 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"
});
return cb("Attempted to ban self");
}
Q.nfcall(Account.rankForName, name, { channel: chan.name })
.then(function (rank) {
if (rank >= actor.account.effectiveRank) {
throw "You don't have permission to ban " + name;
}
return Q.nfcall(db.channels.isNameBanned, chan.name, name);
}).then(function (banned) {
if (banned) {
throw name + " is already banned";
}
if (chan.dead) { throw null; }
return Q.nfcall(db.channels.ban, chan.name, "*", name, reason, actor.getName());
}).then(function () {
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);
}
return true;
}).then(function () {
self.kickBanTarget(name, null);
setImmediate(function () {
cb(null);
});
}).catch(error).done();
};
KickBanModule.prototype.banIP = function (actor, ip, name, reason, cb) {
var self = this;
reason = reason.substring(0, 255);
var masked = util.cloakIP(ip);
var chan = this.channel;
var error = function (what) {
actor.socket.emit("errorMsg", { msg: what });
cb(what);
};
if (!chan.modules.permissions.canBan(actor)) {
return error("You do not have ban permissions on this channel");
}
Q.nfcall(Account.rankForIP, ip, { channel: chan.name }).then(function (rank) {
if (rank >= actor.account.effectiveRank) {
throw "You don't have permission to ban IP " + masked;
}
return Q.nfcall(db.channels.isIPBanned, chan.name, ip);
}).then(function (banned) {
if (banned) {
throw masked + " is already banned";
}
if (chan.dead) { throw null; }
return Q.nfcall(db.channels.ban, chan.name, ip, name, reason, actor.getName());
}).then(function () {
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);
}
}).then(function () {
self.kickBanTarget(name, ip);
setImmediate(function () {
cb(null);
});
}).catch(error).done();
};
KickBanModule.prototype.banAll = function (actor, name, range, reason, cb) {
var self = this;
reason = reason.substring(0, 255);
var chan = self.channel;
var error = function (what) {
cb(what);
};
if (!chan.modules.permissions.canBan(actor)) {
return error("You do not have ban permissions on this channel");
}
self.banName(actor, name, reason, function (err) {
if (err && err.indexOf("is already banned") === -1) {
cb(err);
} else {
db.getIPs(name, function (err, ips) {
if (err) {
return error(err);
}
var all = ips.map(function (ip) {
if (range === "range") {
ip = util.getIPRange(ip);
} else if (range === "wrange") {
ip = util.getWideIPRange(ip);
}
return Q.nfcall(self.banIP.bind(self), actor, ip, name, reason);
});
Q.all(all).then(function () {
setImmediate(cb);
}).catch(error).done();
});
}
});
};
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.activeLock.lock();
db.channels.unbanId(this.channel.name, data.id, function (err) {
if (err) {
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.activeLock.release();
});
};
module.exports = KickBanModule;

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

@ -0,0 +1,111 @@
var ChannelModule = require("./module");
var Flags = require("../flags");
var util = require("../utilities");
var InfoGetter = require("../get-info");
var db = require("../database");
var Media = require("../media");
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.getItem = function (id, cb) {
db.channels.getLibraryItem(this.channel.name, id, function (err, row) {
if (err) {
cb(err, null);
} else {
var meta = JSON.parse(row.meta || "{}");
cb(null, new Media(row.id, row.title, row.seconds, row.type, meta));
}
});
};
LibraryModule.prototype.handleUncache = function (user, data) {
if (!this.channel.is(Flags.C_REGISTERED)) {
return;
}
if (!this.channel.modules.permissions.canUncache(user)) {
return;
}
var chan = this.channel;
chan.activeLock.lock();
db.channels.deleteFromLibrary(chan.name, data.id, function (err, res) {
if (chan.dead || err) {
return;
}
chan.logger.log("[library] " + user.getName() + " deleted " + data.id +
"from the library");
chan.activeLock.release();
});
};
LibraryModule.prototype.handleSearchMedia = function (user, data) {
var query = data.query.substring(0, 100);
var searchYT = function () {
InfoGetter.Getters.ytSearch(query, function (e, vids) {
if (!e) {
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 {
db.channels.searchLibrary(this.channel.name, query, function (err, res) {
if (err) {
res = [];
}
if (res.length === 0) {
return searchYT();
}
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,209 @@
var Vimeo = require("cytube-mediaquery/lib/provider/vimeo");
var ChannelModule = require("./module");
var Config = require("../config");
var InfoGetter = require("../get-info");
var Logger = require("../logger");
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 "gd":
pl._refreshing = true;
return this.initGoogleDocs(data, function () {
pl._refreshing = false;
cb(null, ChannelModule.PASSTHROUGH);
});
case "gp":
pl._refreshing = true;
return this.initGooglePlus(data, function () {
pl._refreshing = false;
cb(null, ChannelModule.PASSTHROUGH);
});
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.initGoogleDocs = function (data, cb) {
var self = this;
self.refreshGoogleDocs(data, cb);
/*
* Refresh every 55 minutes.
* The expiration is 1 hour, but refresh 5 minutes early to be safe
*/
self._interval = setInterval(function () {
self.refreshGoogleDocs(data);
}, 55 * 60 * 1000);
};
MediaRefresherModule.prototype.initVimeo = function (data, cb) {
if (!Config.get("vimeo-workaround")) {
if (cb) cb();
return;
}
var self = this;
self.channel.activeLock.lock();
Vimeo.extract(data.id).then(function (direct) {
if (self.dead || self.channel.dead)
return;
if (self._media === data) {
data.meta.direct = direct;
self.channel.logger.log("[mediarefresher] Refreshed vimeo video with ID " +
data.id);
}
self.channel.activeLock.release();
if (cb) cb();
}).catch(function (err) {
Logger.errlog.log("Unexpected vimeo::extract() fail: " + err.stack);
if (cb) cb();
});
};
MediaRefresherModule.prototype.refreshGoogleDocs = function (media, cb) {
var self = this;
if (self.dead || self.channel.dead) {
return;
}
self.channel.activeLock.lock();
InfoGetter.getMedia(media.id, "gd", function (err, data) {
if (self.dead || self.channel.dead) {
return;
}
if (typeof err === "string") {
err = err.replace(/Google Drive lookup failed for [\w-]+: /, "");
err = err.replace(/Forbidden/, "Access Denied");
err = err.replace(/You don't have permission to access this video\./,
"Access Denied");
}
switch (err) {
case "Moved Temporarily":
self.channel.logger.log("[mediarefresher] Google Docs refresh failed " +
"(likely redirect to login page-- make sure it is shared " +
"correctly)");
self.channel.activeLock.release();
if (cb) cb();
return;
case "Access Denied":
case "Not Found":
case "Internal Server Error":
case "Service Unavailable":
case "Google Drive does not permit videos longer than 1 hour to be played":
case "Google Drive videos must be shared publicly":
self.channel.logger.log("[mediarefresher] Google Docs refresh failed: " +
err);
self.channel.activeLock.release();
if (cb) cb();
return;
default:
if (err) {
self.channel.logger.log("[mediarefresher] Google Docs refresh failed: " +
err);
Logger.errlog.log("Google Docs refresh failed for ID " + media.id +
": " + err);
self.channel.activeLock.release();
if (cb) cb();
return;
}
}
if (media !== self._media) {
self.channel.activeLock.release();
if (cb) cb();
return;
}
self.channel.logger.log("[mediarefresher] Refreshed Google Docs video with ID " +
media.id);
media.meta = data.meta;
self.channel.activeLock.release();
if (cb) cb();
});
};
MediaRefresherModule.prototype.initGooglePlus = function (media, cb) {
var self = this;
if (self.dead || self.channel.dead) {
return;
}
self.channel.activeLock.lock();
InfoGetter.getMedia(media.id, "gp", function (err, data) {
if (self.dead || self.channel.dead) {
return;
}
if (typeof err === "string") {
err = err.replace(/Forbidden/, "Access Denied");
}
switch (err) {
case "Access Denied":
case "Not Found":
case "Internal Server Error":
case "Service Unavailable":
case "The video is still being processed":
case "A processing error has occured":
case "The video has been processed but is not yet accessible":
case ("Unable to retreive video information. Check that the video exists " +
"and is shared publicly"):
self.channel.logger.log("[mediarefresher] Google+ refresh failed: " +
err);
self.channel.activeLock.release();
if (cb) cb();
return;
default:
if (err) {
self.channel.logger.log("[mediarefresher] Google+ refresh failed: " +
err);
Logger.errlog.log("Google+ refresh failed for ID " + media.id +
": " + err);
self.channel.activeLock.release();
if (cb) cb();
return;
}
}
if (media !== self._media) {
self.channel.activeLock.release();
if (cb) cb();
return;
}
self.channel.logger.log("[mediarefresher] Refreshed Google+ video with ID " +
media.id);
media.meta = data.meta;
self.channel.activeLock.release();
if (cb) cb();
});
};
module.exports = MediaRefresherModule;

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

@ -0,0 +1,81 @@
function ChannelModule(channel) {
this.channel = channel;
}
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;

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

@ -0,0 +1,278 @@
var ChannelModule = require("./module");
var Config = require("../config");
var Utilities = require("../utilities");
var url = require("url");
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
allow_ascii_control: false,// Allow ASCII control characters (\x00-\x1f)
playlist_max_per_user: 0 // Maximum number of playlist items per user
};
}
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.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);
};
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;
}
if ("allow_voteskip" in data) {
this.opts.allow_voteskip = Boolean(data.allow_voteskip);
}
if ("voteskip_ratio" in data) {
var ratio = parseFloat(data.voteskip_ratio);
if (isNaN(ratio) || ratio < 0) {
ratio = 0;
}
this.opts.voteskip_ratio = ratio;
}
if ("afk_timeout" in data) {
var tm = parseInt(data.afk_timeout);
if (isNaN(tm) || tm < 0) {
tm = 0;
}
var same = tm === this.opts.afk_timeout;
this.opts.afk_timeout = tm;
if (!same) {
this.channel.users.forEach(function (u) {
u.autoAFK();
});
}
}
if ("pagetitle" in data && user.account.effectiveRank >= 3) {
var title = (""+data.pagetitle).substring(0, 100);
if (!title.trim().match(Config.get("reserved-names.pagetitles"))) {
this.opts.pagetitle = (""+data.pagetitle).substring(0, 100);
} 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;
}
if ("externalcss" in data && user.account.effectiveRank >= 3) {
var link = (""+data.externalcss).substring(0, 255);
if (!link) {
this.opts.externalcss = "";
} else {
try {
var data = url.parse(link);
if (!data.protocol || !data.protocol.match(/^(https?|ftp):/)) {
throw "Unacceptable protocol " + data.protocol;
} else if (!data.host) {
throw "URL is missing host";
} else {
link = data.href;
}
} catch (e) {
user.socket.emit("errorMsg", {
msg: "Invalid URL for external CSS: " + e,
alert: true
});
return;
}
this.opts.externalcss = link;
}
}
if ("externaljs" in data && user.account.effectiveRank >= 3) {
var link = (""+data.externaljs).substring(0, 255);
if (!link) {
this.opts.externaljs = "";
} else {
try {
var data = url.parse(link);
if (!data.protocol || !data.protocol.match(/^(https?|ftp):/)) {
throw "Unacceptable protocol " + data.protocol;
} else if (!data.host) {
throw "URL is missing host";
} else {
link = data.href;
}
} catch (e) {
user.socket.emit("errorMsg", {
msg: "Invalid URL for external JS: " + e,
alert: true
});
return;
}
this.opts.externaljs = link;
}
}
if ("chat_antiflood" in data) {
this.opts.chat_antiflood = Boolean(data.chat_antiflood);
}
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
};
}
if ("show_public" in data && user.account.effectiveRank >= 3) {
this.opts.show_public = Boolean(data.show_public);
}
if ("enable_link_regex" in data) {
this.opts.enable_link_regex = Boolean(data.enable_link_regex);
}
if ("password" in data && user.account.effectiveRank >= 3) {
var pw = data.password + "";
pw = pw === "" ? false : pw.substring(0, 100);
this.opts.password = pw;
}
if ("allow_dupes" in data) {
this.opts.allow_dupes = Boolean(data.allow_dupes);
}
if ("torbanned" in data && user.account.effectiveRank >= 3) {
this.opts.torbanned = Boolean(data.torbanned);
}
if ("allow_ascii_control" in data && user.account.effectiveRank >= 3) {
this.opts.allow_ascii_control = Boolean(data.allow_ascii_control);
}
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;
}
}
this.channel.logger.log("[mod] " + user.getName() + " updated channel options");
this.sendOpts(this.channel.users);
};
module.exports = OptionsModule;

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

@ -0,0 +1,392 @@
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
};
function PermissionsModule(channel) {
ChannelModule.apply(this, arguments);
this.permissions = {};
this.openPlaylist = false;
}
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;
}
};
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.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 (var key in perms) {
if (typeof perms[key] !== "number") {
perms[key] = parseFloat(perms[key]);
if (isNaN(perms[key])) {
delete perms[key];
}
}
}
for (var 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.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.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) {
if (actor instanceof User) {
actor = actor.account;
}
return actor.effectiveRank >= 2;
};
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
};
for (var key in perms) {
this.permissions[key] = perms[key];
}
this.openPlaylist = true;
};
module.exports = PermissionsModule;

1372
src/channel/playlist.js Normal file

File diff suppressed because it is too large Load diff

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

@ -0,0 +1,186 @@
var ChannelModule = require("./module");
var Poll = require("../poll").Poll;
const TYPE_NEW_POLL = {
title: "string",
timeout: "number,optional",
obscured: "boolean",
opts: "array"
};
const TYPE_VOTE = {
option: "number"
};
function PollModule(channel) {
ChannelModule.apply(this, arguments);
this.poll = null;
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));
}
}
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 = new Poll(data.poll.initiator, "", [], data.poll.obscured);
this.poll.title = data.poll.title;
this.poll.options = data.poll.options;
this.poll.counts = data.poll.counts;
this.poll.votes = data.poll.votes;
}
}
};
PollModule.prototype.save = function (data) {
if (this.poll === null) {
data.poll = null;
return;
}
data.poll = {
title: this.poll.title,
initiator: this.poll.initiator,
options: this.poll.options,
counts: this.poll.counts,
votes: this.poll.votes,
obscured: this.poll.obscured
};
};
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));
};
PollModule.prototype.onUserPart = function(user) {
if (this.poll) {
this.poll.unvote(user.realip);
this.sendPollUpdate(this.channel.users);
}
};
PollModule.prototype.sendPoll = function (users) {
if (!this.poll) {
return;
}
var obscured = this.poll.packUpdate(false);
var unobscured = this.poll.packUpdate(true);
var perms = this.channel.modules.permissions;
users.forEach(function (u) {
u.socket.emit("closePoll");
if (perms.canViewHiddenPoll(u)) {
u.socket.emit("newPoll", unobscured);
} else {
u.socket.emit("newPoll", obscured);
}
});
};
PollModule.prototype.sendPollUpdate = function (users) {
if (!this.poll) {
return;
}
var obscured = this.poll.packUpdate(false);
var unobscured = this.poll.packUpdate(true);
var perms = this.channel.modules.permissions;
users.forEach(function (u) {
if (perms.canViewHiddenPoll(u)) {
u.socket.emit("updatePoll", unobscured);
} else {
u.socket.emit("updatePoll", obscured);
}
});
};
PollModule.prototype.handleNewPoll = function (user, data) {
if (!this.channel.modules.permissions.canControlPoll(user)) {
return;
}
var title = data.title.substring(0, 255);
var opts = data.opts.map(function (x) { return (""+x).substring(0, 255); });
var obscured = data.obscured;
var poll = new Poll(user.getName(), title, opts, obscured);
var self = this;
if (data.hasOwnProperty("timeout") && !isNaN(data.timeout) && data.timeout > 0) {
poll.timer = setTimeout(function () {
if (self.poll === poll) {
self.handleClosePoll({
getName: function () { return "[poll timer]" },
effectiveRank: 255
});
}
}, data.timeout * 1000);
}
this.poll = poll;
this.sendPoll(this.channel.users);
this.channel.logger.log("[poll] " + user.getName() + " opened poll: '" + poll.title + "'");
};
PollModule.prototype.handleVote = function (user, data) {
if (!this.channel.modules.permissions.canVote(user)) {
return;
}
if (this.poll) {
this.poll.vote(user.realip, data.option);
this.sendPollUpdate(this.channel.users);
}
};
PollModule.prototype.handleClosePoll = function (user) {
if (!this.channel.modules.permissions.canControlPoll(user)) {
return;
}
if (this.poll) {
if (this.poll.obscured) {
this.poll.obscured = false;
this.channel.broadcastAll("updatePoll", this.poll.packUpdate(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;
}
};
PollModule.prototype.handlePollCmd = function (obscured, user, msg, meta) {
if (!this.channel.modules.permissions.canControlPoll(user)) {
return;
}
msg = msg.replace(/^\/h?poll/, "");
var args = msg.split(",");
var title = args.shift();
var poll = new Poll(user.getName(), title, args, obscured);
this.poll = poll;
this.sendPoll(this.channel.users);
this.channel.logger.log("[poll] " + user.getName() + " opened poll: '" + poll.title + "'");
};
module.exports = PollModule;

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

@ -0,0 +1,192 @@
var ChannelModule = require("./module");
var Flags = require("../flags");
var Account = require("../account");
var db = require("../database");
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;
}
receiver.account.channelRank = rank;
receiver.account.effectiveRank = Math.max(receiver.account.globalRank, rank);
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
});
}
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, { channel: this.channel.name }, function (err, rank) {
if (err) {
return cb(err);
}
if (rank >= data.userrank && !(rank === 4 && data.userrank === 4)) {
cb("You can't promote or demote someone with equal or higher rank than you.");
return;
}
db.channels.setRank(chan.name, data.name, data.rank, cb);
});
};
module.exports = RankModule;

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

@ -0,0 +1,123 @@
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 = new Poll("[server]", "voteskip", ["skip"], false);
}
this.poll.vote(user.realip, 0);
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.unvote(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;
}
var max = this.calcVoteskipMax();
var need = Math.ceil(max * this.channel.modules.options.get("voteskip_ratio"));
if (this.poll.counts[0] >= need) {
this.channel.logger.log("[playlist] Voteskip passed.");
this.channel.modules.playlist._playNext();
}
this.sendVoteskipData(this.channel.users);
};
VoteskipModule.prototype.sendVoteskipData = function (users) {
var max = this.calcVoteskipMax();
var data = {
count: this.poll ? this.poll.counts[0] : 0,
need: this.poll ? Math.ceil(max * this.channel.modules.options.get("voteskip_ratio"))
: 0
};
var perms = this.channel.modules.permissions;
users.forEach(function (u) {
if (perms.canSeeVoteskipResults(u)) {
u.socket.emit("voteskip", data);
}
});
};
VoteskipModule.prototype.calcVoteskipMax = function () {
var perms = this.channel.modules.permissions;
return this.channel.users.map(function (u) {
if (!perms.canVoteskip(u)) {
return 0;
}
return u.is(Flags.U_AFK) ? 0 : 1;
}).reduce(function (a, b) {
return a + b;
}, 0);
};
VoteskipModule.prototype.onMediaChange = function (data) {
this.poll = false;
this.sendVoteskipData(this.channel.users);
};
module.exports = VoteskipModule;

385
src/config.js Normal file
View file

@ -0,0 +1,385 @@
var fs = require("fs");
var path = require("path");
var Logger = require("./logger");
var nodemailer = require("nodemailer");
var net = require("net");
var YAML = require("yamljs");
var defaults = {
mysql: {
server: "localhost",
port: 3306,
database: "cytube3",
user: "cytube3",
password: "",
},
listen: [
{
ip: "0.0.0.0",
port: 8080,
http: true,
},
{
ip: "0.0.0.0",
port: 1337,
io: true
}
],
http: {
domain: "http://localhost",
"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"
},
https: {
enabled: false,
domain: "https://localhost",
"default-port": 8443,
keyfile: "localhost.key",
passphrase: "",
certfile: "localhost.cert",
cafile: "",
ciphers: "HIGH:!DSS:!aNULL@STRENGTH",
redirect: true
},
io: {
domain: "http://localhost",
"default-port": 1337,
"ip-connection-limit": 10
},
mail: {
enabled: false,
/* the key "config" is omitted because the format depends on the
service the owner is configuring for nodemailer */
"from-address": "some.user@gmail.com",
"from-name": "CyTube Services"
},
"youtube-v3-key": "",
"channel-save-interval": 5,
"max-channels-per-user": 5,
"max-accounts-per-ip": 5,
"guest-login-delay": 60,
stats: {
interval: 3600000,
"max-age": 86400000
},
aliases: {
"purge-interval": 3600000,
"max-age": 2592000000
},
"vimeo-workaround": false,
"vimeo-oauth": {
enabled: false,
"consumer-key": "",
secret: ""
},
"html-template": {
title: "CyTube Beta", description: "Free, open source synchtube"
},
"reserved-names": {
usernames: ["^(.*?[-_])?admin(istrator)?([-_].*)?$", "^(.*?[-_])?owner([-_].*)?$"],
channels: ["^(.*?[-_])?admin(istrator)?([-_].*)?$", "^(.*?[-_])?owner([-_].*)?$"],
pagetitles: []
},
"contacts": [
{
name: "calzoneman",
title: "Developer",
email: "cyzon@cytu.be"
}
],
"aggressive-gc": false,
playlist: {
"max-items": 4000,
"update-interval": 5
},
"channel-blacklist": [],
ffmpeg: {
enabled: false,
"ffprobe-exec": "ffprobe"
},
"link-domain-blacklist": [],
setuid: {
enabled: false,
"group": "users",
"user": "nobody",
"timeout": 15
},
};
/**
* 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.syslog.log("[WARNING] Missing config key " + (path + "." + key) +
"; using default: " + JSON.stringify(def[key]));
obj[key] = def[key];
}
}
}
var cfg = defaults;
/**
* Initializes the configuration from the given YAML file
*/
exports.load = function (file) {
try {
cfg = YAML.load(path.join(__dirname, "..", file));
} catch (e) {
if (e.code === "ENOENT") {
Logger.syslog.log(file + " does not exist, assuming default configuration");
cfg = defaults;
return;
} else {
Logger.errlog.log("Error loading config file " + file + ": ");
Logger.errlog.log(e);
if (e.stack) {
Logger.errlog.log(e.stack);
}
cfg = defaults;
return;
}
}
if (cfg == null) {
Logger.syslog.log(file + " is an Invalid configuration file, " +
"assuming default configuration");
cfg = defaults;
return;
}
var mailconfig = {};
if (cfg.mail && cfg.mail.config) {
mailconfig = cfg.mail.config;
delete cfg.mail.config;
}
merge(cfg, defaults, "config");
cfg.mail.config = mailconfig;
preprocessConfig(cfg);
Logger.syslog.log("Loaded configuration from " + file);
};
function preprocessConfig(cfg) {
/* Detect 3.0.0-style config and warng the user about it */
if ("host" in cfg.http || "port" in cfg.http || "port" in cfg.https) {
Logger.syslog.log("[WARN] The method of specifying which IP/port to bind has "+
"changed. The config loader will try to handle this "+
"automatically, but you should read config.template.yaml "+
"and change your config.yaml to the new format.");
cfg.listen = [
{
ip: cfg.http.host || "0.0.0.0",
port: cfg.http.port,
http: true
},
{
ip: cfg.http.host || "0.0.0.0",
port: cfg.io.port,
io: true
}
];
if (cfg.https.enabled) {
cfg.listen.push(
{
ip: cfg.http.host || "0.0.0.0",
port: cfg.https.port,
https: true,
io: true
}
);
}
cfg.http["default-port"] = cfg.http.port;
cfg.https["default-port"] = cfg.https.port;
cfg.io["default-port"] = cfg.io.port;
}
// Root domain should start with a . for cookies
var root = cfg.http["root-domain"];
root = root.replace(/^\.*/, "");
cfg.http["root-domain"] = root;
if (root.indexOf(".") !== -1 && !net.isIP(root)) {
root = "." + root;
}
cfg.http["root-domain-dotted"] = root;
// Setup nodemailer
cfg.mail.nodemailer = nodemailer.createTransport(
cfg.mail.config
);
// Debug
if (process.env.DEBUG === "1" || process.env.DEBUG === "true") {
cfg.debug = true;
} else {
cfg.debug = false;
}
// Strip trailing slashes from domains
cfg.http.domain = cfg.http.domain.replace(/\/*$/, "");
cfg.https.domain = cfg.https.domain.replace(/\/*$/, "");
// HTTP/HTTPS domains with port numbers
if (!cfg.http["full-address"]) {
var httpfa = cfg.http.domain;
if (cfg.http["default-port"] !== 80) {
httpfa += ":" + cfg.http["default-port"];
}
cfg.http["full-address"] = httpfa;
}
if (!cfg.https["full-address"]) {
var httpsfa = cfg.https.domain;
if (cfg.https["default-port"] !== 443) {
httpsfa += ":" + cfg.https["default-port"];
}
cfg.https["full-address"] = httpsfa;
}
// 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.errlog.log("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.errlog.log("(Listener was: " + JSON.stringify(srv) + ")");
} else {
cfg.io["ipv6-ssl"] = srv.url;
}
} else if (!cfg.io["ipv6-nossl"]) {
if (!srv.url) {
Logger.errlog.log("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.errlog.log("(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"];
// sioconfig
var sioconfig = "var IO_URLS={'ipv4-nossl':'" + cfg.io["ipv4-nossl"] + "'," +
"'ipv4-ssl':'" + cfg.io["ipv4-ssl"] + "'," +
"'ipv6-nossl':'" + cfg.io["ipv6-nossl"] + "'," +
"'ipv6-ssl':'" + cfg.io["ipv6-ssl"] + "'};";
cfg.sioconfig = sioconfig;
// 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;
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("$^", "gi");
}
if (cfg["youtube-v3-key"]) {
require("cytube-mediaquery/lib/provider/youtube").setApiKey(
cfg["youtube-v3-key"]);
} else {
Logger.errlog.log("Warning: 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.");
}
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];
};

21
src/counters.js Normal file
View file

@ -0,0 +1,21 @@
var Logger = require('./logger');
var counterLog = new Logger.Logger('counters.log');
var counters = {};
exports.add = function (counter, value) {
if (!value) {
value = 1;
}
if (!counters.hasOwnProperty(counter)) {
counters[counter] = value;
} else {
counters[counter] += value;
}
};
setInterval(function () {
counterLog.log(JSON.stringify(counters));
counters = {};
}, 60000);

99
src/customembed.js Normal file
View file

@ -0,0 +1,99 @@
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($) {
var tag = $("embed");
if (tag.length !== 0) {
return filterEmbed(tag[0]);
}
tag = $("object");
if (tag.length !== 0) {
return filterObject(tag[0]);
}
tag = $("iframe");
if (tag.length !== 0) {
return filterIframe(tag[0]);
}
throw new Error("Invalid embed. Input must be an <iframe>, <object>, or " +
"<embed> tag.");
}
const ALLOWED_PARAMS = /^(flashvars|bgcolor|movie)$/i;
function filterEmbed(tag) {
if (tag.attribs.type && tag.attribs.type !== "application/x-shockwave-flash") {
throw new Error("Invalid embed. Only type 'application/x-shockwave-flash' " +
"is allowed for <embed> tags.");
}
var meta = {
embed: {
tag: "object",
src: tag.attribs.src,
params: {}
}
}
for (var key in tag.attribs) {
if (ALLOWED_PARAMS.test(key)) {
meta.embed.params[key] = tag.attribs[key];
}
}
return meta;
}
function filterObject(tag) {
if (tag.attribs.type && tag.attribs.type !== "application/x-shockwave-flash") {
throw new Error("Invalid embed. Only type 'application/x-shockwave-flash' " +
"is allowed for <object> tags.");
}
var meta = {
embed: {
tag: "object",
src: tag.attribs.data,
params: {}
}
};
tag.children.forEach(function (child) {
if (child.name !== "param") return;
if (!ALLOWED_PARAMS.test(child.attribs.name)) return;
meta.embed.params[child.attribs.name] = child.attribs.value;
});
return meta;
}
function filterIframe(tag) {
var meta = {
embed: {
tag: "iframe",
src: tag.attribs.src
}
};
return meta;
}
exports.filter = filter;

596
src/database.js Normal file
View file

@ -0,0 +1,596 @@
var mysql = require("mysql");
var bcrypt = require("bcrypt");
var $util = require("./utilities");
var Logger = require("./logger");
var Config = require("./config");
var Server = require("./server");
var tables = require("./database/tables");
var net = require("net");
var util = require("./utilities");
var pool = null;
var global_ipbans = {};
module.exports.init = function () {
pool = mysql.createPool({
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,
charset: "UTF8MB4_GENERAL_CI" // Needed for emoji and other non-BMP unicode
});
// Test the connection
pool.getConnection(function (err, conn) {
if (err) {
Logger.errlog.log("Initial database connection failed: " + err.stack);
process.exit(1);
} else {
tables.init(module.exports.query, function (err) {
if (err) {
return;
}
require("./database/update").checkVersion();
module.exports.loadAnnouncement();
});
// Refresh global IP bans
module.exports.listGlobalBans();
}
});
global_ipbans = {};
module.exports.users = require("./database/accounts");
module.exports.channels = require("./database/channels");
};
/**
* Execute a database query
*/
module.exports.query = function (query, sub, callback) {
// 2nd argument is optional
if (typeof sub === "function") {
callback = sub;
sub = false;
}
if (typeof callback !== "function") {
callback = blackHole;
}
pool.getConnection(function (err, conn) {
if (err) {
Logger.errlog.log("! DB connection failed: " + err);
callback("Database failure", null);
} else {
function cback(err, res) {
if (err) {
Logger.errlog.log("! DB query failed: " + query);
if (sub) {
Logger.errlog.log("Substitutions: " + sub);
}
Logger.errlog.log(err);
callback("Database failure", null);
} else {
callback(null, res);
}
conn.release();
}
if (sub) {
conn.query(query, sub, cback);
} else {
conn.query(query, cback);
}
}
});
};
/**
* Dummy function to be used as a callback when none is provided
*/
function blackHole() {
}
/* REGION global bans */
/**
* Check if an IP address is globally banned
*/
module.exports.isGlobalIPBanned = function (ip, callback) {
var range = util.getIPRange(ip);
var wrange = util.getWideIPRange(ip);
var banned = ip in global_ipbans ||
range in global_ipbans ||
wrange in global_ipbans;
if (callback) {
callback(null, banned);
}
return banned;
};
/**
* Retrieve all global bans from the database.
* Cache locally in global_bans
*/
module.exports.listGlobalBans = function (callback) {
if (typeof callback !== "function") {
callback = blackHole;
}
module.exports.query("SELECT * FROM global_bans WHERE 1", function (err, res) {
if (err) {
callback(err, null);
return;
}
global_ipbans = {};
for (var i = 0; i < res.length; i++) {
global_ipbans[res[i].ip] = res[i];
}
callback(null, global_ipbans);
});
};
/**
* Globally ban by IP
*/
module.exports.globalBanIP = function (ip, reason, callback) {
if (typeof callback !== "function") {
callback = blackHole;
}
var query = "INSERT INTO global_bans (ip, reason) VALUES (?, ?)" +
" ON DUPLICATE KEY UPDATE reason=?";
module.exports.query(query, [ip, reason, reason], function (err, res) {
if(err) {
callback(err, null);
return;
}
module.exports.listGlobalBans();
callback(null, res);
});
};
/**
* Remove a global IP ban
*/
module.exports.globalUnbanIP = function (ip, callback) {
if (typeof callback !== "function") {
callback = blackHole;
}
var query = "DELETE FROM global_bans WHERE ip=?";
module.exports.query(query, [ip], function (err, res) {
if(err) {
callback(err, null);
return;
}
module.exports.listGlobalBans();
callback(null, res);
});
};
/* END REGION */
/* 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 aliases WHERE time < ?";
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]);
};
/*
module.exports.genPasswordReset = function (ip, name, email, callback) {
if(typeof callback !== "function")
callback = blackHole;
var query = "SELECT email FROM registrations WHERE uname=?";
module.exports.query(query, [name], function (err, res) {
if(err) {
callback(err, null);
return;
}
if(res.length == 0) {
callback("Provided username does not exist", null);
return;
}
if(res[0].email != email) {
callback("Provided email does not match user's email", null);
return;
}
var hash = hashlib.sha256($util.randomSalt(32) + name);
var expire = Date.now() + 24*60*60*1000;
query = "INSERT INTO password_reset " +
"(ip, name, hash, email, expire) VALUES (?, ?, ?, ?, ?) " +
"ON DUPLICATE KEY UPDATE hash=?, expire=?";
module.exports.query(query, [ip, name, hash, email, expire, hash, expire],
function (err, res) {
if(err) {
callback(err, null);
return;
}
callback(null, hash);
});
});
};
module.exports.recoverUserPassword = function (hash, callback) {
if(typeof callback !== "function")
callback = blackHole;
var query = "SELECT * FROM password_reset WHERE hash=?";
module.exports.query(query, [hash], function (err, res) {
if(err) {
callback(err, null);
return;
}
if(res.length == 0) {
callback("Invalid password reset link", null);
return;
}
if(Date.now() > res[0].expire) {
module.exports.query("DELETE FROM password_reset WHERE hash=?", [hash]);
callback("Link expired. Password resets are valid for 24hr",
null);
return;
}
var name = res[0].name;
resetUserPassword(res[0].name, function (err, pw) {
if(err) {
callback(err, null);
return;
}
module.exports.query("DELETE FROM password_reset WHERE hash=?", [hash]);
callback(null, {
name: name,
pw: pw
});
});
});
};
module.exports.resetUserPassword = function (name, callback) {
if(typeof callback !== "function")
callback = blackHole;
var pwChars = "abcdefghijkmnopqrstuvwxyz023456789";
var pw = "";
for(var i = 0; i < 10; i++)
pw += pwChars[parseInt(Math.random() * 33)];
bcrypt.hash(pw, 10, function (err, data) {
if(err) {
Logger.errlog.log("bcrypt error: " + err);
callback("Password reset failure", null);
return;
}
var query = "UPDATE registrations SET pw=? WHERE uname=?";
module.exports.query(query, [data, name], function (err, res) {
if(err) {
callback(err, null);
return;
}
callback(null, pw);
});
});
};
*/
/* 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 */
/* REGION stats */
module.exports.addStatPoint = function (time, ucount, ccount, mem, callback) {
if (typeof callback !== "function") {
callback = blackHole;
}
var query = "INSERT INTO stats VALUES (?, ?, ?, ?)";
module.exports.query(query, [time, ucount, ccount, mem], callback);
};
module.exports.pruneStats = function (before, callback) {
if (typeof callback !== "function") {
callback = blackHole;
}
var query = "DELETE FROM stats WHERE time < ?";
module.exports.query(query, [before], callback);
};
module.exports.listStats = function (callback) {
if (typeof callback !== "function") {
return;
}
var query = "SELECT * FROM stats ORDER BY time ASC";
module.exports.query(query, callback);
};
/* 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.errlog.log("Invalid announcement data in database: " +
announcement.value);
module.exports.clearAnnouncement();
return;
}
var sv = Server.getServer();
sv.announcement = announcement;
for (var id in sv.ioServers) {
sv.ioServers[id].sockets.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'");
};

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

@ -0,0 +1,564 @@
var $util = require("../utilities");
var bcrypt = require("bcrypt");
var db = require("../database");
var Config = require("../config");
var Logger = require("../logger");
var registrationLock = {};
var blackHole = function () { };
/**
* Replaces look-alike characters with "_" (single character wildcard) for
* use in LIKE queries. This prevents guests from taking names that look
* visually identical to existing names in certain fonts.
*/
function wildcardSimilarChars(name) {
return name.replace(/_/g, "\\_").replace(/[Il1oO0]/g, "_");
}
module.exports = {
init: function () {
},
/**
* Check if a username is taken
*/
isUsernameTaken: function (name, callback) {
db.query("SELECT name FROM `users` WHERE name LIKE ? ESCAPE '\\\\'",
[wildcardSimilarChars(name)],
function (err, rows) {
if (err) {
callback(err, true);
return;
}
callback(null, rows.length > 0);
});
},
/**
* Search for a user by name
*/
search: function (name, fields, callback) {
/* This bit allows it to accept varargs
Function can be called as (name, callback) or
(name, fields, callback)
*/
if (typeof callback !== "function") {
if (typeof fields === "function") {
callback = fields;
fields = ["name"];
} else {
return;
}
}
// 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 name LIKE ?",
["%"+name+"%"],
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 = ?", [name], function (err, rows) {
if (err) {
callback(err, true);
return;
}
if (rows.length !== 1) {
return callback("User does not exist");
}
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) {
if (err) {
delete registrationLock[lname];
callback(err, null);
return;
}
if (taken) {
delete registrationLock[lname];
callback("Username is already registered", 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`)" +
" VALUES " +
"(?, ?, ?, ?, '', ?, ?)",
[name, hash, 1, email, ip, Date.now()],
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;
}
/* 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 name,password,global_rank FROM `users` WHERE name=?",
[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 {
callback(null, rows[0]);
}
});
});
},
/**
* Verify an auth string of the form name:hash
*/
verifyAuth: function (auth, callback) {
if (typeof callback !== "function") {
return;
}
if (typeof auth !== "string") {
callback("Invalid auth string", null);
return;
}
var split = auth.split(":");
if (split.length !== 2) {
callback("Invalid auth string", null);
return;
}
var name = split[0];
var hash = split[1];
db.query("SELECT name,password,global_rank FROM `users` WHERE " +
"name=? and password=?", [name, hash],
function (err, rows) {
if (err) {
callback(err, null);
return;
}
if (rows.length === 0) {
callback("Auth string does not match an existing user", null);
return;
}
callback(null, {
name: rows[0].name,
hash: rows[0].password,
global_rank: rows[0].global_rank
});
});
},
/**
* 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=?", [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.errlog.log("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);
});
},
generatePasswordReset: function (ip, name, email, callback) {
if (typeof callback !== "function") {
return;
}
callback("generatePasswordReset is not implemented", null);
},
recoverPassword: function (hash, callback) {
if (typeof callback !== "function") {
return;
}
callback("recoverPassword is not implemented", null);
},
/**
* Retrieve a list of channels owned by a user
*/
getChannels: function (name, callback) {
if (typeof callback !== "function") {
return;
}
db.query("SELECT * FROM `channels` WHERE owner=?", [name], callback);
},
/**
* 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);
}
};

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

@ -0,0 +1,621 @@
var db = require("../database");
var valid = require("../utilities").isValidChannelName;
var fs = require("fs");
var path = require("path");
var Logger = require("../logger");
var tables = require("./tables");
var Flags = require("../flags");
var util = require("../utilities");
var blackHole = function () { };
function dropTable(name, callback) {
db.query("DROP TABLE `" + name + "`", callback);
}
function initTables(name, owner, callback) {
if (!valid(name)) {
callback("Invalid channel name", null);
return;
}
}
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`) VALUES (?, ?, ?)",
[name, owner, Date.now()],
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.errlog.log("Failed to delete bans for " + name + ": " + err);
}
});
module.exports.deleteLibrary(name, function (err) {
if (err) {
Logger.errlog.log("Failed to delete library for " + name + ": " + err);
}
});
module.exports.deleteAllRanks(name, function (err) {
if (err) {
Logger.errlog.log("Failed to delete ranks for " + name + ": " + err);
}
});
fs.unlink(path.join(__dirname, "..", "..", "chandump", name),
function (err) {
if (err && err.code !== "ENOENT") {
Logger.errlog.log("Deleting chandump failed:");
Logger.errlog.log(err);
}
});
callback(err, !Boolean(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);
});
},
/**
* 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.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 */
names.push(chan);
db.query("SELECT * FROM `channel_ranks` WHERE name IN " +
replace + " AND channel=?", names,
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, chan], 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
});
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);
},
/**
* 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);
});
},
/**
* 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);
}
};

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

@ -0,0 +1,141 @@
const TBL_USERS = "" +
"CREATE TABLE IF NOT EXISTS `users` (" +
"`id` INT NOT NULL AUTO_INCREMENT," +
"`name` VARCHAR(20) NOT NULL," +
"`password` VARCHAR(64) NOT NULL," +
"`global_rank` INT NOT NULL," +
"`email` VARCHAR(255) NOT NULL," +
"`profile` TEXT CHARACTER SET utf8mb4 NOT NULL," +
"`ip` VARCHAR(39) NOT NULL," +
"`time` BIGINT NOT NULL," +
"PRIMARY KEY(`id`)," +
"UNIQUE(`name`)) " +
"CHARACTER SET utf8";
const TBL_CHANNELS = "" +
"CREATE TABLE IF NOT EXISTS `channels` (" +
"`id` INT NOT NULL AUTO_INCREMENT," +
"`name` VARCHAR(30) NOT NULL," +
"`owner` VARCHAR(20) NOT NULL," +
"`time` BIGINT NOT NULL," +
"PRIMARY KEY (`id`), UNIQUE(`name`), INDEX(`owner`))" +
"CHARACTER SET utf8";
const TBL_GLOBAL_BANS = "" +
"CREATE TABLE IF NOT EXISTS `global_bans` (" +
"`ip` VARCHAR(39) NOT NULL," +
"`reason` VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL," +
"PRIMARY KEY (`ip`)) " +
"CHARACTER SET utf8";
const TBL_PASSWORD_RESET = "" +
"CREATE TABLE IF NOT EXISTS `password_reset` (" +
"`ip` VARCHAR(39) NOT NULL," +
"`name` VARCHAR(20) NOT NULL," +
"`hash` VARCHAR(64) NOT NULL," +
"`email` VARCHAR(255) NOT NULL," +
"`expire` BIGINT NOT NULL," +
"PRIMARY KEY (`name`))" +
"CHARACTER SET utf8";
const TBL_USER_PLAYLISTS = "" +
"CREATE TABLE IF NOT EXISTS `user_playlists` (" +
"`user` VARCHAR(20) NOT NULL," +
"`name` VARCHAR(255) NOT NULL," +
"`contents` MEDIUMTEXT NOT NULL," +
"`count` INT NOT NULL," +
"`duration` INT NOT NULL," +
"PRIMARY KEY (`user`, `name`))" +
"CHARACTER SET utf8";
const TBL_ALIASES = "" +
"CREATE TABLE IF NOT EXISTS `aliases` (" +
"`visit_id` INT NOT NULL AUTO_INCREMENT," +
"`ip` VARCHAR(39) NOT NULL," +
"`name` VARCHAR(20) NOT NULL," +
"`time` BIGINT NOT NULL," +
"PRIMARY KEY (`visit_id`), INDEX (`ip`)" +
")";
const TBL_STATS = "" +
"CREATE TABLE IF NOT EXISTS `stats` (" +
"`time` BIGINT NOT NULL," +
"`usercount` INT NOT NULL," +
"`chancount` INT NOT NULL," +
"`mem` INT NOT NULL," +
"PRIMARY KEY (`time`))" +
"CHARACTER SET utf8";
const TBL_META = "" +
"CREATE TABLE IF NOT EXISTS `meta` (" +
"`key` VARCHAR(255) NOT NULL," +
"`value` TEXT NOT NULL," +
"PRIMARY KEY (`key`))" +
"CHARACTER SET utf8";
const TBL_LIBRARIES = "" +
"CREATE TABLE IF NOT EXISTS `channel_libraries` (" +
"`id` VARCHAR(255) NOT NULL," +
"`title` VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL," +
"`seconds` INT NOT NULL," +
"`type` VARCHAR(2) NOT NULL," +
"`meta` TEXT NOT NULL," +
"`channel` VARCHAR(30) NOT NULL," +
"PRIMARY KEY(`id`, `channel`), INDEX(`channel`, `title`)" +
") CHARACTER SET utf8";
const TBL_RANKS = "" +
"CREATE TABLE IF NOT EXISTS `channel_ranks` (" +
"`name` VARCHAR(20) NOT NULL," +
"`rank` INT NOT NULL," +
"`channel` VARCHAR(30) NOT NULL," +
"PRIMARY KEY(`name`, `channel`)" +
") CHARACTER SET utf8";
const TBL_BANS = "" +
"CREATE TABLE IF NOT EXISTS `channel_bans` (" +
"`id` INT NOT NULL AUTO_INCREMENT," +
"`ip` VARCHAR(39) NOT NULL," +
"`name` VARCHAR(20) NOT NULL," +
"`bannedby` VARCHAR(20) NOT NULL," +
"`reason` VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL," +
"`channel` VARCHAR(30) NOT NULL," +
"PRIMARY KEY (`id`, `channel`), UNIQUE (`name`, `ip`, `channel`), " +
"INDEX (`ip`, `channel`), INDEX (`name`, `channel`)" +
") CHARACTER SET utf8";
module.exports.init = function (queryfn, cb) {
var tables = {
users: TBL_USERS,
channels: TBL_CHANNELS,
channel_libraries: TBL_LIBRARIES,
channel_ranks: TBL_RANKS,
channel_bans: TBL_BANS,
global_bans: TBL_GLOBAL_BANS,
password_reset: TBL_PASSWORD_RESET,
user_playlists: TBL_USER_PLAYLISTS,
aliases: TBL_ALIASES,
stats: TBL_STATS,
meta: TBL_META
};
var AsyncQueue = require("../asyncqueue");
var aq = new AsyncQueue();
var hasError = false;
Object.keys(tables).forEach(function (tbl) {
aq.queue(function (lock) {
queryfn(tables[tbl], function (err) {
if (err) {
console.log(err);
hasError = true;
}
lock.release();
});
});
});
aq.queue(function (lock) {
lock.release();
cb(hasError);
});
};

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

@ -0,0 +1,332 @@
var db = require("../database");
var Logger = require("../logger");
var Q = require("q");
const DB_VERSION = 7;
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.errlog.log("[Warning] 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.syslog.log("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 < 3 && hasUpdates.indexOf(2) < 0) {
addMetaColumnToLibraries(cb);
} else if (version < 4) {
Q.allSettled([
Q.nfcall(mergeChannelLibraries),
Q.nfcall(mergeChannelRanks),
Q.nfcall(mergeChannelBans)
]).done(function () {
Logger.syslog.log("Merged channel tables. Please verify that everything " +
"is working correctly, and then type '/delete_old_tables'" +
" into the CyTube process to remove the unused tables.");
cb();
})
} else if (version < 5) {
fixUtf8mb4(cb);
} else if (version < 6) {
fixCustomEmbeds(cb);
} else if (version < 7) {
fixCustomEmbedsInUserPlaylists(cb);
}
}
function addMetaColumnToLibraries(cb) {
Logger.syslog.log("[database] db version indicates channel libraries don't have " +
"meta column. Updating...");
Q.nfcall(db.query, "SHOW TABLES")
.then(function (rows) {
rows = rows.map(function (r) {
return r[Object.keys(r)[0]];
}).filter(function (r) {
return r.match(/_library$/);
});
var queue = [];
rows.forEach(function (table) {
queue.push(Q.nfcall(db.query, "ALTER TABLE `" + table + "` ADD meta TEXT")
.then(function () {
Logger.syslog.log("Added meta column to " + table);
})
);
});
return Q.all(queue);
}).catch(function (err) {
Logger.errlog.log("Adding meta column to library tables failed: " + err);
}).done(cb);
}
function mergeChannelLibraries(cb) {
Q.nfcall(db.query, "SHOW TABLES")
.then(function (rows) {
rows = rows.map(function (r) {
return r[Object.keys(r)[0]];
}).filter(function (r) {
return r.match(/chan_(.*)?_library$/);
});
var queue = [];
rows.forEach(function (table) {
var name = table.match(/chan_(.*?)_library$/)[1];
queue.push(Q.nfcall(db.query,
"INSERT INTO `channel_libraries` SELECT id, title, seconds, type, meta, ?" +
" AS channel FROM `" + table + "`", [name])
.then(function () {
Logger.syslog.log("Copied " + table + " to channel_libraries");
}).catch(function (err) {
Logger.errlog.log("Copying " + table + " to channel_libraries failed: " +
err);
if (err.stack) {
Logger.errlog.log(err.stack);
}
})
);
});
return Q.all(queue);
}).catch(function (err) {
Logger.errlog.log("Copying libraries to channel_libraries failed: " + err);
if (err.stack) {
Logger.errlog.log(err.stack);
}
}).done(function () { cb(null); });
}
function mergeChannelRanks(cb) {
Q.nfcall(db.query, "SHOW TABLES")
.then(function (rows) {
rows = rows.map(function (r) {
return r[Object.keys(r)[0]];
}).filter(function (r) {
return r.match(/chan_(.*?)_ranks$/);
});
var queue = [];
rows.forEach(function (table) {
var name = table.match(/chan_(.*?)_ranks$/)[1];
queue.push(Q.nfcall(db.query,
"INSERT INTO `channel_ranks` SELECT name, rank, ?" +
" AS channel FROM `" + table + "`", [name])
.then(function () {
Logger.syslog.log("Copied " + table + " to channel_ranks");
}).catch(function (err) {
Logger.errlog.log("Copying " + table + " to channel_ranks failed: " +
err);
if (err.stack) {
Logger.errlog.log(err.stack);
}
})
);
});
return Q.all(queue);
}).catch(function (err) {
Logger.errlog.log("Copying ranks to channel_ranks failed: " + err);
if (err.stack) {
Logger.errlog.log(err.stack);
}
}).done(function () { cb(null); });
}
function mergeChannelBans(cb) {
Q.nfcall(db.query, "SHOW TABLES")
.then(function (rows) {
rows = rows.map(function (r) {
return r[Object.keys(r)[0]];
}).filter(function (r) {
return r.match(/chan_(.*?)_bans$/);
});
var queue = [];
rows.forEach(function (table) {
var name = table.match(/chan_(.*?)_bans$/)[1];
queue.push(Q.nfcall(db.query,
"INSERT INTO `channel_bans` SELECT id, ip, name, bannedby, reason, ?" +
" AS channel FROM `" + table + "`", [name])
.then(function () {
Logger.syslog.log("Copied " + table + " to channel_bans");
}).catch(function (err) {
Logger.errlog.log("Copying " + table + " to channel_bans failed: " +
err);
if (err.stack) {
Logger.errlog.log(err.stack);
}
})
);
});
return Q.all(queue);
}).catch(function (err) {
Logger.errlog.log("Copying ranks to channel_bans failed: " + err);
if (err.stack) {
Logger.errlog.log(err.stack);
}
}).done(function () { cb(null); });
}
module.exports.deleteOldChannelTables = function (cb) {
Q.nfcall(db.query, "SHOW TABLES")
.then(function (rows) {
rows = rows.map(function (r) {
return r[Object.keys(r)[0]];
}).filter(function (r) {
return r.match(/chan_(.*?)_(library|ranks|bans)$/);
});
var queue = [];
rows.forEach(function (table) {
queue.push(Q.nfcall(db.query, "DROP TABLE `" + table + "`")
.then(function () {
Logger.syslog.log("Deleted " + table);
}).catch(function (err) {
Logger.errlog.log("Deleting " + table + " failed: " + err);
if (err.stack) {
Logger.errlog.log(err.stack);
}
})
);
});
return Q.all(queue);
}).catch(function (err) {
Logger.errlog.log("Deleting old tables failed: " + err);
if (err.stack) {
Logger.errlog.log(err.stack);
}
}).done(cb);
};
function fixUtf8mb4(cb) {
var queries = [
"ALTER TABLE `users` MODIFY `profile` TEXT CHARACTER SET utf8mb4 NOT NULL",
"ALTER TABLE `global_bans` MODIFY `reason` VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL",
"ALTER TABLE `channel_libraries` MODIFY `title` VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL",
"ALTER TABLE `channel_bans` MODIFY `reason` VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL"
];
Q.allSettled(queries.map(function (query) {
return Q.nfcall(db.query, query);
})).then(function () {
Logger.syslog.log("Fixed utf8mb4");
cb();
}).catch(function (e) {
Logger.errlog.log("Failed to fix utf8mb4: " + e);
});
};
function fixCustomEmbeds(cb) {
var CustomEmbedFilter = require("../customembed").filter;
Q.nfcall(db.query, "SELECT * FROM `channel_libraries` WHERE type='cu'")
.then(function (rows) {
var all = [];
rows.forEach(function (row) {
if (row.id.indexOf("cu:") === 0) return;
all.push(Q.nfcall(db.query, "DELETE FROM `channel_libraries` WHERE `id`=? AND `channel`=?",
[row.id, row.channel]));
try {
var media = CustomEmbedFilter(row.id);
all.push(Q.nfcall(db.channels.addToLibrary, row.channel, media));
} catch(e) {
console.error("WARNING: Unable to convert " + row.id);
}
});
Q.allSettled(all).then(function () {
Logger.syslog.log("Converted custom embeds.");
cb();
});
});
}
function fixCustomEmbedsInUserPlaylists(cb) {
var CustomEmbedFilter = require("../customembed").filter;
Q.nfcall(db.query, "SELECT * FROM `user_playlists` WHERE `contents` LIKE '%\"type\":\"cu\"%'")
.then(function (rows) {
var all = [];
rows.forEach(function (row) {
var data;
try {
data = JSON.parse(row.contents);
} catch (e) {
return;
}
var updated = [];
var item;
while ((item = data.shift()) !== undefined) {
if (item.type !== "cu") {
updated.push(item);
continue;
}
if (/^cu:/.test(item.id)) {
updated.push(item);
continue;
}
var media;
try {
media = CustomEmbedFilter(item.id);
} catch (e) {
Logger.syslog.log("WARNING: Unable to convert " + item.id);
continue;
}
updated.push({
id: media.id,
title: item.title,
seconds: media.seconds,
type: media.type,
meta: {
embed: media.meta.embed
}
});
all.push(Q.nfcall(db.query, "UPDATE `user_playlists` SET `contents`=?, `count`=? WHERE `user`=? AND `name`=?",
[JSON.stringify(updated), updated.length, row.user, row.name]));
}
});
Q.allSettled(all).then(function () {
Logger.syslog.log('Fixed custom embeds in user_playlists');
cb();
});
}).catch(function (err) {
Logger.errlog.log(err.stack);
});
}

72
src/emitter.js Normal file
View file

@ -0,0 +1,72 @@
function MakeEmitter(obj) {
obj.__evHandlers = {};
obj.on = function (ev, fn) {
if (!(ev in this.__evHandlers)) {
this.__evHandlers[ev] = [];
}
this.__evHandlers[ev].push({
fn: fn,
remove: false
});
};
obj.once = function (ev, fn) {
if (!(ev in this.__evHandlers)) {
this.__evHandlers[ev] = [];
}
this.__evHandlers[ev].push({
fn: fn,
remove: true
});
};
obj.emit = function (ev /*, arguments */) {
var self = this;
var handlers = self.__evHandlers[ev];
if (!(handlers instanceof Array)) {
handlers = [];
} else {
handlers = Array.prototype.slice.call(handlers);
}
var args = Array.prototype.slice.call(arguments);
args.shift();
handlers.forEach(function (handler) {
setImmediate(function () {
handler.fn.apply(self, args);
});
if (handler.remove) {
var i = self.__evHandlers[ev].indexOf(handler);
if (i >= 0) {
self.__evHandlers[ev].splice(i, 1);
}
}
});
};
obj.unbind = function (ev, fn) {
var self = this;
if (ev in self.__evHandlers) {
if (!fn) {
self.__evHandlers[ev] = [];
} else {
var j = -1;
for (var i = 0; i < self.__evHandlers[ev].length; i++) {
if (self.__evHandlers[ev][i].fn === fn) {
j = i;
break;
}
}
if (j >= 0) {
self.__evHandlers[ev].splice(j, 1);
}
}
}
};
}
module.exports = MakeEmitter;

308
src/ffmpeg.js Normal file
View file

@ -0,0 +1,308 @@
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");
require("status-message-polyfill");
var USE_JSON = true;
var TIMEOUT = 30000;
var acceptedCodecs = {
"mov/h264": true,
"flv/h264": true,
"matroska/vp8": true,
"matroska/vp9": true,
"ogg/theora": true
};
var acceptedAudioCodecs = {
"mp3": true,
"vorbis": true
};
var audioOnlyContainers = {
"mp3": true
};
function fflog() { }
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 testUrl(url, cb, redirCount) {
if (!redirCount) redirCount = 0;
var data = urlparse.parse(url);
if (!/https?:/.test(data.protocol)) {
return cb("Video links must start with http:// or https://");
}
if (!data.hostname) {
return cb("Invalid link");
}
var transport = (data.protocol === "https:") ? https : http;
data.method = "HEAD";
var req = transport.request(data, function (res) {
req.abort();
if (res.statusCode === 301 || res.statusCode === 302) {
if (redirCount > 2) {
return cb("Too many redirects. Please provide a direct link to the " +
"file");
}
return testUrl(res.headers["location"], cb, redirCount + 1);
}
if (res.statusCode !== 200) {
var message = res.statusMessage;
if (!message) message = "";
return cb("HTTP " + res.statusCode + " " + message);
}
if (!/^audio|^video/.test(res.headers["content-type"])) {
return cb("Server did not return an audio or video file, or sent the " +
"wrong Content-Type");
}
cb();
});
req.on("error", function (err) {
cb(err);
});
req.end();
}
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 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];
data.streams.forEach(function (stream) {
if (stream.codec_type === "video" &&
acceptedCodecs.hasOwnProperty(container + "/" + stream.codec_name)) {
reformatted.vcodec = stream.codec_name;
if (!reformatted.title && stream.tags) {
reformatted.title = stream.tags.title;
}
} else if (stream.codec_type === "audio") {
reformatted.acodec = stream.codec_name;
}
});
if (reformatted.vcodec && !(audioOnlyContainers.hasOwnProperty(container))) {
reformatted.type = [container, reformatted.vcodec].join("/");
reformatted.medium = "video";
} else if (reformatted.acodec) {
reformatted.type = [container, reformatted.acodec].join("/");
reformatted.medium = "audio";
}
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);
var child = spawn(Config.get("ffmpeg.ffprobe-exec"), args);
var stdout = "";
var stderr = "";
var timer = setTimeout(function () {
Logger.errlog.log("Possible runaway ffprobe process for file " + filename);
fflog("Killing ffprobe for " + filename + " after " + (TIMEOUT/1000) + " seconds");
childErr = new Error("File query exceeded time limit of " + (TIMEOUT/1000) +
" seconds");
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("Remote server closed connection unexpectedly");
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.errlog.log("Warning: 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 HTTP " +
"or HTTPS");
}
testUrl(filename, 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");
if (err.message.match(/exceeded time limit/) ||
err.message.match(/remote server closed/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.errlog.log(err.stack || err);
return cb("Unable to query file data with ffmpeg");
} else {
if (!/(av|ff)probe/.test(String(err)))
Logger.errlog.log(err.stack || err);
return cb("Unable to query file data with ffmpeg");
}
}
try {
data = reformatData(data);
} catch (e) {
Logger.errlog.log(e.stack || e);
return cb("Unable to query file data with ffmpeg");
}
if (data.medium === "video") {
if (!acceptedCodecs.hasOwnProperty(data.type)) {
return cb("Unsupported video codec " + data.type);
}
data = {
title: data.title || "Raw Video",
duration: data.duration,
bitrate: data.bitrate,
codec: data.type
};
cb(null, data);
} else if (data.medium === "audio") {
if (!acceptedAudioCodecs.hasOwnProperty(data.acodec)) {
return cb("Unsupported audio codec " + data.acodec);
}
data = {
title: data.title || "Raw Audio",
duration: data.duration,
bitrate: data.bitrate,
codec: data.acodec
};
cb(null, data);
} else {
return cb("Parsed metadata did not contain a valid video or audio " +
"stream. Either the file is invalid or it has a format " +
"unsupported by this server's version of ffmpeg.");
}
});
});
};

14
src/flags.js Normal file
View file

@ -0,0 +1,14 @@
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
};

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

@ -0,0 +1,599 @@
var http = require("http");
var https = require("https");
var cheerio = require('cheerio');
var Logger = require("./logger.js");
var Media = require("./media");
var CustomEmbedFilter = require("./customembed").filter;
var Server = require("./server");
var Config = require("./config");
var ffmpeg = require("./ffmpeg");
var mediaquery = require("cytube-mediaquery");
var YouTube = require("cytube-mediaquery/lib/provider/youtube");
/*
* Preference map of quality => youtube formats.
* see https://en.wikipedia.org/wiki/Youtube#Quality_and_codecs
*
* Prefer WebM over MP4, ignore other codecs (e.g. FLV)
*/
const GOOGLE_PREFERENCE = {
"hd1080": [37, 46],
"hd720": [22, 45],
"large": [59, 44],
"medium": [18, 43, 34] // 34 is 360p FLV as a last-ditch
};
const CONTENT_TYPES = {
43: "webm",
44: "webm",
45: "webm",
46: "webm",
18: "mp4",
22: "mp4",
37: "mp4",
59: "mp4",
34: "flv"
};
var urlRetrieve = function (transport, options, callback) {
var req = transport.request(options, function (res) {
res.on("error", function (err) {
Logger.errlog.log("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.errlog.log("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;
}
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;
}
if (Config.get("vimeo-oauth.enabled")) {
return Getters.vi_oauth(id, callback);
}
var options = {
host: "vimeo.com",
port: 443,
path: "/api/v2/video/" + id + ".json",
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);
data = data[0];
var seconds = data.duration;
var title = data.title;
var media = new Media(id, title, seconds, "vi");
callback(false, media);
} catch(e) {
var err = e;
/**
* This should no longer be necessary as the outer handler
* checks for HTTP 404
*/
if (buffer.match(/not found/))
err = "Video not found";
callback(err, null);
}
});
},
vi_oauth: function (id, callback) {
var OAuth = require("oauth");
var oa = new OAuth.OAuth(
"https://vimeo.com/oauth/request_token",
"https://vimeo.com/oauth/access_token",
Config.get("vimeo-oauth.consumer-key"),
Config.get("vimeo-oauth.secret"),
"1.0",
null,
"HMAC-SHA1"
);
oa.get("https://vimeo.com/api/rest/v2?format=json" +
"&method=vimeo.videos.getInfo&video_id=" + id,
null,
null,
function (err, data, res) {
if (err) {
return callback(err, null);
}
try {
data = JSON.parse(data);
if (data.stat !== "ok") {
return callback(data.err.msg, null);
}
var video = data.video[0];
if (video.embed_privacy !== "anywhere") {
return callback("Embedding disabled", null);
}
var id = video.id;
var seconds = parseInt(video.duration);
var title = video.title;
callback(null, new Media(id, title, seconds, "vi"));
} catch (e) {
callback("Error handling Vimeo response", null);
}
});
},
/* 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 */
sc: function (id, callback) {
/* TODO: require server owners to register their own API key, put in config */
const SC_CLIENT = "2e0c82ab5a020f3a7509318146128abd";
var m = id.match(/([\w-\/\.:]+)/);
if (m) {
id = m[1];
} else {
callback("Invalid ID", null);
return;
}
var options = {
host: "api.soundcloud.com",
port: 443,
path: "/resolve.json?url=" + id + "&client_id=" + SC_CLIENT,
method: "GET",
dataType: "jsonp",
timeout: 1000
};
urlRetrieve(https, options, function (status, data) {
switch (status) {
case 200:
case 302:
break; /* Request is OK, skip to handling data */
case 400:
return callback("Invalid request", null);
case 403:
return callback("Private sound", null);
case 404:
return callback("Sound not found", null);
case 500:
case 503:
return callback("Service unavailable", null);
default:
return callback("HTTP " + status, null);
}
var track = null;
try {
data = JSON.parse(data);
track = data.location;
} catch(e) {
callback(e, null);
return;
}
var options2 = {
host: "api.soundcloud.com",
port: 443,
path: track,
method: "GET",
dataType: "jsonp",
timeout: 1000
};
/**
* There has got to be a way to directly get the data I want without
* making two requests to Soundcloud...right?
* ...right?
*/
urlRetrieve(https, options2, 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 sound", null);
case 404:
return callback("Sound not found", null);
case 500:
case 503:
return callback("Service unavailable", null);
default:
return callback("HTTP " + status, null);
}
try {
data = JSON.parse(data);
var seconds = data.duration / 1000;
var title = data.title;
var meta = {};
if (data.sharing === "private" && data.embeddable_by === "all") {
meta.scuri = data.uri;
}
var media = new Media(id, title, seconds, "sc", meta);
callback(false, media);
} catch(e) {
callback(e, null);
}
});
});
},
/* 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);
},
/* ustream.tv */
us: function (id, callback) {
/**
*2013-09-17
* They couldn't fucking decide whether channels should
* be at http://www.ustream.tv/channel/foo or just
* http://www.ustream.tv/foo so they do both.
* [](/cleese)
*/
var m = id.match(/([^\?&#]+)|(channel\/[^\?&#]+)/);
if (m) {
id = m[1];
} else {
callback("Invalid ID", null);
return;
}
var options = {
host: "www.ustream.tv",
port: 80,
path: "/" + id,
method: "GET",
timeout: 1000
};
urlRetrieve(http, options, function (status, data) {
if(status !== 200) {
callback("Ustream HTTP " + status, null);
return;
}
/**
* Regexing the ID out of the HTML because
* Ustream's API is so horribly documented
* I literally could not figure out how to retrieve
* this information.
*
* [](/eatadick)
*/
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);
}
});
},
/* JWPlayer */
jw: function (id, callback) {
var title = "JWPlayer - " + id;
var media = new Media(id, title, "--:--", "jw");
callback(false, media);
},
/* rtmp stream */
rt: function (id, callback) {
var title = "Livestream";
var media = new Media(id, title, "--:--", "rt");
callback(false, media);
},
/* imgur.com albums */
im: function (id, callback) {
/**
* TODO: Consider deprecating this in favor of custom embeds
*/
var m = id.match(/([\w-]+)/);
if (m) {
id = m[1];
} else {
callback("Invalid ID", null);
return;
}
var title = "Imgur Album - " + id;
var media = new Media(id, title, "--:--", "im");
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.errlog.log(e.stack);
return callback("Unknown error processing embed");
}
}
callback(false, media);
},
/* google docs */
gd: function (id, callback) {
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);
});
},
/* Google+ videos */
gp: function (id, callback) {
var data = {
type: "google+",
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 */
hb: function (id, callback) {
var m = id.match(/([\w-]+)/);
if (m) {
id = m[1];
} else {
callback("Invalid ID", null);
return;
}
var title = "Hitbox.tv - " + id;
var media = new Media(id, title, "--:--", "hb");
callback(false, media);
},
};
module.exports = {
Getters: Getters,
getMedia: function (id, type, callback) {
if(type in this.Getters) {
this.Getters[type](id, callback);
} else {
callback("Unknown media type '" + type + "'", null);
}
}
};

195
src/google2vtt.js Normal file
View file

@ -0,0 +1,195 @@
var cheerio = require('cheerio');
var https = require('https');
var fs = require('fs');
var path = require('path');
var querystring = require('querystring');
var crypto = require('crypto');
var Logger = require('./logger');
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.errlog.log(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.syslog.log('Saved subtitle file ' + file);
cb();
}
});
});
}).on('error', function (err) {
cb(err);
});
}
function clearOldSubtitles() {
fs.readdir(subtitleDir, function (err, files) {
if (err) {
Logger.errlog.log(err.stack);
return;
}
files.forEach(function (file) {
fs.stat(path.join(subtitleDir, file), function (err, stats) {
if (err) {
Logger.errlog.log(err.stack);
return;
}
if (stats.mtime.getTime() < Date.now() - ONE_DAY) {
Logger.syslog.log('Deleting old subtitle file: ' + file);
fs.unlink(path.join(subtitleDir, file));
}
});
});
});
}
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();

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

@ -0,0 +1,258 @@
var sio = require("socket.io");
var Logger = require("../logger");
var db = require("../database");
var User = require("../user");
var Server = require("../server");
var Config = require("../config");
var cookieParser = require("cookie-parser")(Config.get("http.cookie-secret"));
var $util = require("../utilities");
var Flags = require("../flags");
var Account = require("../account");
var typecheck = require("json-typecheck");
var net = require("net");
var util = require("../utilities");
var crypto = require("crypto");
var isTorExit = require("../tor").isTorExit;
var session = require("../session");
var CONNECT_RATE = {
burst: 5,
sustained: 0.1
};
var ipThrottle = {};
// Keep track of number of connections per IP
var ipCount = {};
/**
* Called before an incoming socket.io connection is accepted.
*/
function handleAuth(socket, accept) {
var data = socket.request;
socket.user = false;
if (data.headers.cookie) {
cookieParser(data, null, function () {
var auth = data.signedCookies.auth;
if (!auth) {
return accept(null, true);
}
session.verifySession(auth, function (err, user) {
if (!err) {
socket.user = {
name: user.name,
global_rank: user.global_rank
};
}
accept(null, true);
});
});
} else {
accept(null, true);
}
}
function throttleIP(sock) {
var ip = sock._realip;
if (!(ip in ipThrottle)) {
ipThrottle[ip] = $util.newRateLimiter();
}
if (ipThrottle[ip].throttle(CONNECT_RATE)) {
Logger.syslog.log("WARN: IP throttled: " + ip);
sock.emit("kick", {
reason: "Your IP address is connecting too quickly. Please "+
"wait 10 seconds before joining again."
});
sock.disconnect();
return true;
}
return false;
}
function ipLimitReached(sock) {
var ip = sock._realip;
sock.on("disconnect", function () {
ipCount[ip]--;
if (ipCount[ip] === 0) {
/* Clear out unnecessary counters to save memory */
delete ipCount[ip];
}
});
if (!(ip in ipCount)) {
ipCount[ip] = 0;
}
ipCount[ip]++;
if (ipCount[ip] > Config.get("io.ip-connection-limit")) {
sock.emit("kick", {
reason: "Too many connections from your IP address"
});
sock.disconnect();
return;
}
}
function addTypecheckedFunctions(sock) {
sock.typecheckedOn = function (msg, template, cb) {
sock.on(msg, function (data) {
typecheck(data, template, function (err, data) {
if (err) {
sock.emit("errorMsg", {
msg: "Unexpected error for message " + msg + ": " + err.message
});
} else {
cb(data);
}
});
});
};
sock.typecheckedOnce = function (msg, template, cb) {
sock.once(msg, function (data) {
typecheck(data, template, function (err, data) {
if (err) {
sock.emit("errorMsg", {
msg: "Unexpected error for message " + msg + ": " + err.message
});
} else {
cb(data);
}
});
});
};
}
/**
* Called after a connection is accepted
*/
function handleConnection(sock) {
var ip = sock.client.conn.remoteAddress;
if (!ip) {
sock.emit("kick", {
reason: "Your IP address could not be determined from the socket connection. See https://github.com/Automattic/socket.io/issues/1737 for details"
});
return;
}
if (net.isIPv6(ip)) {
ip = util.expandIPv6(ip);
}
sock._realip = ip;
sock._displayip = $util.cloakIP(ip);
if (isTorExit(ip)) {
sock._isUsingTor = true;
}
var srv = Server.getServer();
if (throttleIP(sock)) {
return;
}
// Check for global ban on the IP
if (db.isGlobalIPBanned(ip)) {
Logger.syslog.log("Rejecting " + ip + " - global banned");
sock.emit("kick", { reason: "Your IP is globally banned." });
sock.disconnect();
return;
}
if (ipLimitReached(sock)) {
return;
}
Logger.syslog.log("Accepted socket from " + ip);
addTypecheckedFunctions(sock);
var user = new User(sock);
if (sock.user) {
user.setFlag(Flags.U_REGISTERED);
user.clearFlag(Flags.U_READY);
user.refreshAccount({ name: sock.user.name },
function (err, account) {
if (err) {
user.clearFlag(Flags.U_REGISTERED);
user.setFlag(Flags.U_READY);
return;
}
user.socket.emit("login", {
success: true,
name: user.getName(),
guest: false
});
db.recordVisit(ip, user.getName());
user.socket.emit("rank", user.account.effectiveRank);
user.setFlag(Flags.U_LOGGED_IN);
user.emit("login", account);
Logger.syslog.log(ip + " logged in as " + user.getName());
user.setFlag(Flags.U_READY);
});
} else {
user.socket.emit("rank", -1);
user.setFlag(Flags.U_READY);
}
}
module.exports = {
init: function (srv) {
var bound = {};
var io = sio.instance = sio();
io.use(handleAuth);
io.on("connection", handleConnection);
Config.get("listen").forEach(function (bind) {
if (!bind.io) {
return;
}
var id = bind.ip + ":" + bind.port;
if (id in bound) {
Logger.syslog.log("[WARN] Ignoring duplicate listen address " + id);
return;
}
if (id in srv.servers) {
io.attach(srv.servers[id]);
} else {
var server = require("http").createServer().listen(bind.port, bind.ip);
server.on("clientError", function (err, socket) {
console.error("clientError on " + id + " - " + err);
try {
socket.destroy();
} catch (e) {
}
});
io.attach(server);
}
bound[id] = null;
});
}
};
/* Clean out old rate limiters */
setInterval(function () {
for (var ip in ipThrottle) {
if (ipThrottle[ip].lastTime < Date.now() - 60 * 1000) {
var obj = ipThrottle[ip];
/* Not strictly necessary, but seems to help the GC out a bit */
for (var key in obj) {
delete obj[key];
}
delete ipThrottle[ip];
}
}
if (Config.get("aggressive-gc") && global && global.gc) {
global.gc();
}
}, 5 * 60 * 1000);

61
src/logger.js Normal file
View file

@ -0,0 +1,61 @@
var fs = require("graceful-fs");
var path = require("path");
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) {
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;

66
src/media.js Normal file
View file

@ -0,0 +1,66 @@
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 () {
return {
id: this.id,
title: this.title,
seconds: this.seconds,
duration: this.duration,
type: this.type,
meta: {
direct: this.meta.direct,
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
}
};
},
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;

54
src/poll.js Normal file
View file

@ -0,0 +1,54 @@
const link = /(\w+:\/\/(?:[^:\/\[\]\s]+|\[[0-9a-f:]+\])(?::\d+)?(?:\/[^\/\s]*)*)/ig;
var XSS = require("./xss");
var Poll = function(initiator, title, options, obscured) {
this.initiator = initiator;
title = XSS.sanitizeText(title);
this.title = title.replace(link, "<a href=\"$1\" target=\"_blank\">$1</a>");
this.options = options;
for (var i = 0; i < this.options.length; i++) {
this.options[i] = XSS.sanitizeText(this.options[i]);
this.options[i] = this.options[i].replace(link, "<a href=\"$1\" target=\"_blank\">$1</a>");
}
this.obscured = obscured || false;
this.counts = new Array(options.length);
for(var i = 0; i < this.counts.length; i++) {
this.counts[i] = 0;
}
this.votes = {};
}
Poll.prototype.vote = function(ip, option) {
if(!(ip in this.votes) || this.votes[ip] == null) {
this.votes[ip] = option;
this.counts[option]++;
}
}
Poll.prototype.unvote = function(ip) {
if(ip in this.votes && this.votes[ip] != null) {
this.counts[this.votes[ip]]--;
this.votes[ip] = null;
}
}
Poll.prototype.packUpdate = function (showhidden) {
var counts = Array.prototype.slice.call(this.counts);
if (this.obscured) {
for(var i = 0; i < counts.length; i++) {
if (!showhidden)
counts[i] = "";
counts[i] += "?";
}
}
var packed = {
title: this.title,
options: this.options,
counts: counts,
initiator: this.initiator
};
return packed;
}
exports.Poll = Poll;

238
src/server.js Normal file
View file

@ -0,0 +1,238 @@
const VERSION = require("../package.json").version;
var singleton = null;
var Config = require("./config");
module.exports = {
init: function () {
Logger.syslog.log("Starting CyTube v" + VERSION);
var chanlogpath = path.join(__dirname, "../chanlogs");
fs.exists(chanlogpath, function (exists) {
exists || fs.mkdir(chanlogpath);
});
var chandumppath = path.join(__dirname, "../chandump");
fs.exists(chandumppath, function (exists) {
exists || fs.mkdir(chandumppath);
});
var gdvttpath = path.join(__dirname, "../google-drive-subtitles");
fs.exists(gdvttpath, function (exists) {
exists || fs.mkdir(gdvttpath);
});
singleton = new Server();
return singleton;
},
getServer: function () {
return singleton;
}
};
var path = require("path");
var fs = require("fs");
var http = require("http");
var https = require("https");
var express = require("express");
var Logger = require("./logger");
var Channel = require("./channel/channel");
var User = require("./user");
var $util = require("./utilities");
var db = require("./database");
var Flags = require("./flags");
var sio = require("socket.io");
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 = {};
// database init ------------------------------------------------------
var Database = require("./database");
self.db = Database;
self.db.init();
// webserver init -----------------------------------------------------
self.express = express();
require("./web/webserver").init(self.express);
// http/https/sio server init -----------------------------------------
var key = "", cert = "", ca = undefined;
if (Config.get("https.enabled")) {
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")) {
ca = fs.readFileSync(path.resolve(__dirname, "..",
Config.get("https.cafile")));
}
}
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.syslog.log("[WARN] Ignoring duplicate listen address " + id);
return;
}
if (bind.https && Config.get("https.enabled")) {
self.servers[id] = https.createServer(opts, self.express)
.listen(bind.port, bind.ip);
self.servers[id].on("clientError", function (err, socket) {
console.error("clientError on " + id + " - " + err);
try {
socket.destroy();
} catch (e) {
}
});
} else if (bind.http) {
self.servers[id] = self.express.listen(bind.port, bind.ip);
self.servers[id].on("clientError", function (err, socket) {
console.error("clientError on " + id + " - " + err);
try {
socket.destroy();
} catch (e) {
}
});
}
});
require("./io/ioserver").init(self);
// background tasks init ----------------------------------------------
require("./bgtask")(self);
// setuid
require("./setuid");
};
Server.prototype.getHTTPIP = function (req) {
var ip = req.ip;
if (ip === "127.0.0.1" || ip === "::1") {
var fwd = req.header("x-forwarded-for");
if (fwd && typeof fwd === "string") {
return fwd;
}
}
return ip;
};
Server.prototype.getSocketIP = function (socket) {
var raw = socket.handshake.address.address;
if (raw === "127.0.0.1" || raw === "::1") {
var fwd = socket.handshake.headers["x-forwarded-for"];
if (fwd && typeof fwd === "string") {
return fwd;
}
}
return raw;
};
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;
};
Server.prototype.getChannel = function (name) {
var self = this;
var cname = name.toLowerCase();
for (var i = 0; i < self.channels.length; i++) {
if (self.channels[i].uniqueName === cname)
return self.channels[i];
}
var c = new Channel(name);
c.on("empty", function () {
self.unloadChannel(c);
});
self.channels.push(c);
return c;
};
Server.prototype.unloadChannel = function (chan) {
if (chan.dead) {
return;
}
chan.saveState();
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;
});
for (var i = 0; i < this.channels.length; i++) {
if (this.channels[i].uniqueName === chan.uniqueName) {
this.channels.splice(i, 1);
i--;
}
}
Logger.syslog.log("Unloaded channel " + chan.name);
// Empty all outward references from the channel
var keys = Object.keys(chan);
for (var i in keys) {
delete chan[keys[i]];
}
chan.dead = true;
};
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");
});
var self = this;
return channels.map(function (c) {
return c.packInfo(isAdmin);
});
};
Server.prototype.announce = function (data) {
if (data == null) {
this.announcement = null;
db.clearAnnouncement();
} else {
this.announcement = data;
db.setAnnouncement(data);
sio.instance.emit("announcement", data);
}
};
Server.prototype.shutdown = function () {
Logger.syslog.log("Unloading channels");
for (var i = 0; i < this.channels.length; i++) {
if (this.channels[i].is(Flags.C_REGISTERED)) {
Logger.syslog.log("Saving /r/" + this.channels[i].name);
this.channels[i].saveState();
}
}
Logger.syslog.log("Goodbye");
process.exit(0);
};

54
src/session.js Normal file
View file

@ -0,0 +1,54 @@
var dbAccounts = require("./database/accounts");
var util = require("./utilities");
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].join(":"));
};
exports.verifySession = function (input, cb) {
if (typeof input !== "string") {
return cb("Invalid auth string");
}
var parts = input.split(":");
if (parts.length !== 4) {
return cb("Invalid auth string");
}
var name = parts[0];
var expiration = parts[1];
var salt = parts[2];
var hash = parts[3];
if (Date.now() > parseInt(expiration)) {
return cb("Session expired");
}
dbAccounts.getUser(name, function (err, account) {
if (err) {
return cb(err);
}
var hashInput = [account.name, account.password, expiration, salt].join(":");
if (sha256(hashInput) !== hash) {
return cb("Invalid auth string");
}
cb(null, account);
});
};

45
src/setuid.js Normal file
View file

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

79
src/tor.js Normal file
View file

@ -0,0 +1,79 @@
var https = require("https");
var path = require("path");
var fs = require("fs");
var domain = require("domain");
var Logger = require("./logger");
function retrieveIPs(cb) {
var options = {
host: "www.dan.me.uk",
port: 443,
path: "/torlist/",
method: "GET"
};
var finish = function (status, data) {
if (status !== 200) {
cb(new Error("Failed to retrieve Tor IP list (HTTP " + status + ")"), null);
return;
}
var ips = data.split("\n");
cb(false, ips);
};
var d = domain.create();
d.on("error", function (err) {
if (err.stack)
Logger.errlog.log(err.stack);
else
Logger.errlog.log(err);
});
d.run(function () {
var req = https.request(options, function (res) {
var buffer = "";
res.setEncoding("utf-8");
res.on("data", function (data) { buffer += data; });
res.on("end", function () { finish(res.statusCode, buffer); });
});
req.end();
});
}
function getTorIPs(cb) {
retrieveIPs(function (err, ips) {
if (!err) {
cb(false, ips);
fs.writeFile(path.join(__dirname, "..", "torlist"),
ips.join("\n"));
return;
}
fs.readFile(path.join(__dirname, "..", "torlist"), function (err, data) {
if (err) {
cb(err, null);
return;
}
data = (""+data).split("\n");
cb(false, data);
});
});
}
var _ipList = [];
getTorIPs(function (err, ips) {
if (err) {
Logger.errlog.log(err);
return;
}
Logger.syslog.log("Loaded Tor IP list");
_ipList = ips;
});
exports.isTorExit = function (ip) {
return _ipList.indexOf(ip) >= 0;
};

183
src/ullist.js Normal file
View file

@ -0,0 +1,183 @@
/*
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;

435
src/user.js Normal file
View file

@ -0,0 +1,435 @@
var Logger = require("./logger");
var Server = require("./server");
var util = require("./utilities");
var MakeEmitter = require("./emitter");
var db = require("./database");
var InfoGetter = require("./get-info");
var Config = require("./config");
var ACP = require("./acp");
var Account = require("./account");
var Flags = require("./flags");
function User(socket) {
var self = this;
MakeEmitter(self);
self.flags = 0;
self.socket = socket;
self.realip = socket._realip;
self.displayip = socket._displayip;
self.hostmask = socket._hostmask;
self.account = Account.default(self.realip);
self.channel = null;
self.queueLimiter = util.newRateLimiter();
self.chatLimiter = util.newRateLimiter();
self.awaytimer = false;
var announcement = Server.getServer().announcement;
if (announcement != null) {
self.socket.emit("announcement", announcement);
}
self.socket.once("joinChannel", function (data) {
if (typeof data !== "object" || typeof data.name !== "string") {
return;
}
if (self.inChannel()) {
return;
}
if (!util.isValidChannelName(data.name)) {
self.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 _"
});
self.kick("Invalid channel name");
return;
}
data.name = data.name.toLowerCase();
if (data.name in Config.get("channel-blacklist")) {
self.kick("This channel is blacklisted.");
return;
}
self.waitFlag(Flags.U_READY, function () {
var chan = Server.getServer().getChannel(data.name);
chan.joinUser(self, data);
});
});
self.socket.once("initACP", function () {
self.waitFlag(Flags.U_LOGGED_IN, function () {
if (self.account.globalRank >= 255) {
ACP.init(self);
} else {
self.kick("Attempted initACP from non privileged user. This incident " +
"will be reported.");
Logger.eventlog.log("[acp] Attempted initACP from socket client " +
self.getName() + "@" + self.realip);
}
});
});
self.socket.on("login", function (data) {
data = (typeof data === "object") ? data : {};
var name = data.name;
if (typeof name !== "string") {
return;
}
var pw = data.pw || "";
if (typeof pw !== "string") {
pw = "";
}
if (self.is(Flags.U_LOGGING_IN) || self.is(Flags.U_LOGGED_IN)) {
return;
}
if (!pw) {
self.guestLogin(name);
} else {
self.login(name, pw);
}
});
self.on("login", function (account) {
if (account.globalRank >= 255) {
self.initAdminCallbacks();
}
});
}
User.prototype.die = function () {
for (var key in this.socket._events) {
delete this.socket._events[key];
}
delete this.socket.typecheckedOn;
delete this.socket.typecheckedOnce;
for (var 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.unbind("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;
};
/* 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);
}
} else {
this.clearFlag(Flags.U_AFK);
this.autoAFK();
}
/* Number of AFK users changed, voteskip state changes */
if (this.channel.modules.voteskip) {
this.channel.modules.voteskip.update();
}
this.channel.broadcastAll("setAFK", {
name: this.getName(),
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) {
this.socket.emit("kick", { reason: reason });
this.socket.disconnect();
};
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;
}
var opts = { name: user.name };
if (self.inChannel()) {
opts.channel = self.channel.name;
}
self.setFlag(Flags.U_REGISTERED);
self.refreshAccount(opts, function (err, account) {
if (err) {
Logger.errlog.log("[SEVERE] getAccount failed for user " + user.name);
Logger.errlog.log(err);
self.clearFlag(Flags.U_REGISTERED);
self.clearFlag(Flags.U_LOGGING_IN);
return;
}
self.socket.emit("login", {
success: true,
name: user.name
});
db.recordVisit(self.realip, self.getName());
self.socket.emit("rank", self.account.effectiveRank);
Logger.syslog.log(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;
}
// 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();
var opts = { name: name };
if (self.inChannel()) {
opts.channel = self.channel.name;
}
self.refreshAccount(opts, function (err, account) {
if (err) {
Logger.errlog.log("[SEVERE] getAccount failed for guest login " + name);
Logger.errlog.log(err);
return;
}
self.socket.emit("login", {
success: true,
name: name,
guest: true
});
db.recordVisit(self.realip, self.getName());
self.socket.emit("rank", 0);
Logger.syslog.log(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.refreshAccount = function (opts, cb) {
if (!cb) {
cb = opts;
opts = {};
}
var different = false;
for (var key in opts) {
if (opts[key] !== this.account[key]) {
different = true;
break;
}
}
if (!different) {
return;
}
var name = ("name" in opts) ? opts.name : this.account.name;
opts.registered = this.is(Flags.U_REGISTERED);
var self = this;
var old = this.account;
Account.getAccount(name, this.realip, opts, function (err, account) {
if (!err) {
/* Update account if anything changed in the meantime */
for (var key in old) {
if (self.account[key] !== old[key]) {
account[key] = self.account[key];
}
}
self.account = account;
}
cb(err, account);
});
};
module.exports = User;

321
src/utilities.js Normal file
View file

@ -0,0 +1,321 @@
(function () {
var root, crypto, net = false;
if (typeof window === "undefined") {
root = module.exports;
} else {
root = window.utils = {};
}
if (typeof require === "function") {
crypto = require("crypto");
net = require("net");
}
var Set = function (items) {
this._items = {};
var self = this;
if (items instanceof Array)
items.forEach(function (it) { self.add(it); });
};
Set.prototype.contains = function (what) {
return (what in this._items);
};
Set.prototype.add = function (what) {
this._items[what] = true;
};
Set.prototype.remove = function (what) {
if (what in this._items)
delete this._items[what];
};
Set.prototype.clear = function () {
this._items = {};
};
Set.prototype.forEach = function (fn) {
for (var k in this._items) {
fn(k);
}
};
root.Set = Set;
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 (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;
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) {
switch (type) {
case "yt":
return "http://youtu.be/" + id;
case "vi":
return "http://vimeo.com/" + id;
case "dm":
return "http://dailymotion.com/video/" + id;
case "sc":
return id;
case "li":
return "http://livestream.com/" + id;
case "tw":
return "http://twitch.tv/" + id;
case "rt":
return id;
case "jw":
return id;
case "im":
return "http://imgur.com/a/" + id;
case "us":
return "http://ustream.tv/" + id;
case "gd":
return "https://docs.google.com/file/d/" + id;
case "fi":
return id;
case "hb":
return "http://hitbox.tv/" + id;
default:
return "";
}
},
root.isLive = function (type) {
switch (type) {
case "li":
case "tw":
case "us":
case "rt":
case "cu":
case "im":
case "jw":
case "hb":
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) {
if (i < 2) return segment;
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) {
if (i < 2) return segment;
var part = iphash(accumulator + segment + i, 4);
accumulator += segment;
return part;
});
while (parts.length < 4) parts.push("*");
return parts.join(":");
}
}
})();

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

@ -0,0 +1,644 @@
/**
* web/account.js - Webserver details for account management
*
* @author Calvin Montgomery <cyzon@cyzon.us>
*/
var webserver = require("./webserver");
var sendJade = require("./jade").sendJade;
var Logger = require("../logger");
var db = require("../database");
var $util = require("../utilities");
var Config = require("../config");
var Server = require("../server");
var session = require("../session");
var csrf = require("./csrf");
/**
* Handles a GET request for /account/edit
*/
function handleAccountEditPage(req, res) {
if (webserver.redirectHttps(req, res)) {
return;
}
sendJade(res, "account-edit", {});
}
/**
* Handles a POST request to edit a user"s account
*/
function handleAccountEdit(req, res) {
csrf.verify(req);
var action = req.body.action;
switch(action) {
case "change_password":
handleChangePassword(req, res);
break;
case "change_email":
handleChangeEmail(req, res);
break;
default:
res.send(400);
break;
}
}
/**
* Handles a request to change the user"s password
*/
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) {
sendJade(res, "account-edit", {
errorMessage: "New password must not be empty"
});
return;
}
if (!req.user) {
sendJade(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) {
sendJade(res, "account-edit", {
errorMessage: err
});
return;
}
db.users.setPassword(name, newpassword, function (err, dbres) {
if (err) {
sendJade(res, "account-edit", {
errorMessage: err
});
return;
}
Logger.eventlog.log("[account] " + webserver.ipForRequest(req) +
" changed password for " + name);
db.users.getUser(name, function (err, user) {
if (err) {
return sendJade(res, "account-edit", {
errorMessage: err
});
}
res.user = user;
var expiration = new Date(parseInt(req.signedCookies.auth.split(":")[1]));
session.genSession(user, expiration, function (err, auth) {
if (err) {
return sendJade(res, "account-edit", {
errorMessage: err
});
}
if (req.hostname.indexOf(Config.get("http.root-domain")) >= 0) {
res.cookie("auth", auth, {
domain: Config.get("http.root-domain-dotted"),
expires: expiration,
httpOnly: true,
signed: true
});
} else {
res.cookie("auth", auth, {
expires: expiration,
httpOnly: true,
signed: true
});
}
sendJade(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 !== "") {
sendJade(res, "account-edit", {
errorMessage: "Invalid email address"
});
return;
}
db.users.verifyLogin(name, password, function (err, user) {
if (err) {
sendJade(res, "account-edit", {
errorMessage: err
});
return;
}
db.users.setEmail(name, email, function (err, dbres) {
if (err) {
sendJade(res, "account-edit", {
errorMessage: err
});
return;
}
Logger.eventlog.log("[account] " + webserver.ipForRequest(req) +
" changed email for " + name +
" to " + email);
sendJade(res, "account-edit", {
successMessage: "Email address changed."
});
});
});
}
/**
* Handles a GET request for /account/channels
*/
function handleAccountChannelPage(req, res) {
if (webserver.redirectHttps(req, res)) {
return;
}
if (!req.user) {
return sendJade(res, "account-channels", {
channels: []
});
}
db.channels.listUserChannels(req.user.name, function (err, channels) {
sendJade(res, "account-channels", {
channels: channels
});
});
}
/**
* Handles a POST request to modify a user"s channels
*/
function handleAccountChannel(req, res) {
csrf.verify(req);
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
*/
function handleNewChannel(req, res) {
var name = req.body.name;
if (typeof name !== "string") {
res.send(400);
return;
}
if (!req.user) {
return sendJade(res, "account-channels", {
channels: []
});
}
db.channels.listUserChannels(req.user.name, function (err, channels) {
if (err) {
sendJade(res, "account-channels", {
channels: [],
newChannelError: err
});
return;
}
if (name.match(Config.get("reserved-names.channels"))) {
sendJade(res, "account-channels", {
channels: channels,
newChannelError: "That channel name is reserved"
});
return;
}
if (channels.length >= Config.get("max-channels-per-user")) {
sendJade(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, req.user.name, function (err, channel) {
if (!err) {
Logger.eventlog.log("[channel] " + req.user.name + "@" +
webserver.ipForRequest(req) +
" registered channel " + name);
var sv = Server.getServer();
if (sv.isChannelLoaded(name)) {
var chan = sv.getChannel(name);
var users = Array.prototype.slice.call(chan.users);
users.forEach(function (u) {
u.kick("Channel reloading");
});
if (!chan.dead) {
chan.emit("empty");
}
}
channels.push({
name: name
});
}
sendJade(res, "account-channels", {
channels: channels,
newChannelError: err ? err : undefined
});
});
});
}
/**
* Handles a request to delete a new channel
*/
function handleDeleteChannel(req, res) {
var name = req.body.name;
if (typeof name !== "string") {
res.send(400);
return;
}
if (!req.user) {
return sendJade(res, "account-channels", {
channels: [],
});
}
db.channels.lookup(name, function (err, channel) {
if (err) {
sendJade(res, "account-channels", {
channels: [],
deleteChannelError: err
});
return;
}
if (channel.owner !== req.user.name && req.user.global_rank < 255) {
db.channels.listUserChannels(req.user.name, function (err2, channels) {
sendJade(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] " + req.user.name + "@" +
webserver.ipForRequest(req) + " deleted channel " +
name);
}
var sv = Server.getServer();
if (sv.isChannelLoaded(name)) {
var chan = sv.getChannel(name);
chan.clearFlag(require("../flags").C_REGISTERED);
var users = Array.prototype.slice.call(chan.users);
users.forEach(function (u) {
u.kick("Channel reloading");
});
if (!chan.dead) {
chan.emit("empty");
}
}
db.channels.listUserChannels(req.user.name, function (err2, channels) {
sendJade(res, "account-channels", {
channels: err2 ? [] : channels,
deleteChannelError: err ? err : undefined
});
});
});
});
}
/**
* Handles a GET request for /account/profile
*/
function handleAccountProfilePage(req, res) {
if (webserver.redirectHttps(req, res)) {
return;
}
if (!req.user) {
return sendJade(res, "account-profile", {
profileImage: "",
profileText: ""
});
}
db.users.getProfile(req.user.name, function (err, profile) {
if (err) {
sendJade(res, "account-profile", {
profileError: err,
profileImage: "",
profileText: ""
});
return;
}
sendJade(res, "account-profile", {
profileImage: profile.image,
profileText: profile.text,
profileError: false
});
});
}
/**
* Handles a POST request to edit a profile
*/
function handleAccountProfile(req, res) {
csrf.verify(req);
if (!req.user) {
return sendJade(res, "account-profile", {
profileImage: "",
profileText: "",
profileError: "You must be logged in to edit your profile",
});
}
var image = req.body.image;
var text = req.body.text;
db.users.setProfile(req.user.name, { image: image, text: text }, function (err) {
if (err) {
sendJade(res, "account-profile", {
profileImage: "",
profileText: "",
profileError: err
});
return;
}
sendJade(res, "account-profile", {
profileImage: image,
profileText: text,
profileError: false
});
});
}
/**
* Handles a GET request for /account/passwordreset
*/
function handlePasswordResetPage(req, res) {
if (webserver.redirectHttps(req, res)) {
return;
}
sendJade(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);
var name = req.body.name,
email = req.body.email;
if (typeof name !== "string" || typeof email !== "string") {
res.send(400);
return;
}
if (!$util.isValidUserName(name)) {
sendJade(res, "account-passwordreset", {
reset: false,
resetEmail: "",
resetErr: "Invalid username '" + name + "'"
});
return;
}
db.users.getEmail(name, function (err, actualEmail) {
if (err) {
sendJade(res, "account-passwordreset", {
reset: false,
resetEmail: "",
resetErr: err
});
return;
}
if (actualEmail !== email.trim()) {
sendJade(res, "account-passwordreset", {
reset: false,
resetEmail: "",
resetErr: "Provided email does not match the email address on record for " + name
});
return;
} else if (actualEmail === "") {
sendJade(res, "account-passwordreset", {
reset: false,
resetEmail: "",
resetErr: name + " doesn't have an email address on record. Please contact an " +
"administrator to manually reset your password."
});
return;
}
var hash = $util.sha1($util.randomSalt(64));
// 24-hour expiration
var expire = Date.now() + 86400000;
var ip = webserver.ipForRequest(req);
db.addPasswordReset({
ip: ip,
name: name,
email: email,
hash: hash,
expire: expire
}, function (err, dbres) {
if (err) {
sendJade(res, "account-passwordreset", {
reset: false,
resetEmail: "",
resetErr: err
});
return;
}
Logger.eventlog.log("[account] " + ip + " requested password recovery for " +
name + " <" + email + ">");
if (!Config.get("mail.enabled")) {
sendJade(res, "account-passwordreset", {
reset: false,
resetEmail: email,
resetErr: "This server does not have mail support enabled. Please " +
"contact an administrator for assistance."
});
return;
}
var msg = "A password reset request was issued for your " +
"account `"+ name + "` on " + Config.get("http.domain") +
". This request is valid for 24 hours. If you did "+
"not initiate this, there is no need to take action."+
" To reset your password, copy and paste the " +
"following link into your browser: " +
Config.get("http.domain") + "/account/passwordrecover/"+hash;
var mail = {
from: Config.get("mail.from-name") + " <" + Config.get("mail.from-address") + ">",
to: email,
subject: "Password reset request",
text: msg
};
Config.get("mail.nodemailer").sendMail(mail, function (err, response) {
if (err) {
Logger.errlog.log("mail fail: " + err);
sendJade(res, "account-passwordreset", {
reset: false,
resetEmail: email,
resetErr: "Sending reset email failed. Please contact an " +
"administrator for assistance."
});
} else {
sendJade(res, "account-passwordreset", {
reset: true,
resetEmail: email,
resetErr: false
});
}
});
});
});
}
/**
* Handles a request for /account/passwordrecover/<hash>
*/
function handlePasswordRecover(req, res) {
var hash = req.params.hash;
if (typeof hash !== "string") {
res.send(400);
return;
}
var ip = webserver.ipForRequest(req);
db.lookupPasswordReset(hash, function (err, row) {
if (err) {
sendJade(res, "account-passwordrecover", {
recovered: false,
recoverErr: err
});
return;
}
if (Date.now() >= row.expire) {
sendJade(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) {
sendJade(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);
sendJade(res, "account-passwordrecover", {
recovered: true,
recoverPw: newpw
});
});
});
}
module.exports = {
/**
* Initialize the module
*/
init: function (app) {
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", handlePasswordRecover);
app.get("/account", function (req, res) {
res.redirect("/login");
});
}
};

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

@ -0,0 +1,114 @@
var path = require("path");
var fs = require("fs");
var webserver = require("./webserver");
var sendJade = require("./jade").sendJade;
var Logger = require("../logger");
var db = require("../database");
var Config = require("../config");
function checkAdmin(cb) {
return function (req, res) {
if (!req.user) {
return res.send(403);
}
if (req.user.global_rank < 255) {
res.send(403);
Logger.eventlog.log("[acp] Attempted GET "+req.path+" from non-admin " +
user.name + "@" + webserver.ipForRequest(req));
return;
}
cb(req, res, req.user);
};
}
/**
* Handles a request for the ACP
*/
function handleAcp(req, res, user) {
var sio;
if (req.secure || req.header("x-forwarded-proto") === "https") {
sio = Config.get("https.domain") + ":" + Config.get("https.default-port");
} else {
sio = Config.get("io.domain") + ":" + Config.get("io.default-port");
}
sio += "/socket.io/socket.io.js";
sendJade(res, "acp", {
sioSource: sio
});
}
/**
* 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) {
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));
}
};

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

@ -0,0 +1,241 @@
/**
* web/auth.js - Webserver functions for user authentication and registration
*
* @author Calvin Montgomery <cyzon@cyzon.us>
*/
var jade = require("jade");
var path = require("path");
var webserver = require("./webserver");
var cookieall = webserver.cookieall;
var sendJade = require("./jade").sendJade;
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");
/**
* 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 || req.header("referer") || null;
dest = dest && dest.match(/login|logout/) ? null : dest;
if (typeof name !== "string" || typeof password !== "string") {
res.sendStatus(400);
return;
}
var host = req.hostname;
if (host.indexOf(Config.get("http.root-domain")) === -1 &&
Config.get("http.alt-domains").indexOf(host) === -1) {
Logger.syslog.log("WARNING: 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
+ "@" + webserver.ipForRequest(req));
}
sendJade(res, "login", {
loggedIn: false,
loginError: err
});
return;
}
session.genSession(user, expiration, function (err, auth) {
if (err) {
sendJade(res, "login", {
loggedIn: false,
loginError: err
});
return;
}
if (req.hostname.indexOf(Config.get("http.root-domain")) >= 0) {
// Prevent non-root cookie from screwing things up
res.clearCookie("auth");
res.cookie("auth", auth, {
domain: Config.get("http.root-domain-dotted"),
expires: expiration,
httpOnly: true,
signed: true
});
} else {
res.cookie("auth", auth, {
expires: expiration,
httpOnly: true,
signed: true
});
}
if (dest) {
res.redirect(dest);
} else {
res.user = user;
sendJade(res, "login", {});
}
});
});
}
/**
* Handles a GET request for /login
*/
function handleLoginPage(req, res) {
if (webserver.redirectHttps(req, res)) {
return;
}
if (req.user) {
return sendJade(res, "login", {
wasAlreadyLoggedIn: true
});
}
sendJade(res, "login", {
redirect: req.query.dest || req.header("referer")
});
}
/**
* Handles a request for /logout. Clears auth cookie
*/
function handleLogout(req, res) {
csrf.verify(req);
res.clearCookie("auth");
req.user = res.user = null;
// Try to find an appropriate redirect
var dest = req.query.dest || req.header("referer");
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 {
sendJade(res, "logout", {});
}
}
/**
* Handles a GET request for /register
*/
function handleRegisterPage(req, res) {
if (webserver.redirectHttps(req, res)) {
return;
}
if (req.user) {
sendJade(res, "register", {});
return;
}
sendJade(res, "register", {
registered: false,
registerError: false
});
}
/**
* Processes a registration request.
*/
function handleRegister(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 = webserver.ipForRequest(req);
if (typeof name !== "string" || typeof password !== "string") {
res.sendStatus(400);
return;
}
if (name.length === 0) {
sendJade(res, "register", {
registerError: "Username must not be empty"
});
return;
}
if (name.match(Config.get("reserved-names.usernames"))) {
sendJade(res, "register", {
registerError: "That username is reserved"
});
return;
}
if (password.length === 0) {
sendJade(res, "register", {
registerError: "Password must not be empty"
});
return;
}
password = password.substring(0, 100);
if (email.length > 0 && !$util.isValidEmail(email)) {
sendJade(res, "register", {
registerError: "Invalid email address"
});
return;
}
db.users.register(name, password, email, ip, function (err) {
if (err) {
sendJade(res, "register", {
registerError: err
});
} else {
Logger.eventlog.log("[register] " + ip + " registered account: " + name +
(email.length > 0 ? " <" + email + ">" : ""));
sendJade(res, "register", {
registered: true,
registerName: name,
redirect: req.body.redirect
});
}
});
}
module.exports = {
/**
* Initializes auth callbacks
*/
init: function (app) {
app.get("/login", handleLoginPage);
app.post("/login", handleLogin);
app.get("/logout", handleLogout);
app.get("/register", handleRegisterPage);
app.post("/register", handleRegister);
}
};

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

@ -0,0 +1,46 @@
/*
* Adapted from https://github.com/expressjs/csurf
*/
var csrf = require("csrf");
var createError = require("http-errors");
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 createError(403, 'invalid csrf token', {
code: 'EBADCSRFTOKEN'
});
}
};

62
src/web/jade.js Normal file
View file

@ -0,0 +1,62 @@
var jade = require("jade");
var fs = require("fs");
var path = require("path");
var Config = require("../config");
var templates = path.join(__dirname, "..", "..", "templates");
var cache = {};
/**
* Merges locals with globals for jade rendering
*/
function merge(locals, res) {
var _locals = {
siteTitle: Config.get("html-template.title"),
siteDescription: Config.get("html-template.description"),
siteAuthor: "Calvin 'calzoneman' 'cyzon' Montgomery",
loginDomain: Config.get("https.enabled") ? Config.get("https.full-address")
: Config.get("http.full-address"),
csrfToken: res.req.csrfToken(),
baseUrl: getBaseUrl(res)
};
if (typeof locals !== "object") {
return _locals;
}
for (var key in locals) {
_locals[key] = locals[key];
}
return _locals;
}
function getBaseUrl(res) {
var req = res.req;
var proto;
if (["http", "https"].indexOf(req.header("x-forwarded-proto")) >= 0) {
proto = req.header("x-forwarded-proto");
} else {
proto = req.protocol;
}
return proto + "://" + req.header("host");
}
/**
* Renders and serves a jade template
*/
function sendJade(res, view, locals) {
locals.loggedIn = locals.loggedIn || !!res.user;
locals.loginName = locals.loginName || res.user ? res.user.name : false;
if (!(view in cache) || Config.get("debug")) {
var file = path.join(templates, view + ".jade");
var fn = jade.compile(fs.readFileSync(file), {
filename: file,
pretty: !Config.get("http.minify")
});
cache[view] = fn;
}
var html = cache[view](merge(locals, res));
res.send(html);
}
module.exports = {
sendJade: sendJade
};

278
src/web/webserver.js Normal file
View file

@ -0,0 +1,278 @@
var path = require("path");
var fs = require("fs");
var net = require("net");
var express = require("express");
var webroot = path.join(__dirname, "..", "www");
var sendJade = require("./jade").sendJade;
var Server = require("../server");
var $util = require("../utilities");
var Logger = require("../logger");
var Config = require("../config");
var db = require("../database");
var bodyParser = require("body-parser");
var cookieParser = require("cookie-parser");
var serveStatic = require("serve-static");
var morgan = require("morgan");
var session = require("../session");
var csrf = require("./csrf");
var XSS = require("../xss");
const LOG_FORMAT = ':real-address - :remote-user [:date] ":method :url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"';
morgan.token('real-address', function (req) { return req._ip; });
/**
* Extracts an IP address from a request. Uses X-Forwarded-For if the IP is localhost
*/
function ipForRequest(req) {
var ip = req.ip;
if (ip === "127.0.0.1" || ip === "::1") {
var xforward = req.header("x-forwarded-for");
if (typeof xforward !== "string") {
xforward = [];
} else {
xforward = xforward.split(",");
}
for (var i = 0; i < xforward.length; i++) {
if (net.isIP(xforward[i])) {
return xforward[i];
}
}
return ip;
}
return ip;
}
/**
* Redirects a request to HTTPS if the server supports it
*/
function redirectHttps(req, res) {
if (!req.secure && Config.get("https.enabled") && Config.get("https.redirect")) {
var ssldomain = Config.get("https.full-address");
if (ssldomain.indexOf(req.hostname) < 0) {
return false;
}
res.redirect(ssldomain + req.path);
return true;
}
return false;
}
/**
* Redirects a request to HTTP if the server supports it
*/
function redirectHttp(req, res) {
if (req.secure) {
var domain = Config.get("http.full-address");
res.redirect(domain + req.path);
return true;
}
return false;
}
/**
* Handles a GET request for /r/:channel - serves channel.html
*/
function handleChannel(req, res) {
if (!$util.isValidChannelName(req.params.channel)) {
res.status(404);
res.send("Invalid channel name '" + XSS.sanitizeText(req.params.channel) + "'");
return;
}
var sio;
if (net.isIPv6(ipForRequest(req))) {
sio = Config.get("io.ipv6-default");
}
if (!sio) {
sio = Config.get("io.ipv4-default");
}
sio += "/socket.io/socket.io.js";
sendJade(res, "channel", {
channelName: req.params.channel,
sioSource: sio
});
}
/**
* Handles a request for the index page
*/
function handleIndex(req, res) {
var channels = Server.getServer().packChannelList(true);
channels.sort(function (a, b) {
if (a.usercount === b.usercount) {
return a.uniqueName > b.uniqueName ? -1 : 1;
}
return b.usercount - a.usercount;
});
sendJade(res, "index", {
channels: channels
});
}
/**
* Handles a request for the socket.io information
*/
function handleSocketConfig(req, res) {
res.type("application/javascript");
var sioconfig = Config.get("sioconfig");
var iourl;
var ip = ipForRequest(req);
var ipv6 = false;
if (net.isIPv6(ip)) {
iourl = Config.get("io.ipv6-default");
ipv6 = true;
}
if (!iourl) {
iourl = Config.get("io.ipv4-default");
}
sioconfig += "var IO_URL='" + iourl + "';";
sioconfig += "var IO_V6=" + ipv6 + ";";
res.send(sioconfig);
}
function handleUserAgreement(req, res) {
sendJade(res, "tos", {
domain: Config.get("http.domain")
});
}
function handleContactPage(req, res) {
// Make a copy to prevent messing with the original
var contacts = Config.get("contacts").map(function (c) {
return {
name: c.name,
email: c.email,
title: c.title
};
});
// Rudimentary hiding of email addresses to prevent spambots
contacts.forEach(function (c) {
c.emkey = $util.randomSalt(16)
var email = new Array(c.email.length);
for (var i = 0; i < c.email.length; i++) {
email[i] = String.fromCharCode(
c.email.charCodeAt(i) ^ c.emkey.charCodeAt(i % c.emkey.length)
);
}
c.email = escape(email.join(""));
c.emkey = escape(c.emkey);
});
sendJade(res, "contact", {
contacts: contacts
});
}
module.exports = {
/**
* Initializes webserver callbacks
*/
init: function (app) {
app.use(function (req, res, next) {
req._ip = ipForRequest(req);
next();
});
app.use(bodyParser.urlencoded({
extended: false,
limit: '1kb' // No POST data should ever exceed this size under normal usage
}));
if (Config.get("http.cookie-secret") === "change-me") {
Logger.errlog.log("YOU SHOULD CHANGE THE VALUE OF cookie-secret IN config.yaml");
}
app.use(cookieParser(Config.get("http.cookie-secret")));
app.use(csrf.init(Config.get("http.root-domain-dotted")));
app.use(morgan(LOG_FORMAT, {
stream: require("fs").createWriteStream(path.join(__dirname, "..", "..",
"http.log"), {
flags: "a",
encoding: "utf-8"
})
}));
app.use(function (req, res, next) {
if (req.path.match(/^\/(css|js|img|boop).*$/)) {
return next();
}
if (!req.signedCookies || !req.signedCookies.auth) {
return next();
}
session.verifySession(req.signedCookies.auth, function (err, account) {
if (!err) {
req.user = res.user = account;
}
next();
});
});
if (Config.get("http.gzip")) {
app.use(require("compression")({ threshold: Config.get("http.gzip-threshold") }));
Logger.syslog.log("Enabled gzip compression");
}
if (Config.get("http.minify")) {
var cache = path.join(__dirname, "..", "..", "www", "cache")
if (!fs.existsSync(cache)) {
fs.mkdirSync(cache);
}
app.use(require("express-minify")({
cache: cache
}));
Logger.syslog.log("Enabled express-minify for CSS and JS");
}
app.get("/r/:channel", handleChannel);
app.get("/", handleIndex);
app.get("/sioconfig", handleSocketConfig);
app.get("/useragreement", handleUserAgreement);
app.get("/contact", handleContactPage);
require("./auth").init(app);
require("./account").init(app);
require("./acp").init(app);
require("../google2vtt").attach(app);
app.use(serveStatic(path.join(__dirname, "..", "..", "www"), {
maxAge: Config.get("http.max-age") || Config.get("http.cache-ttl")
}));
app.use(function (err, req, res, next) {
if (err) {
if (err.message && err.message.match(/failed to decode param/i)) {
return res.status(400).send("Malformed path: " + req.path);
} else if (err.message && err.message.match(/requested range not/i)) {
return res.status(416).end();
} else if (err.message && err.message.match(/request entity too large/i)) {
return res.status(413).end();
} else if (err.message && err.message.match(/bad request/i)) {
return res.status(400).end("Bad Request");
} else if (err.message && err.message.match(/invalid csrf token/i)) {
res.status(403);
sendJade(res, 'csrferror', { path: req.path });
return;
}
Logger.errlog.log(err.stack);
res.status(500).end();
} else {
next();
}
});
},
ipForRequest: ipForRequest,
redirectHttps: redirectHttps,
redirectHttp: redirectHttp
};

97
src/xss.js Normal file
View file

@ -0,0 +1,97 @@
var sanitizeHTML = require("sanitize-html");
// These tags are allowed in addition to the defaults
// See https://github.com/punkave/sanitize-html
const ALLOWED_TAGS = [
"button",
"center",
"details",
"font",
"h1",
"h2",
"img",
"marquee", // It pains me to do this, but a lot of people use it...
"s",
"section",
"span",
"summary"
];
const ALLOWED_ATTRIBUTES = [
"id",
"aria-*",
"border",
"class",
"color",
"data-*",
"height",
"role",
"style",
"title",
"valign",
"width"
];
const ALLOWED_SCHEMES = [
"mumble"
];
var ATTRIBUTE_MAP = {
a: ["href", "name", "target"],
font: ["size"],
img: ["src"],
marquee: ["behavior", "behaviour", "direction", "scrollamount"],
table: ["cellpadding", "cellspacing"],
th: ["colspan", "rowspan"],
td: ["colspan", "rowspan"]
}
for (var key in ATTRIBUTE_MAP) {
ALLOWED_ATTRIBUTES.forEach(function (attr) {
ATTRIBUTE_MAP[key].push(attr);
});
}
sanitizeHTML.defaults.allowedTags.concat(ALLOWED_TAGS).forEach(function (tag) {
if (!(tag in ATTRIBUTE_MAP)) {
ATTRIBUTE_MAP[tag] = ALLOWED_ATTRIBUTES;
}
});
const SETTINGS = {
allowedSchemes: sanitizeHTML.defaults.allowedSchemes.concat(ALLOWED_SCHEMES),
allowedTags: sanitizeHTML.defaults.allowedTags.concat(ALLOWED_TAGS),
allowedAttributes: ATTRIBUTE_MAP
};
function sanitizeText(str) {
str = str.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/\(/g, "&#40;")
.replace(/\)/g, "&#41;");
return str;
}
function decodeText(str) {
str = str.replace(/&#([0-9]{2,7});?/g, function (m, p1) {
return String.fromCharCode(parseInt(p1));
});
str = str.replace(/&#x([0-9a-f]{2,7});?/ig, function (m, p1) {
return String.fromCharCode(parseInt(p1, 16));
});
str = str.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, "\"")
.replace(/&amp;/g, "&");
return str;
}
module.exports.sanitizeHTML = function (html) {
return sanitizeHTML(html, SETTINGS);
};
module.exports.sanitizeText = sanitizeText;
module.exports.decodeText = decodeText;