init commit
This commit is contained in:
parent
ae639426d0
commit
7a491681cc
257 changed files with 95524 additions and 80 deletions
748
src/web/account.js
Normal file
748
src/web/account.js
Normal 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
118
src/web/acp.js
Normal 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
297
src/web/auth.js
Normal 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
45
src/web/csrf.js
Normal 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
5
src/web/httpstatus.js
Normal 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;
|
||||
13
src/web/localchannelindex.js
Normal file
13
src/web/localchannelindex.js
Normal 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));
|
||||
}
|
||||
}
|
||||
66
src/web/middleware/authorize.js
Normal file
66
src/web/middleware/authorize.js
Normal 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));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
50
src/web/middleware/ipsessioncookie.js
Normal file
50
src/web/middleware/ipsessioncookie.js
Normal 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();
|
||||
}
|
||||
29
src/web/middleware/x-forwarded-for.js
Normal file
29
src/web/middleware/x-forwarded-for.js
Normal 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
82
src/web/pug.js
Normal 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
|
||||
};
|
||||
161
src/web/routes/account/delete-account.js
Normal file
161
src/web/routes/account/delete-account.js
Normal 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
25
src/web/routes/channel.js
Normal 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
26
src/web/routes/contact.js
Normal 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
|
||||
});
|
||||
});
|
||||
}
|
||||
7
src/web/routes/google_drive_userscript.js
Normal file
7
src/web/routes/google_drive_userscript.js
Normal 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
21
src/web/routes/index.js
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
23
src/web/routes/socketconfig.js
Normal file
23
src/web/routes/socketconfig.js
Normal 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
253
src/web/webserver.js
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue