init commit

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

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

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

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

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

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

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

25
src/web/routes/channel.js Normal file
View file

@ -0,0 +1,25 @@
import CyTubeUtil from '../../utilities';
import { sanitizeText } from '../../xss';
import { sendPug } from '../pug';
import * as HTTPStatus from '../httpstatus';
import { HTTPError } from '../../errors';
export default function initialize(app, ioConfig, chanPath) {
app.get(`/${chanPath}/:channel`, (req, res) => {
if (!req.params.channel || !CyTubeUtil.isValidChannelName(req.params.channel)) {
throw new HTTPError(`"${sanitizeText(req.params.channel)}" is not a valid ` +
'channel name.', { status: HTTPStatus.NOT_FOUND });
}
const endpoints = ioConfig.getSocketEndpoints();
if (endpoints.length === 0) {
throw new HTTPError('No socket.io endpoints configured');
}
const socketBaseURL = endpoints[0].url;
sendPug(res, 'channel', {
channelName: req.params.channel,
sioSource: `${socketBaseURL}/socket.io/socket.io.js`
});
});
}

26
src/web/routes/contact.js Normal file
View file

@ -0,0 +1,26 @@
import CyTubeUtil from '../../utilities';
import { sendPug } from '../pug';
export default function initialize(app, webConfig) {
app.get('/contact', (req, res) => {
// Basic obfuscation of email addresses to prevent spambots
// from picking them up. Not real encryption.
// Deobfuscated by clientside JS.
const contacts = webConfig.getEmailContacts().map(contact => {
const emkey = CyTubeUtil.randomSalt(16);
let email = new Array(contact.email.length);
for (let i = 0; i < contact.email.length; i++) {
email[i] = String.fromCharCode(
contact.email.charCodeAt(i) ^ emkey.charCodeAt(i % emkey.length)
);
}
contact.email = escape(email.join(""));
contact.emkey = escape(emkey);
return contact;
});
return sendPug(res, 'contact', {
contacts: contacts
});
});
}

View file

@ -0,0 +1,7 @@
import { sendPug } from '../pug';
export default function initialize(app) {
app.get('/google_drive_userscript', (req, res) => {
return sendPug(res, 'google_drive_userscript');
});
}

21
src/web/routes/index.js Normal file
View file

@ -0,0 +1,21 @@
import { sendPug } from '../pug';
export default function initialize(app, channelIndex, maxEntries) {
app.get('/', (req, res) => {
channelIndex.listPublicChannels().then((channels) => {
channels.sort((a, b) => {
if (a.usercount === b.usercount) {
return a.uniqueName > b.uniqueName ? -1 : 1;
}
return b.usercount - a.usercount;
});
channels = channels.slice(0, maxEntries);
sendPug(res, 'index', {
channels: channels
});
});
});
}

View file

@ -0,0 +1,23 @@
import CyTubeUtil from '../../utilities';
import * as HTTPStatus from '../httpstatus';
const LOGGER = require('@calzoneman/jsli')('web/routes/socketconfig');
export default function initialize(app, clusterClient) {
app.get('/socketconfig/:channel.json', (req, res) => {
if (!req.params.channel || !CyTubeUtil.isValidChannelName(req.params.channel)) {
return res.status(HTTPStatus.NOT_FOUND).json({
error: `Channel "${req.params.channel}" does not exist.`
});
}
clusterClient.getSocketConfig(req.params.channel).then(config => {
res.json(config);
}).catch(err => {
LOGGER.error(err.stack);
return res.status(500).json({
error: err.message
});
});
});
}

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

@ -0,0 +1,253 @@
import fs from 'fs';
import path from 'path';
import { sendPug } from './pug';
import Config from '../config';
import bodyParser from 'body-parser';
import cookieParser from 'cookie-parser';
import serveStatic from 'serve-static';
import morgan from 'morgan';
import csrf from './csrf';
import * as HTTPStatus from './httpstatus';
import { CSRFError, HTTPError } from '../errors';
import { Summary, Counter } from 'prom-client';
import session from '../session';
const verifySessionAsync = require('bluebird').promisify(session.verifySession);
const LOGGER = require('@calzoneman/jsli')('webserver');
function initializeLog(app) {
const logFormat = ':real-address - :remote-user [:date] ":method :url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"';
const logPath = path.join(__dirname, '..', '..', 'http.log');
const outputStream = fs.createWriteStream(logPath, {
flags: 'a', // append to existing file
encoding: 'utf8'
});
morgan.token('real-address', req => req.realIP);
app.use(morgan(logFormat, {
stream: outputStream
}));
}
function initPrometheus(app) {
const latency = new Summary({
name: 'cytube_http_req_duration_seconds',
help: 'HTTP Request latency from execution of the first middleware '
+ 'until the "finish" event on the response object.',
labelNames: ['method', 'statusCode']
});
const requests = new Counter({
name: 'cytube_http_req_total',
help: 'HTTP Request count',
labelNames: ['method', 'statusCode']
});
app.use((req, res, next) => {
const startTime = process.hrtime();
res.on('finish', () => {
try {
const diff = process.hrtime(startTime);
const diffSec = diff[0] + diff[1]*1e-9;
latency.labels(req.method, res.statusCode).observe(diffSec);
requests.labels(req.method, res.statusCode).inc(1, new Date());
} catch (error) {
LOGGER.error('Failed to record HTTP Prometheus metrics: %s', error.stack);
}
});
next();
});
setInterval(() => {
latency.reset();
}, 5 * 60 * 1000).unref();
}
function initializeErrorHandlers(app) {
app.use((req, res, next) => {
return next(new HTTPError(`No route for ${req.path}`, {
status: HTTPStatus.NOT_FOUND
}));
});
app.use((err, req, res, next) => {
if (err) {
if (err instanceof CSRFError) {
res.status(HTTPStatus.FORBIDDEN);
return sendPug(res, 'csrferror', {
path: req.path,
referer: req.header('referer')
});
}
let { message, status } = err;
if (!status) {
status = HTTPStatus.INTERNAL_SERVER_ERROR;
}
if (!message) {
message = 'An unknown error occurred.';
} else if (/\.(pug|js)/.test(message)) {
// Prevent leakage of stack traces
message = 'An internal error occurred.';
}
// Log 5xx (server) errors
if (Math.floor(status / 100) === 5) {
LOGGER.error(err.stack);
}
res.status(status);
return sendPug(res, 'httperror', {
path: req.path,
status: status,
message: message
});
} else {
next();
}
});
}
function patchExpressToHandleAsync() {
const Layer = require('express/lib/router/layer');
// https://github.com/expressjs/express/blob/4.x/lib/router/layer.js#L86
Layer.prototype.handle_request = function handle(req, res, next) {
const fn = this.handle;
if (fn.length > 3) {
next();
}
try {
const result = fn(req, res, next);
if (result && result.catch) {
result.catch(error => next(error));
}
} catch (error) {
next(error);
}
};
}
module.exports = {
/**
* Initializes webserver callbacks
*/
init: function (
app,
webConfig,
ioConfig,
clusterClient,
channelIndex,
session,
globalMessageBus,
emailConfig,
emailController,
captchaConfig,
captchaController
) {
patchExpressToHandleAsync();
const chanPath = Config.get('channel-path');
initPrometheus(app);
require('./middleware/x-forwarded-for').initialize(app, webConfig);
app.use(bodyParser.urlencoded({
extended: false,
limit: '32kb' // No POST data should ever exceed this size under normal usage
}));
app.use(bodyParser.json({
limit: '8kb'
}));
if (webConfig.getCookieSecret() === 'change-me') {
LOGGER.warn('The configured cookie secret was left as the ' +
'default of "change-me".');
}
app.use(cookieParser(webConfig.getCookieSecret()));
app.use(csrf.init(webConfig.getCookieDomain()));
app.use(`/${chanPath}/:channel`, require('./middleware/ipsessioncookie').ipSessionCookieMiddleware);
initializeLog(app);
require('./middleware/authorize')(app, session);
if (webConfig.getEnableGzip()) {
app.use(require('compression')({
threshold: webConfig.getGzipThreshold()
}));
LOGGER.info('Enabled gzip compression');
}
if (webConfig.getEnableMinification()) {
const cacheDir = path.join(__dirname, '..', '..', 'www', 'cache');
if (!fs.existsSync(cacheDir)) {
fs.mkdirSync(cacheDir);
}
app.use((req, res, next) => {
res.minifyOptions = res.minifyOptions || {};
if (/\.user\.js/.test(req.url)) {
res.minifyOptions.minify = false;
}
next();
});
app.use(require('express-minify')({
cache: cacheDir
}));
LOGGER.info('Enabled express-minify for CSS and JS');
}
require('./routes/channel')(app, ioConfig, chanPath);
require('./routes/index')(app, channelIndex, webConfig.getMaxIndexEntries());
require('./routes/socketconfig')(app, clusterClient);
require('./routes/contact')(app, webConfig);
require('./auth').init(app, captchaConfig, captchaController);
require('./account').init(app, globalMessageBus, emailConfig, emailController, captchaConfig);
require('./routes/account/delete-account')(
app,
csrf.verify,
require('../database/channels'),
require('../database/accounts'),
emailConfig,
emailController
);
require('./acp').init(app, ioConfig);
require('../google2vtt').attach(app);
require('./routes/google_drive_userscript')(app);
app.use(serveStatic(path.join(__dirname, '..', '..', 'www'), {
maxAge: webConfig.getCacheTTL()
}));
initializeErrorHandlers(app);
},
authorize: async function authorize(req) {
if (!req.signedCookies || !req.signedCookies.auth) {
return false;
}
try {
return await verifySessionAsync(req.signedCookies.auth);
} catch (error) {
return false;
}
},
setAuthCookie: function setAuthCookie(req, res, expiration, auth) {
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
});
}
}
};