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:
parent
d042619b21
commit
0109a87e55
55 changed files with 9 additions and 3 deletions
155
src/account.js
Normal file
155
src/account.js
Normal 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
318
src/acp.js
Normal 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
54
src/asyncqueue.js
Normal 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
84
src/bgtask.js
Normal 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);
|
||||
};
|
||||
70
src/channel/accesscontrol.js
Normal file
70
src/channel/accesscontrol.js
Normal 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
689
src/channel/channel.js
Normal 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
615
src/channel/chat.js
Normal 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;
|
||||
119
src/channel/customization.js
Normal file
119
src/channel/customization.js
Normal 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
56
src/channel/drink.js
Normal 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
205
src/channel/emotes.js
Normal 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
300
src/channel/filters.js
Normal 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
429
src/channel/kickban.js
Normal 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
111
src/channel/library.js
Normal 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;
|
||||
209
src/channel/mediarefresher.js
Normal file
209
src/channel/mediarefresher.js
Normal 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
81
src/channel/module.js
Normal 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
278
src/channel/opts.js
Normal 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
392
src/channel/permissions.js
Normal 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
1372
src/channel/playlist.js
Normal file
File diff suppressed because it is too large
Load diff
186
src/channel/poll.js
Normal file
186
src/channel/poll.js
Normal 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
192
src/channel/ranks.js
Normal 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
123
src/channel/voteskip.js
Normal 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
385
src/config.js
Normal 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
21
src/counters.js
Normal 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
99
src/customembed.js
Normal 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
596
src/database.js
Normal 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
564
src/database/accounts.js
Normal 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
621
src/database/channels.js
Normal 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
141
src/database/tables.js
Normal 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
332
src/database/update.js
Normal 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
72
src/emitter.js
Normal 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
308
src/ffmpeg.js
Normal 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
14
src/flags.js
Normal 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
599
src/get-info.js
Normal 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
195
src/google2vtt.js
Normal 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(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/-->/g, '-->');
|
||||
}
|
||||
|
||||
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
258
src/io/ioserver.js
Normal 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
61
src/logger.js
Normal 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
66
src/media.js
Normal 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
54
src/poll.js
Normal 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
238
src/server.js
Normal 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
54
src/session.js
Normal 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
45
src/setuid.js
Normal 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
79
src/tor.js
Normal 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
183
src/ullist.js
Normal 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
435
src/user.js
Normal 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
321
src/utilities.js
Normal 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
644
src/web/account.js
Normal 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
114
src/web/acp.js
Normal 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
241
src/web/auth.js
Normal 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
46
src/web/csrf.js
Normal 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
62
src/web/jade.js
Normal 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
278
src/web/webserver.js
Normal 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
97
src/xss.js
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\(/g, "(")
|
||||
.replace(/\)/g, ")");
|
||||
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(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, "\"")
|
||||
.replace(/&/g, "&");
|
||||
return str;
|
||||
}
|
||||
|
||||
module.exports.sanitizeHTML = function (html) {
|
||||
return sanitizeHTML(html, SETTINGS);
|
||||
};
|
||||
|
||||
module.exports.sanitizeText = sanitizeText;
|
||||
module.exports.decodeText = decodeText;
|
||||
Loading…
Add table
Add a link
Reference in a new issue