diff --git a/app.js b/app.js
index c5507da2e..b3400b939 100644
--- a/app.js
+++ b/app.js
@@ -1,4 +1,6 @@
const express = require('express');
+const nunjucks = require('nunjucks');
+const cons = require('consolidate');
const trace = require('./middleware/trace');
const logging = require('./middleware/logging');
const path = require('path');
@@ -72,7 +74,26 @@ app.use(
// VIEW CONFIGURATION
//==============================================================================
-app.set('views', path.join(__dirname, 'views'));
+// configure the default views directory.
+const views = path.join(__dirname, 'views');
+app.set('views', views);
+
+// reconfigure nunjucks.
+cons.requires.nunjucks = nunjucks.configure(views, {
+ autoescape: true,
+ trimBlocks: true,
+ lstripBlocks: true,
+ watch: process.env.NODE_ENV === 'development',
+});
+
+// assign the nunjucks engine to .njk files.
+app.engine('njk', cons.nunjucks);
+
+// assign the ejs engine to .ejs and .html files.
+app.engine('ejs', cons.ejs);
+app.engine('html', cons.ejs);
+
+// set .ejs as the default extension.
app.set('view engine', 'ejs');
//==============================================================================
diff --git a/config.js b/config.js
index 6a1f999a7..77ed4ab2b 100644
--- a/config.js
+++ b/config.js
@@ -48,6 +48,10 @@ const CONFIG = {
// request all of the records. Otherwise, minimum limits of 0 are enforced.
ALLOW_NO_LIMIT_QUERIES: process.env.TALK_ALLOW_NO_LIMIT_QUERIES === 'TRUE',
+ // ENABLE_STRICT_CSP enables strict CSP enforcement, and will enforce as well
+ // as report CSP violations.
+ ENABLE_STRICT_CSP: process.env.TALK_ENABLE_STRICT_CSP === 'TRUE',
+
// LOGGING_LEVEL specifies the logging level used by the bunyan logger.
LOGGING_LEVEL: ['fatal', 'error', 'warn', 'info', 'debug', 'trace'].includes(
process.env.TALK_LOGGING_LEVEL
diff --git a/docs/source/02-02-advanced-configuration.md b/docs/source/02-02-advanced-configuration.md
index 5b638401d..c4f86dbb8 100644
--- a/docs/source/02-02-advanced-configuration.md
+++ b/docs/source/02-02-advanced-configuration.md
@@ -497,6 +497,14 @@ tracing of GraphQL requests.
**Note: Apollo Engine is a premium service, charges may apply.**
+## TALK_ENABLE_STRICT_CSP
+
+Setting this to `TRUE` will enforce the [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP)
+(or CSP). By default, this configuration is set to
+[report only](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP#Testing_your_policy)
+where the policy is not enforced, but any violations are reported to a provided
+URI. (Default `FALSE`)
+
## ALLOW_NO_LIMIT_QUERIES
Setting this to `TRUE` will allow queries to execute without a limit (returns
diff --git a/middleware/contentSecurityPolicy.js b/middleware/contentSecurityPolicy.js
new file mode 100644
index 000000000..c47faa74e
--- /dev/null
+++ b/middleware/contentSecurityPolicy.js
@@ -0,0 +1,55 @@
+const helmet = require('helmet');
+const { WEBSOCKET_LIVE_URI, ENABLE_STRICT_CSP } = require('../config');
+const { BASE_PATH, BASE_URL, STATIC_URL } = require('../url');
+const { URL } = require('url');
+
+// websocketUri represents the host where we can connect for websocket requests.
+const websocketUri = new URL(WEBSOCKET_LIVE_URI || BASE_URL);
+websocketUri.protocol = websocketUri.protocol.startsWith('https')
+ ? 'wss'
+ : 'ws';
+const { origin: websocketSrc } = websocketUri;
+
+// staticSrc represents any static asset hosted on the static host.
+const { host: staticSrc } = new URL(STATIC_URL);
+
+// nonceSrc represents the nonce source that is used to indicate a safe resource
+// to load.
+const nonceSrc = (req, res) => `'nonce-${res.locals.nonce}'`;
+
+module.exports = helmet.contentSecurityPolicy({
+ directives: {
+ reportUri: `${BASE_PATH}api/v1/csp`, // report all policy violations to our reporting uri
+ defaultSrc: ["'none'"], // by default, do not allow anything at all
+ scriptSrc: [
+ "'self'",
+ 'https://ajax.googleapis.com', // for jquery
+ staticSrc, // for any static files loaded from a cdn
+ nonceSrc,
+ ],
+ styleSrc: [
+ "'self'",
+ 'https://maxcdn.bootstrapcdn.com', // for bootstrap css
+ 'https://fonts.googleapis.com', // for google fonts
+ 'https://code.getmdl.io', // for mdl css
+ staticSrc, // for any static files loaded from a cdn
+ nonceSrc,
+ ],
+ connectSrc: ["'self'", websocketSrc],
+ fontSrc: [
+ "'self'",
+ 'https://maxcdn.bootstrapcdn.com', // for font-awesome
+ 'https://fonts.gstatic.com', // for google fonts
+ staticSrc, // for any static files loaded from a cdn
+ nonceSrc,
+ ],
+ imgSrc: [
+ "'self'",
+ staticSrc, // for any static files loaded from a cdn
+ nonceSrc,
+ ],
+ },
+ browserSniff: false,
+ // Allow the configuration to disable strict enforcement of CSP.
+ reportOnly: !ENABLE_STRICT_CSP,
+});
diff --git a/middleware/nonce.js b/middleware/nonce.js
new file mode 100644
index 000000000..1181fac85
--- /dev/null
+++ b/middleware/nonce.js
@@ -0,0 +1,9 @@
+const uuid = require('uuid/v4');
+
+// nonce is designed to create a random value that can be used in conjunction
+// with the csp middleware.
+module.exports = (req, res, next) => {
+ res.locals.nonce = uuid();
+
+ next();
+};
diff --git a/package.json b/package.json
index 5dcb5c6ff..b21e2f78a 100644
--- a/package.json
+++ b/package.json
@@ -90,6 +90,7 @@
"common-tags": "^1.4.0",
"compression": "1.7.1",
"compression-webpack-plugin": "^1.0.0",
+ "consolidate": "0.14.0",
"cookie-parser": "^1.4.3",
"copy-webpack-plugin": "^4.0.0",
"cross-spawn": "^5.1.0",
diff --git a/plugins/talk-plugin-notifications/server/router.js b/plugins/talk-plugin-notifications/server/router.js
index 68919a936..9185c5aba 100644
--- a/plugins/talk-plugin-notifications/server/router.js
+++ b/plugins/talk-plugin-notifications/server/router.js
@@ -4,7 +4,7 @@ const { get, isEmpty, reduce } = require('lodash');
module.exports = router => {
router.get('/account/unsubscribe-notifications', (req, res) => {
- res.render(path.join(__dirname, 'views/unsubscribe-notifications'));
+ res.render(path.join(__dirname, 'views/unsubscribe-notifications.njk'));
});
/**
diff --git a/plugins/talk-plugin-notifications/server/views/unsubscribe-notifications.ejs b/plugins/talk-plugin-notifications/server/views/unsubscribe-notifications.ejs
deleted file mode 100644
index 129c3115c..000000000
--- a/plugins/talk-plugin-notifications/server/views/unsubscribe-notifications.ejs
+++ /dev/null
@@ -1,64 +0,0 @@
-
-
-
-
- <%= t('talk-plugin-notifications.unsubscribe_page.unsubscribe') %>
- <%- include(root + '/partials/head') %>
-
-
-
-
-
-
<%= t('talk-plugin-notifications.unsubscribe_page.token_invalid') %>
-
<%= t('talk-plugin-notifications.unsubscribe_page.are_unsubscribed') %>
-
-
-
-
-
-
\ No newline at end of file
diff --git a/plugins/talk-plugin-notifications/server/views/unsubscribe-notifications.njk b/plugins/talk-plugin-notifications/server/views/unsubscribe-notifications.njk
new file mode 100644
index 000000000..5080277f0
--- /dev/null
+++ b/plugins/talk-plugin-notifications/server/views/unsubscribe-notifications.njk
@@ -0,0 +1,68 @@
+{% extends "templates/account.njk" %}
+
+{% block title %}{{ t('talk-plugin-notifications.unsubscribe_page.unsubscribe') }}{% endblock %}
+
+{% block css %}
+{{ super() }}
+
+{% endblock %}
+
+{% block html %}
+
+
{{ t('talk-plugin-notifications.unsubscribe_page.token_invalid') }}
+
{{ t('talk-plugin-notifications.unsubscribe_page.are_unsubscribed') }}
+
+
+{% endblock %}
+
+{% block js %}
+
+
+{% endblock %}
diff --git a/plugins/talk-plugin-profile-data/server/router.js b/plugins/talk-plugin-profile-data/server/router.js
index 6d8f47d03..6bd165e64 100644
--- a/plugins/talk-plugin-profile-data/server/router.js
+++ b/plugins/talk-plugin-profile-data/server/router.js
@@ -102,7 +102,7 @@ async function loadComments(ctx, userID, archive, latestContentDate) {
module.exports = router => {
// /account/download will render the download page.
router.get('/account/download', (req, res) => {
- res.render(path.join(__dirname, 'views', 'download'));
+ res.render(path.join(__dirname, 'views', 'download.njk'));
});
// /api/v1/account/download will send back a zipped archive of the users
diff --git a/plugins/talk-plugin-profile-data/server/views/download.ejs b/plugins/talk-plugin-profile-data/server/views/download.ejs
deleted file mode 100644
index 260badf3a..000000000
--- a/plugins/talk-plugin-profile-data/server/views/download.ejs
+++ /dev/null
@@ -1,56 +0,0 @@
-
-
-
- <%= t('download_landing.download_your_account') %>
- <%- include(root + '/partials/account') %>
-
-
-
-
- <%= t('download_landing.download_your_account') %>
- <%= t('download_landing.download_details') %>
- <%= t('download_landing.all_information_included') %>
-
- - <%= t('download_landing.information_included.date') %>
- - <%= t('download_landing.information_included.url') %>
- - <%= t('download_landing.information_included.body') %>
- - <%= t('download_landing.information_included.asset_url') %>
-
-
-
-
-
-
-
-
-
diff --git a/plugins/talk-plugin-profile-data/server/views/download.njk b/plugins/talk-plugin-profile-data/server/views/download.njk
new file mode 100644
index 000000000..ee9c94272
--- /dev/null
+++ b/plugins/talk-plugin-profile-data/server/views/download.njk
@@ -0,0 +1,56 @@
+{% extends "templates/account.njk" %}
+
+{% block title %}{{ t('download_landing.download_your_account') }}{% endblock %}
+
+{% block html %}
+
+
+ {{ t('download_landing.download_your_account') }}
+ {{ t('download_landing.download_details') }}
+ {{ t('download_landing.all_information_included') }}
+
+ - {{ t('download_landing.information_included.date') }}
+ - {{ t('download_landing.information_included.url') }}
+ - {{ t('download_landing.information_included.body') }}
+ - {{ t('download_landing.information_included.asset_url') }}
+
+
+
+
+
+{% endblock %}
+
+{% block js %}
+
+
+{% endblock %}
diff --git a/routes/account/index.js b/routes/account/index.js
index 70e62accc..37b26e344 100644
--- a/routes/account/index.js
+++ b/routes/account/index.js
@@ -2,11 +2,11 @@ const express = require('express');
const router = express.Router();
router.get('/email/confirm', (req, res) => {
- res.render('account/email/confirm');
+ res.render('account/email/confirm.njk');
});
router.get('/password/reset', (req, res) => {
- res.render('account/password/reset');
+ res.render('account/password/reset.njk');
});
module.exports = router;
diff --git a/routes/admin/index.js b/routes/admin/index.js
index d00ef8642..1a9993cfd 100644
--- a/routes/admin/index.js
+++ b/routes/admin/index.js
@@ -2,7 +2,7 @@ const express = require('express');
const router = express.Router();
router.get('*', (req, res) => {
- res.render('admin');
+ res.render('admin.njk');
});
module.exports = router;
diff --git a/routes/api/v1/csp.js b/routes/api/v1/csp.js
new file mode 100644
index 000000000..ad1101987
--- /dev/null
+++ b/routes/api/v1/csp.js
@@ -0,0 +1,33 @@
+const express = require('express');
+const Joi = require('joi');
+const { logger } = require('../../../services/logging');
+const router = express.Router();
+
+const schema = Joi.object().keys({
+ 'csp-report': Joi.object().keys({
+ 'document-uri': Joi.string(),
+ referrer: Joi.string(),
+ 'blocked-uri': Joi.string(),
+ 'violated-directive': Joi.string(),
+ 'original-policy': Joi.string(),
+ }),
+});
+
+const json = express.json({ type: 'application/csp-report' });
+
+router.post('/', json, async (req, res, next) => {
+ const { value, error: err } = Joi.validate(req.body, schema, {
+ stripUnknown: true,
+ presence: 'required',
+ });
+ if (err) {
+ res.status(400).end();
+ return;
+ }
+
+ logger.error({ report: value }, 'csp violation reported');
+
+ res.status(202).end();
+});
+
+module.exports = router;
diff --git a/routes/api/v1/graph.js b/routes/api/v1/graph.js
index 40e87415b..0660755ea 100644
--- a/routes/api/v1/graph.js
+++ b/routes/api/v1/graph.js
@@ -10,7 +10,7 @@ router.use('/ql', apollo.graphqlExpress(createGraphOptions));
if (process.env.NODE_ENV !== 'production') {
// Interactive graphiql interface.
router.use('/iql', staticTemplate, (req, res) => {
- res.render('api/graphiql', {
+ res.render('api/graphiql.njk', {
endpointURL: 'api/v1/graph/ql',
});
});
diff --git a/routes/api/v1/index.js b/routes/api/v1/index.js
index 46d5c0278..7f715c854 100644
--- a/routes/api/v1/index.js
+++ b/routes/api/v1/index.js
@@ -9,6 +9,7 @@ router.get('/', (req, res) => {
});
router.use('/account', require('./account'));
+router.use('/csp', require('./csp'));
router.use('/assets', require('./assets'));
router.use('/auth', require('./auth'));
router.use('/graph', require('./graph'));
diff --git a/routes/dev/assets.js b/routes/dev/assets.js
index aea0628e3..df59f21a0 100644
--- a/routes/dev/assets.js
+++ b/routes/dev/assets.js
@@ -11,7 +11,7 @@ router.get('/id/:asset_id', async (req, res, next) => {
throw new ErrNotFound();
}
- res.render('dev/article', {
+ res.render('dev/article.njk', {
title: asset.title,
asset_id: asset.id,
asset_url: asset.url,
@@ -28,7 +28,7 @@ router.get('/random', (req, res) => {
});
router.get('/title/:asset_title', (req, res) => {
- res.render('dev/article', {
+ res.render('dev/article.njk', {
title: req.params.asset_title.split('-').join(' '),
asset_url: '',
asset_id: null,
@@ -48,7 +48,7 @@ router.get('/', async (req, res, next) => {
Asset.count(),
]);
- res.render('dev/articles', {
+ res.render('dev/articles.njk', {
skip,
limit,
count,
diff --git a/routes/dev/index.js b/routes/dev/index.js
index 58daca583..580e702fd 100644
--- a/routes/dev/index.js
+++ b/routes/dev/index.js
@@ -12,7 +12,7 @@ router.get('/', staticTemplate, async (req, res) => {
await SetupService.isAvailable();
return res.redirect(url.resolve(MOUNT_PATH, 'admin/install'));
} catch (e) {
- return res.render('dev/article', {
+ return res.render('dev/article.njk', {
title: 'Coral Talk',
asset_url: '',
asset_id: '',
diff --git a/routes/embed/index.js b/routes/embed/index.js
index 695ffe609..7456a5b71 100644
--- a/routes/embed/index.js
+++ b/routes/embed/index.js
@@ -2,7 +2,7 @@ const express = require('express');
const router = express.Router();
router.use('/stream', (req, res) => {
- res.render('embed/stream');
+ res.render('embed/stream.njk');
});
module.exports = router;
diff --git a/routes/index.js b/routes/index.js
index 2dbfd51ff..5183e2881 100644
--- a/routes/index.js
+++ b/routes/index.js
@@ -9,7 +9,9 @@ const path = require('path');
const compression = require('compression');
const plugins = require('../services/plugins');
const staticTemplate = require('../middleware/staticTemplate');
-const staticMiddleware = require('express-static-gzip');
+const contentSecurityPolicy = require('../middleware/contentSecurityPolicy');
+const nonce = require('../middleware/nonce');
+const staticServer = require('express-static-gzip');
const { DISABLE_STATIC_SERVER } = require('../config');
const { passport } = require('../services/passport');
const { MOUNT_PATH } = require('../url');
@@ -48,7 +50,7 @@ if (!DISABLE_STATIC_SERVER) {
if (process.env.NODE_ENV === 'production') {
router.use(
'/static',
- staticMiddleware(dist, {
+ staticServer(dist, {
indexFromEmptyFile: false,
enableBrotli: true,
customCompressions: [
@@ -74,10 +76,12 @@ router.use(compression());
// STATIC ROUTES
//==============================================================================
-router.use('/admin', staticTemplate, require('./admin'));
-router.use('/account', staticTemplate, require('./account'));
-router.use('/login', staticTemplate, require('./login'));
-router.use('/embed', staticTemplate, require('./embed'));
+const staticMiddleware = [staticTemplate, nonce, contentSecurityPolicy];
+
+router.use('/admin', ...staticMiddleware, require('./admin'));
+router.use('/account', ...staticMiddleware, require('./account'));
+router.use('/login', ...staticMiddleware, require('./login'));
+router.use('/embed', ...staticMiddleware, require('./embed'));
//==============================================================================
// PASSPORT MIDDLEWARE
diff --git a/routes/login/index.js b/routes/login/index.js
index b69cb7f5c..e03690dd0 100644
--- a/routes/login/index.js
+++ b/routes/login/index.js
@@ -2,7 +2,7 @@ const express = require('express');
const router = express.Router();
router.get('*', (req, res) => {
- res.render('login');
+ res.render('login.njk');
});
module.exports = router;
diff --git a/routes/plugins.js b/routes/plugins.js
index 647acdb75..fcc1125b9 100644
--- a/routes/plugins.js
+++ b/routes/plugins.js
@@ -2,15 +2,13 @@ const express = require('express');
const debug = require('debug')('talk:routes:plugins');
const plugins = require('../services/plugins');
const staticTemplate = require('../middleware/staticTemplate');
+const contentSecurityPolicy = require('../middleware/contentSecurityPolicy');
+const nonce = require('../middleware/nonce');
const router = express.Router();
-// Routes mounted from plugins won't have access to our internal partials
-// directory, so we should make that available.
-router.use(staticTemplate, (req, res, next) => {
- res.locals.root = res.app.get('views');
- next();
-});
+// Apply the middleware.
+router.use(staticTemplate, nonce, contentSecurityPolicy);
// Inject server route plugins.
plugins.get('server', 'router').forEach(plugin => {
diff --git a/services/passport.js b/services/passport.js
index e4813e419..4b96d2352 100644
--- a/services/passport.js
+++ b/services/passport.js
@@ -115,13 +115,13 @@ const HandleAuthPopupCallback = (req, res, next) => (err, user) => {
res.locals.encodeJSONForHTML = encodeJSONForHTML;
if (err) {
- return res.render('auth-callback', {
+ return res.render('auth-callback.njk', {
auth: { err, data: null },
});
}
if (!user) {
- return res.render('auth-callback', {
+ return res.render('auth-callback.njk', {
auth: { err: new ErrNotAuthorized(), data: null },
});
}
@@ -132,7 +132,7 @@ const HandleAuthPopupCallback = (req, res, next) => (err, user) => {
SetTokenForSafari(req, res, token);
// We logged in the user! Let's send back the user data.
- res.render('auth-callback', {
+ res.render('auth-callback.njk', {
auth: { err: null, data: { user, token } },
});
};
diff --git a/views/account/email/confirm.ejs b/views/account/email/confirm.ejs
deleted file mode 100644
index 47bc7773b..000000000
--- a/views/account/email/confirm.ejs
+++ /dev/null
@@ -1,63 +0,0 @@
-
-
-
- <%= t('confirm_email.email_confirmation') %>
- <%- include ../../partials/account %>
-
-
-
-
- <%= t('confirm_email.email_confirmation') %>
- <%= t('confirm_email.click_to_confirm') %>
-
-
-
-
-
-
-
-
diff --git a/views/account/email/confirm.njk b/views/account/email/confirm.njk
new file mode 100644
index 000000000..8e2c9ff05
--- /dev/null
+++ b/views/account/email/confirm.njk
@@ -0,0 +1,63 @@
+{% extends "templates/account.njk" %}
+
+{% block title %}{{ t('confirm_email.email_confirmation') }}{% endblock %}
+
+{% block html %}
+
+
+ {{ t('confirm_email.email_confirmation') }}
+ {{ t('confirm_email.click_to_confirm') }}
+
+
+
+
+{% endblock %}
+
+{% block js %}
+
+
+{% endblock %}
diff --git a/views/account/password/reset.ejs b/views/account/password/reset.ejs
deleted file mode 100644
index c8b106139..000000000
--- a/views/account/password/reset.ejs
+++ /dev/null
@@ -1,85 +0,0 @@
-
-
-
- <%= t('password_reset.set_new_password') %>
- <%- include ../../partials/account %>
-
-
-
-
- <%= t('password_reset.set_new_password') %>
- <%= t('password_reset.change_password_help') %>
-
-
-
-
-
-
-
-
diff --git a/views/account/password/reset.njk b/views/account/password/reset.njk
new file mode 100644
index 000000000..6eee12dd0
--- /dev/null
+++ b/views/account/password/reset.njk
@@ -0,0 +1,85 @@
+{% extends "templates/account.njk" %}
+
+{% block title %}{{ t('password_reset.set_new_password') }}{% endblock %}
+
+{% block html %}
+
+
+ {{ t('password_reset.set_new_password') }}
+ {{ t('password_reset.change_password_help') }}
+
+
+
+
+{% endblock %}
+
+{% block js %}
+
+
+{% endblock %}
diff --git a/views/admin.ejs b/views/admin.ejs
deleted file mode 100644
index 89cc150f7..000000000
--- a/views/admin.ejs
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
- Talk - Coral Admin
- <%- include partials/head %>
-
-
-
- <%- include partials/custom-css %>
-
-
-
-
-
-
-
diff --git a/views/admin.njk b/views/admin.njk
new file mode 100644
index 000000000..1b60f6e76
--- /dev/null
+++ b/views/admin.njk
@@ -0,0 +1,14 @@
+{% extends "templates/base.njk" %}
+
+{% block title %}Talk Admin{% endblock %}
+
+{% block css %}
+
+
+
+{% endblock %}
+
+{% block js %}
+
+
+{% endblock %}
diff --git a/views/api/graphiql.ejs b/views/api/graphiql.ejs
deleted file mode 100644
index 8007569b3..000000000
--- a/views/api/graphiql.ejs
+++ /dev/null
@@ -1,125 +0,0 @@
-
-
-
-
-
- GraphiQL
-
- <%- include ../partials/dev %>
-
-
-
-
-
-
-
-
- <%- include ../partials/dev-nav %>
-
-
-
-
diff --git a/views/api/graphiql.njk b/views/api/graphiql.njk
new file mode 100644
index 000000000..b726918f3
--- /dev/null
+++ b/views/api/graphiql.njk
@@ -0,0 +1,126 @@
+{% extends "templates/development.njk" %}
+
+{% block title %}GraphiQL{% endblock %}
+
+{% block css %}
+{# Include the base development pieces #}
+{{ super() }}
+
+
+{% endblock %}
+
+{% block js %}
+
+
+
+
+
+{% endblock %}
+
+{% block html %}
+
+{% endblock %}
diff --git a/views/auth-callback.ejs b/views/auth-callback.ejs
deleted file mode 100644
index e4d2387b7..000000000
--- a/views/auth-callback.ejs
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
- <%- include partials/data %>
-
-
-
-
-
-
diff --git a/views/auth-callback.njk b/views/auth-callback.njk
new file mode 100644
index 000000000..7119a6255
--- /dev/null
+++ b/views/auth-callback.njk
@@ -0,0 +1,10 @@
+
+
+
+ {% include "partials/data.njk" %}
+
+
+
+
+
+
diff --git a/views/dev/article.ejs b/views/dev/article.ejs
deleted file mode 100644
index 008d3989e..000000000
--- a/views/dev/article.ejs
+++ /dev/null
@@ -1,52 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- <%= title %>
- <%- include ../partials/dev %>
-
-
- <%- include ../partials/dev-nav %>
-
-
-
diff --git a/views/dev/article.njk b/views/dev/article.njk
new file mode 100644
index 000000000..2fb8b3724
--- /dev/null
+++ b/views/dev/article.njk
@@ -0,0 +1,55 @@
+{% extends "templates/development.njk" %}
+
+{% block title %}{{ title }}{% endblock %}
+
+{% block meta %}
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block html %}
+
+{% endblock %}
+
+{% block js %}
+
+
+{% endblock %}
diff --git a/views/dev/articles.ejs b/views/dev/articles.ejs
deleted file mode 100644
index 1b07ed116..000000000
--- a/views/dev/articles.ejs
+++ /dev/null
@@ -1,40 +0,0 @@
-
-
- All Assets
- <%- include ../partials/dev %>
-
-
- <%- include ../partials/dev-nav %>
-
-
-
All Assets
- <%= skip + 1 %> - <%= skip + assets.length %> of <%= count %> Assets
-
-
- <% if (count !== assets.length) { %>
-
- <% } %>
-
-
-
diff --git a/views/dev/articles.njk b/views/dev/articles.njk
new file mode 100644
index 000000000..e204c39fc
--- /dev/null
+++ b/views/dev/articles.njk
@@ -0,0 +1,37 @@
+{% extends "templates/development.njk" %}
+
+{% block title %}All Assets{% endblock %}
+
+{% block html %}
+
+
+
All Assets
+ {{ skip + 1 }} - {{ skip + assets.length }} of {{ count }} Assets
+
+
+ {% if count !== assets.length %}
+
+ {% endif %}
+
+{% endblock %}
diff --git a/views/embed/stream.ejs b/views/embed/stream.ejs
deleted file mode 100644
index a1c708b55..000000000
--- a/views/embed/stream.ejs
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
- <%- include ../partials/head %>
-
-
- <%- include ../partials/custom-css %>
-
-
-
-
-
-
diff --git a/views/embed/stream.njk b/views/embed/stream.njk
new file mode 100644
index 000000000..ddc620f8e
--- /dev/null
+++ b/views/embed/stream.njk
@@ -0,0 +1,16 @@
+{% extends "templates/base.njk" %}
+
+{% block title %}Talk{% endblock %}
+
+{% block css %}
+
+
+{% endblock %}
+
+{% block html %}
+
+{% endblock %}
+
+{% block js %}
+
+{% endblock %}
diff --git a/views/login.ejs b/views/login.ejs
deleted file mode 100644
index 671b25c83..000000000
--- a/views/login.ejs
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
-
- <%- include partials/head %>
-
- <%- include partials/custom-css %>
-
-
-
-
-
-
-
diff --git a/views/login.njk b/views/login.njk
new file mode 100644
index 000000000..6a3c1112f
--- /dev/null
+++ b/views/login.njk
@@ -0,0 +1,16 @@
+{% extends "templates/base.njk" %}
+
+{% block title %}Talk - Login{% endblock %}
+
+{% block css %}
+
+{% endblock %}
+
+{% block html %}
+
+{% endblock %}
+
+{% block js %}
+
+
+{% endblock %}
diff --git a/views/partials/account.ejs b/views/partials/account.ejs
deleted file mode 100644
index ff2166816..000000000
--- a/views/partials/account.ejs
+++ /dev/null
@@ -1,4 +0,0 @@
-<%- include head %>
-
-
-<%- include custom-css %>
diff --git a/views/partials/custom-css.ejs b/views/partials/custom-css.ejs
deleted file mode 100644
index d453c37e1..000000000
--- a/views/partials/custom-css.ejs
+++ /dev/null
@@ -1 +0,0 @@
-<% if (locals.customCssUrl) { %><% } %>
diff --git a/views/partials/custom-css.njk b/views/partials/custom-css.njk
new file mode 100644
index 000000000..4e5a494e6
--- /dev/null
+++ b/views/partials/custom-css.njk
@@ -0,0 +1,3 @@
+{% if customCssUrl %}
+
+{% endif %}
diff --git a/views/partials/data.ejs b/views/partials/data.ejs
deleted file mode 100644
index f0bbf5925..000000000
--- a/views/partials/data.ejs
+++ /dev/null
@@ -1,3 +0,0 @@
-<%_ if (data != null) { _%>
-
-<%_ } _%>
\ No newline at end of file
diff --git a/views/partials/data.njk b/views/partials/data.njk
new file mode 100644
index 000000000..4edaf2e2e
--- /dev/null
+++ b/views/partials/data.njk
@@ -0,0 +1,3 @@
+{% if data %}
+
+{% endif %}
diff --git a/views/partials/dev-nav.ejs b/views/partials/dev-nav.ejs
deleted file mode 100644
index 088c52c20..000000000
--- a/views/partials/dev-nav.ejs
+++ /dev/null
@@ -1,8 +0,0 @@
-
diff --git a/views/partials/favicon.njk b/views/partials/favicon.njk
new file mode 100644
index 000000000..5ec2aad8a
--- /dev/null
+++ b/views/partials/favicon.njk
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/views/partials/head.ejs b/views/partials/head.ejs
deleted file mode 100644
index dc3df7103..000000000
--- a/views/partials/head.ejs
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-<%- include data %>
-
-
diff --git a/views/partials/nav.njk b/views/partials/nav.njk
new file mode 100644
index 000000000..ecea9018d
--- /dev/null
+++ b/views/partials/nav.njk
@@ -0,0 +1,8 @@
+
diff --git a/views/templates/account.njk b/views/templates/account.njk
new file mode 100644
index 000000000..7e1b6e6c9
--- /dev/null
+++ b/views/templates/account.njk
@@ -0,0 +1,6 @@
+{% extends "templates/base.njk" %}
+
+{% block css %}
+
+
+{% endblock %}
diff --git a/views/templates/base.njk b/views/templates/base.njk
new file mode 100644
index 000000000..23048151e
--- /dev/null
+++ b/views/templates/base.njk
@@ -0,0 +1,39 @@
+
+
+
+ {# Meta tags #}
+
+
+ {% block meta %}{% endblock %}
+
+ {# Favicon Configuration #}
+ {% include "partials/favicon.njk" %}
+
+ {# CSP and security headers #}
+ {% block security %}{% endblock %}
+
+ {# Title #}
+ {% block title %}{% endblock %}
+
+ {# CSS #}
+
+
+
+ {% include "partials/custom-css.njk" %}
+ {% block css %}{% endblock %}
+
+ {# Static data injection #}
+ {% include "partials/data.njk" %}
+
+ {# Configuration #}
+
+
+
+ {% block body %}
+ {% block html %}
+
+ {% endblock %}
+ {% endblock %}
+ {% block js %}{% endblock %}
+
+
diff --git a/views/partials/dev.ejs b/views/templates/development.njk
similarity index 54%
rename from views/partials/dev.ejs
rename to views/templates/development.njk
index 691b548e5..f7fab6682 100644
--- a/views/partials/dev.ejs
+++ b/views/templates/development.njk
@@ -1,5 +1,17 @@
-
+{% extends "templates/base.njk" %}
+
+{# Null out the security block, we don't want/need that in development #}
+{% block security %}{% endblock %}
+
+{% block css %}
-
+
+{% endblock %}
+
+{% block body %}
+ {% include "partials/nav.njk" %}
+
+ {% block html %}{% endblock %}
+{% endblock %}
diff --git a/yarn.lock b/yarn.lock
index dff8e4dab..a65a3524d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2392,6 +2392,12 @@ console-control-strings@^1.0.0, console-control-strings@~1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e"
+consolidate@0.14.0:
+ version "0.14.0"
+ resolved "https://registry.yarnpkg.com/consolidate/-/consolidate-0.14.0.tgz#b03acd566a2565ca96e99f44fd1417486b4df88d"
+ dependencies:
+ bluebird "^3.1.1"
+
constantinople@^3.0.1:
version "3.1.0"
resolved "https://registry.yarnpkg.com/constantinople/-/constantinople-3.1.0.tgz#7569caa8aa3f8d5935d62e1fa96f9f702cd81c79"