Merge branch 'web-refactoring' into 3.0

This commit is contained in:
calzoneman 2015-11-02 21:08:14 -08:00
commit de9b963d38
22 changed files with 418 additions and 224 deletions

View File

@ -3,6 +3,7 @@ try {
} catch (err) {
console.error('FATAL: Failed to require() lib/server.js');
console.error('Have you run `npm run build-server` yet to generate it?');
console.error(err.stack);
process.exit(1);
}
var Config = require("./lib/config");

View File

@ -13,6 +13,7 @@
"bluebird": "^2.10.1",
"body-parser": "^1.14.0",
"cheerio": "^0.19.0",
"clone": "^1.0.2",
"compression": "^1.5.2",
"cookie-parser": "^1.4.0",
"create-error": "^0.3.1",

View File

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

View File

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

View File

@ -42,6 +42,11 @@ var $util = require("./utilities");
var db = require("./database");
var Flags = require("./flags");
var sio = require("socket.io");
import LocalChannelIndex from './web/localchannelindex';
import IOConfiguration from './configuration/ioconfig';
import WebConfiguration from './configuration/webconfig';
import NullClusterClient from './io/cluster/nullclusterclient';
import session from './session';
var Server = function () {
var self = this;
@ -60,8 +65,17 @@ var Server = function () {
ChannelStore.init();
// webserver init -----------------------------------------------------
const ioConfig = IOConfiguration.fromOldConfig(Config);
const webConfig = WebConfiguration.fromOldConfig(Config);
const clusterClient = new NullClusterClient(ioConfig);
const channelIndex = new LocalChannelIndex();
self.express = express();
require("./web/webserver").init(self.express);
require("./web/webserver").init(self.express,
webConfig,
ioConfig,
clusterClient,
channelIndex,
session);
// http/https/sio server init -----------------------------------------
var key = "", cert = "", ca = undefined;
@ -241,4 +255,3 @@ Server.prototype.shutdown = function () {
process.exit(1);
});
};

View File

@ -92,7 +92,7 @@ function handleChangePassword(req, res) {
return;
}
Logger.eventlog.log("[account] " + webserver.ipForRequest(req) +
Logger.eventlog.log("[account] " + req.realIP +
" changed password for " + name);
db.users.getUser(name, function (err, user) {
@ -172,7 +172,7 @@ function handleChangeEmail(req, res) {
});
return;
}
Logger.eventlog.log("[account] " + webserver.ipForRequest(req) +
Logger.eventlog.log("[account] " + req.realIP +
" changed email for " + name +
" to " + email);
sendJade(res, "account-edit", {
@ -269,7 +269,7 @@ function handleNewChannel(req, res) {
db.channels.register(name, req.user.name, function (err, channel) {
if (!err) {
Logger.eventlog.log("[channel] " + req.user.name + "@" +
webserver.ipForRequest(req) +
req.realIP +
" registered channel " + name);
var sv = Server.getServer();
if (sv.isChannelLoaded(name)) {
@ -336,7 +336,7 @@ function handleDeleteChannel(req, res) {
db.channels.drop(name, function (err) {
if (!err) {
Logger.eventlog.log("[channel] " + req.user.name + "@" +
webserver.ipForRequest(req) + " deleted channel " +
req.realIP + " deleted channel " +
name);
}
var sv = Server.getServer();
@ -498,7 +498,7 @@ function handlePasswordReset(req, res) {
var hash = $util.sha1($util.randomSalt(64));
// 24-hour expiration
var expire = Date.now() + 86400000;
var ip = webserver.ipForRequest(req);
var ip = req.realIP;
db.addPasswordReset({
ip: ip,
@ -575,7 +575,7 @@ function handlePasswordRecover(req, res) {
return;
}
var ip = webserver.ipForRequest(req);
var ip = req.realIP;
db.lookupPasswordReset(hash, function (err, row) {
if (err) {

View File

@ -15,7 +15,7 @@ function checkAdmin(cb) {
if (req.user.global_rank < 255) {
res.send(403);
Logger.eventlog.log("[acp] Attempted GET "+req.path+" from non-admin " +
user.name + "@" + webserver.ipForRequest(req));
user.name + "@" + req.realIP);
return;
}

View File

@ -54,7 +54,7 @@ function handleLogin(req, res) {
if (err) {
if (err === "Invalid username/password combination") {
Logger.eventlog.log("[loginfail] Login failed (bad password): " + name
+ "@" + webserver.ipForRequest(req));
+ "@" + req.realIP);
}
sendJade(res, "login", {
loggedIn: false,
@ -127,7 +127,7 @@ function handleLogout(req, res) {
res.clearCookie("auth");
req.user = res.user = null;
// Try to find an appropriate redirect
var dest = req.query.dest || req.header("referer");
var dest = req.body.dest || req.header("referer");
dest = dest && dest.match(/login|logout|account/) ? null : dest;
var host = req.hostname;
@ -173,7 +173,7 @@ function handleRegister(req, res) {
if (typeof email !== "string") {
email = "";
}
var ip = webserver.ipForRequest(req);
var ip = req.realIP;
if (typeof name !== "string" || typeof password !== "string") {
res.sendStatus(400);
@ -234,7 +234,7 @@ module.exports = {
init: function (app) {
app.get("/login", handleLoginPage);
app.post("/login", handleLogin);
app.get("/logout", handleLogout);
app.post("/logout", handleLogout);
app.get("/register", handleRegisterPage);
app.post("/register", handleRegister);
}

View File

@ -2,8 +2,9 @@
* Adapted from https://github.com/expressjs/csurf
*/
import { CSRFError } from '../errors';
var csrf = require("csrf");
var createError = require("http-errors");
var tokens = csrf();
@ -39,8 +40,6 @@ exports.verify = function csrfVerify(req) {
var token = req.body._csrf || req.query._csrf;
if (!tokens.verify(secret, token)) {
throw createError(403, 'invalid csrf token', {
code: 'EBADCSRFTOKEN'
});
throw new CSRFError('Invalid CSRF token');
}
};

4
src/web/httpstatus.js Normal file
View File

@ -0,0 +1,4 @@
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,14 @@
import Promise from 'bluebird';
import Server from '../server';
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,19 @@
const STATIC_RESOURCE = /\..+$/;
export default function initialize(app, session) {
app.use((req, res, next) => {
if (STATIC_RESOURCE.test(req.path)) {
return next();
} else if (!req.signedCookies || !req.signedCookies.auth) {
return nuext();
} else {
session.verifySession(req.signedCookies.auth, (err, account) => {
if (!err) {
req.user = res.user = account;
}
next();
});
}
});
}

View File

@ -0,0 +1,32 @@
import net from 'net';
export default function initialize(app, webConfig) {
function isTrustedProxy(ip) {
return webConfig.getTrustedProxies().indexOf(ip) >= 0;
}
function getForwardedIP(req) {
const xForwardedFor = req.header('x-forwarded-for');
if (!xForwardedFor) {
return req.ip;
}
const ipList = xForwardedFor.split(',');
for (let i = 0; i < ipList.length; i++) {
const ip = ipList[i].trim();
if (net.isIP(ip)) {
return ip;
}
}
return req.ip;
}
app.use((req, res, next) => {
if (isTrustedProxy(req.ip)) {
req.realIP = getForwardedIP(req);
}
next();
});
}

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

@ -0,0 +1,25 @@
import CyTubeUtil from '../../utilities';
import { sanitizeText } from '../../xss';
import { sendJade } from '../jade';
import * as HTTPStatus from '../httpstatus';
import { HTTPError } from '../../errors';
export default function initialize(app, ioConfig) {
app.get('/r/: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;
sendJade(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 { sendJade } from '../jade';
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 sendJade(res, 'contact', {
contacts: contacts
});
});
}

19
src/web/routes/index.js Normal file
View File

@ -0,0 +1,19 @@
import { sendJade } from '../jade';
export default function initialize(app, channelIndex) {
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;
});
sendJade(res, 'index', {
channels: channels
});
});
});
}

View File

@ -1,16 +1,12 @@
import IOConfiguration from '../../configuration/ioconfig';
import NullClusterClient from '../../io/cluster/nullclusterclient';
import Config from '../../config';
import CyTubeUtil from '../../utilities';
import Logger from '../../logger';
import * as HTTPStatus from '../httpstatus';
export default function initialize(app) {
const ioConfig = IOConfiguration.fromOldConfig(Config);
const clusterClient = new NullClusterClient(ioConfig);
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(404).json({
return res.status(HTTPStatus.NOT_FOUND).json({
error: `Channel "${req.params.channel}" does not exist.`
});
}

View File

@ -1,55 +1,38 @@
var path = require("path");
var fs = require("fs");
var net = require("net");
var express = require("express");
var webroot = path.join(__dirname, "..", "www");
var sendJade = require("./jade").sendJade;
var Server = require("../server");
var $util = require("../utilities");
var Logger = require("../logger");
var Config = require("../config");
var db = require("../database");
var bodyParser = require("body-parser");
var cookieParser = require("cookie-parser");
var serveStatic = require("serve-static");
var morgan = require("morgan");
var session = require("../session");
var csrf = require("./csrf");
var XSS = require("../xss");
import fs from 'fs';
import path from 'path';
import net from 'net';
import express from 'express';
import { sendJade } from './jade';
import Logger from '../logger';
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 counters from "../counters";
const LOG_FORMAT = ':real-address - :remote-user [:date] ":method :url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"';
morgan.token('real-address', function (req) { return req._ip; });
/**
* Extracts an IP address from a request. Uses X-Forwarded-For if the IP is localhost
*/
function ipForRequest(req) {
var ip = req.ip;
if (ip === "127.0.0.1" || ip === "::1") {
var xforward = req.header("x-forwarded-for");
if (typeof xforward !== "string") {
xforward = [];
} else {
xforward = xforward.split(",");
}
for (var i = 0; i < xforward.length; i++) {
if (net.isIP(xforward[i])) {
return xforward[i];
}
}
return ip;
}
return ip;
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
}));
}
/**
* Redirects a request to HTTPS if the server supports it
*/
function redirectHttps(req, res) {
if (!req.secure && Config.get("https.enabled") && Config.get("https.redirect")) {
var ssldomain = Config.get("https.full-address");
if (!req.secure && Config.get('https.enabled') && Config.get('https.redirect')) {
var ssldomain = Config.get('https.full-address');
if (ssldomain.indexOf(req.hostname) < 0) {
return false;
}
@ -65,120 +48,92 @@ function redirectHttps(req, res) {
*/
function redirectHttp(req, res) {
if (req.secure) {
var domain = Config.get("http.full-address");
var domain = Config.get('http.full-address');
res.redirect(domain + req.path);
return true;
}
return false;
}
/**
* Handles a GET request for /r/:channel - serves channel.html
*/
function handleChannel(req, res) {
if (!$util.isValidChannelName(req.params.channel)) {
res.status(404);
res.send("Invalid channel name '" + XSS.sanitizeText(req.params.channel) + "'");
return;
}
var sio;
if (net.isIPv6(ipForRequest(req))) {
sio = Config.get("io.ipv6-default");
}
if (!sio) {
sio = Config.get("io.ipv4-default");
}
sio += "/socket.io/socket.io.js";
sendJade(res, "channel", {
channelName: req.params.channel,
sioSource: sio
});
}
/**
* Handles a request for the index page
*/
function handleIndex(req, res) {
var channels = Server.getServer().packChannelList(true);
channels.sort(function (a, b) {
if (a.usercount === b.usercount) {
return a.uniqueName > b.uniqueName ? -1 : 1;
}
return b.usercount - a.usercount;
});
sendJade(res, "index", {
channels: channels
});
}
/**
* Legacy socket.io configuration endpoint. This is being migrated to
* /socketconfig/<channel name>.json (see ./routes/socketconfig.js)
*/
function handleSocketConfig(req, res) {
function handleLegacySocketConfig(req, res) {
if (/\.json$/.test(req.path)) {
res.json(Config.get("sioconfigjson"));
res.json(Config.get('sioconfigjson'));
return;
}
res.type("application/javascript");
res.type('application/javascript');
var sioconfig = Config.get("sioconfig");
var sioconfig = Config.get('sioconfig');
var iourl;
var ip = ipForRequest(req);
var ip = req.realIP;
var ipv6 = false;
if (net.isIPv6(ip)) {
iourl = Config.get("io.ipv6-default");
iourl = Config.get('io.ipv6-default');
ipv6 = true;
}
if (!iourl) {
iourl = Config.get("io.ipv4-default");
iourl = Config.get('io.ipv4-default');
}
sioconfig += "var IO_URL='" + iourl + "';";
sioconfig += "var IO_V6=" + ipv6 + ";";
sioconfig += 'var IO_URL=\'' + iourl + '\';';
sioconfig += 'var IO_V6=' + ipv6 + ';';
res.send(sioconfig);
}
function handleUserAgreement(req, res) {
sendJade(res, "tos", {
domain: Config.get("http.domain")
sendJade(res, 'tos', {
domain: Config.get('http.domain')
});
}
function handleContactPage(req, res) {
// Make a copy to prevent messing with the original
var contacts = Config.get("contacts").map(function (c) {
return {
name: c.name,
email: c.email,
title: c.title
};
function initializeErrorHandlers(app) {
app.use((req, res, next) => {
return next(new HTTPError(`No route for ${req.path}`, {
status: HTTPStatus.NOT_FOUND
}));
});
// Rudimentary hiding of email addresses to prevent spambots
contacts.forEach(function (c) {
c.emkey = $util.randomSalt(16)
var email = new Array(c.email.length);
for (var i = 0; i < c.email.length; i++) {
email[i] = String.fromCharCode(
c.email.charCodeAt(i) ^ c.emkey.charCodeAt(i % c.emkey.length)
);
app.use((err, req, res, next) => {
if (err) {
if (err instanceof CSRFError) {
res.status(HTTPStatus.FORBIDDEN);
return sendJade(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 (/\.(jade|js)/.test(message)) {
// Prevent leakage of stack traces
message = 'An internal error occurred.';
}
// Log 5xx (server) errors
if (Math.floor(status / 100) === 5) {
Logger.errlog.log(err.stack);
}
res.status(status);
return sendJade(res, 'httperror', {
path: req.path,
status: status,
message: message
});
} else {
next();
}
c.email = escape(email.join(""));
c.emkey = escape(c.emkey);
});
sendJade(res, "contact", {
contacts: contacts
});
}
@ -186,100 +141,60 @@ module.exports = {
/**
* Initializes webserver callbacks
*/
init: function (app) {
app.use(function (req, res, next) {
init: function (app, webConfig, ioConfig, clusterClient, channelIndex, session) {
app.use((req, res, next) => {
counters.add("http:request", 1);
req._ip = ipForRequest(req);
next();
});
require('./middleware/x-forwarded-for')(app, webConfig);
app.use(bodyParser.urlencoded({
extended: false,
limit: '1kb' // No POST data should ever exceed this size under normal usage
}));
if (Config.get("http.cookie-secret") === "change-me") {
Logger.errlog.log("YOU SHOULD CHANGE THE VALUE OF cookie-secret IN config.yaml");
if (webConfig.getCookieSecret() === 'change-me') {
Logger.errlog.log('WARNING: The configured cookie secret was left as the ' +
'default of "change-me".');
}
app.use(cookieParser(Config.get("http.cookie-secret")));
app.use(csrf.init(Config.get("http.root-domain-dotted")));
app.use(morgan(LOG_FORMAT, {
stream: require("fs").createWriteStream(path.join(__dirname, "..", "..",
"http.log"), {
flags: "a",
encoding: "utf-8"
})
}));
app.use(cookieParser(webConfig.getCookieSecret()));
app.use(csrf.init(webConfig.getCookieDomain()));
initializeLog(app);
require('./middleware/authorize')(app, session);
app.use(function (req, res, next) {
if (req.path.match(/^\/(css|js|img|boop).*$/)) {
return next();
}
if (!req.signedCookies || !req.signedCookies.auth) {
return next();
}
session.verifySession(req.signedCookies.auth, function (err, account) {
if (!err) {
req.user = res.user = account;
}
next();
});
});
if (Config.get("http.gzip")) {
app.use(require("compression")({ threshold: Config.get("http.gzip-threshold") }));
Logger.syslog.log("Enabled gzip compression");
if (webConfig.getEnableGzip()) {
app.use(require('compression')({
threshold: webConfig.getGzipThreshold()
}));
Logger.syslog.log('Enabled gzip compression');
}
if (Config.get("http.minify")) {
var cache = path.join(__dirname, "..", "..", "www", "cache")
if (webConfig.getEnableMinification()) {
const cacheDir = path.join(__dirname, '..', '..', 'www', 'cache');
if (!fs.existsSync(cache)) {
fs.mkdirSync(cache);
}
app.use(require("express-minify")({
cache: cache
app.use(require('express-minify')({
cache: cacheDir
}));
Logger.syslog.log("Enabled express-minify for CSS and JS");
Logger.syslog.log('Enabled express-minify for CSS and JS');
}
app.get("/r/:channel", handleChannel);
app.get("/", handleIndex);
app.get("/sioconfig(.json)?", handleSocketConfig);
require("./routes/socketconfig")(app);
app.get("/useragreement", handleUserAgreement);
app.get("/contact", handleContactPage);
require("./auth").init(app);
require("./account").init(app);
require("./acp").init(app);
require("../google2vtt").attach(app);
app.use(serveStatic(path.join(__dirname, "..", "..", "www"), {
maxAge: Config.get("http.max-age") || Config.get("http.cache-ttl")
require('./routes/channel')(app, ioConfig);
require('./routes/index')(app, channelIndex);
app.get('/sioconfig(.json)?', handleLegacySocketConfig);
require('./routes/socketconfig')(app, clusterClient);
app.get('/useragreement', handleUserAgreement);
require('./routes/contact')(app, webConfig);
require('./auth').init(app);
require('./account').init(app);
require('./acp').init(app);
require('../google2vtt').attach(app);
app.use(serveStatic(path.join(__dirname, '..', '..', 'www'), {
maxAge: webConfig.getCacheTTL()
}));
app.use(function (err, req, res, next) {
if (err) {
if (err.message && err.message.match(/failed to decode param/i)) {
return res.status(400).send("Malformed path: " + req.path);
} else if (err.message && err.message.match(/range not satisfiable/i)) {
return res.status(416).end();
} else if (err.message && err.message.match(/request entity too large/i)) {
return res.status(413).end();
} else if (err.message && err.message.match(/bad request/i)) {
return res.status(400).end("Bad Request");
} else if (err.message && err.message.match(/invalid csrf token/i)) {
res.status(403);
sendJade(res, 'csrferror', { path: req.path });
return;
}
Logger.errlog.log(err.stack);
res.status(500).end();
} else {
next();
}
});
},
ipForRequest: ipForRequest,
initializeErrorHandlers(app);
},
redirectHttps: redirectHttps,

View File

@ -24,7 +24,8 @@ html(lang="en")
li A malicious user has attempted to tamper with your session
li Your browser does not support cookies, or they are not enabled
| If the problem persists, please contact an administrator.
a(href=path) Return to previous page
if referer
a(href=referer) Return to previous page
include footer
mixin footer()

38
templates/httperror.jade Normal file
View File

@ -0,0 +1,38 @@
mixin notfound()
h1 Not Found
p The page you were looking for doesn't seem to exist. Please check that you typed the URL correctly.
if message
p Reason: #{message}
mixin forbidden()
h1 Forbidden
p You don't have permission to access <code>#{path}</code>
mixin genericerror()
h1 Oops
p Your request could not be processed. Status code: <code>#{status}</code>, message: <code>#{message}</code>
doctype html
html(lang="en")
head
include head
mixin head()
body
#wrap
nav.navbar.navbar-inverse.navbar-fixed-top(role="navigation")
include nav
mixin navheader()
#nav-collapsible.collapse.navbar-collapse
ul.nav.navbar-nav
mixin navdefaultlinks(path)
mixin navloginlogout(path)
section#mainpage.container
.col-md-12
.alert.alert-danger
if status == 404
mixin notfound()
else if status == 403
mixin forbidden()
else
mixin genericerror()
include footer
mixin footer()

View File

@ -67,8 +67,10 @@ mixin navloginform(redirect)
mixin navlogoutform(redirect)
p#logoutform.navbar-text.pull-right
form#logoutform.navbar-text.pull-right(action="/logout", method="post")
input(type="hidden", name="dest", value=baseUrl + redirect)
input(type="hidden", name="_csrf", value=csrfToken)
span#welcome Welcome, #{loginName}
span &nbsp;&middot;&nbsp;
a#logout.navbar-link(href="/logout?dest=#{encodeURIComponent(baseUrl + redirect)}&_csrf=#{csrfToken}") Logout
input#logout.navbar-link(type="submit", value="Logout")

View File

@ -639,3 +639,13 @@ li.vjs-menu-item.vjs-selected {
.video-js video::-webkit-media-text-track-container {
bottom: 50px;
}
input#logout[type="submit"] {
background: none;
border: none;
padding: 0;
}
input#logout[type="submit"]:hover {
text-decoration: underline;
}