diff --git a/.travis.yml b/.travis.yml
index c9c1e4b5..790ac4d5 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,4 +1,6 @@
language: node_js
node_js:
- "8"
- - "10"
\ No newline at end of file
+ - "10"
+before_script:
+ - npm run build
diff --git a/layouts/partials/head.ejs b/layouts/partials/head.ejs
index 00d8b169..a3372ad8 100644
--- a/layouts/partials/head.ejs
+++ b/layouts/partials/head.ejs
@@ -8,10 +8,13 @@
-
-
-
-
-
+ <% if (locals.inlineCSS) { %>
+
+ <% } else { %>
+
+
+
+ <% } %>
+
diff --git a/package-lock.json b/package-lock.json
index 3bcc6f73..e8def75b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5536,16 +5536,16 @@
"integrity": "sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ=="
},
"mime-db": {
- "version": "1.29.0",
- "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.29.0.tgz",
- "integrity": "sha1-SNJtI1WJZRcErFkWygYAGRQmaHg="
+ "version": "1.40.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.40.0.tgz",
+ "integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA=="
},
"mime-types": {
- "version": "2.1.16",
- "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.16.tgz",
- "integrity": "sha1-K4WKUuXs1RbbiXrCvodIeDBpjiM=",
+ "version": "2.1.24",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.24.tgz",
+ "integrity": "sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==",
"requires": {
- "mime-db": "~1.29.0"
+ "mime-db": "1.40.0"
}
},
"minimatch": {
diff --git a/package.json b/package.json
index 773246f7..149bd863 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,7 @@
"js-yaml": "^3.13.1",
"lodash": "^4.17.14",
"md5": "^2.2.1",
+ "mime-types": "^2.1.24",
"moment": "^2.24.0",
"node-sass": "^4.12.0",
"passport": "^0.4.0",
@@ -50,7 +51,9 @@
},
"scripts": {
"start": "node server/index.js",
- "build": "node-sass --include-path custom/styles --include-path styles/partials --source-map public/css/style.css.map --source-map-contents styles/style.scss public/css/style.css",
+ "build:main": "node-sass --include-path custom/styles --include-path styles/partials --source-map public/css/style.css.map --source-map-contents styles/style.scss public/css/style.css",
+ "build:inline": "node-sass --include-path custom/styles --include-path styles/partials styles/errors.scss public/css/errors.css",
+ "build": "npm run build:main && npm run build:inline",
"debug": "node -r dotenv/config --inspect --debug-brk server/index.js",
"watch": "npm run build && concurrently \"nodemon --inspect=0.0.0.0 -r dotenv/config -e js,ejs server/index.js\" \"nodemon -e scss --watch styles --watch custom/styles -x npm run build\"",
"test": "NODE_ENV=test PORT=3001 mocha test/**/*.test.js -r ./test/utils/bootstrap.js --recursive --exit",
diff --git a/server/routes/errors.js b/server/routes/errors.js
index a24d560a..de2cca83 100644
--- a/server/routes/errors.js
+++ b/server/routes/errors.js
@@ -1,10 +1,60 @@
'use strict'
const log = require('../logger')
-const {stringTemplate} = require('../utils')
+const {assetDataURI, readFileAsync, stringTemplate} = require('../utils')
+
+let assetCache
+
+// Because asset requests are authenticated, we inline the images and CSS
+// that we need to serve to logged-out users, e.g. on the auth error page.
+async function loadInlineAssets() {
+ if (assetCache) return assetCache
+
+ const assets = {}
+
+ // Load the core stylesheet.
+ // If want to add support for base64-encoding background-image URLs,
+ // we could augment this function with a regex that replaces each URL pattern
+ // in public/css/errors.css with the result of a call to assetDataURI()
+ const cssLoader = async () => {
+ const css = await readFileAsync('public/css/errors.css')
+ assets.css = css.toString()
+ }
+
+ const assetLoaders = ['branding.icon', 'branding.favicon'].map((key) => {
+ // Load essential images as base64 data-URIs
+ const loader = async () => {
+ try {
+ const img = await assetDataURI(stringTemplate(key))
+ assets[key] = img
+ } catch (err) {
+ // It's okay for users to reference non-local or non-existent files
+ // here, but any other error should throw.
+ if (err.code !== 'ENOENT') throw err
+ }
+ }
+ return loader()
+ }).concat(cssLoader())
+
+ assets.stringTemplate = (key, ...args) => {
+ return assets[key] || stringTemplate(key, ...args)
+ }
+
+ try {
+ await Promise.all(assetLoaders)
+ // Store the inlined assets in memory for subsequent requests
+ assetCache = assets
+ } catch (error) {
+ log.warn(`Error ${error.code} inlining assets!`)
+ log.info(error)
+ log.info('Falling back to linked assets instead')
+ }
+
+ return assets
+}
// generic error handler to return error pages to user
-module.exports = (err, req, res, next) => {
+module.exports = async (err, req, res, next) => {
const messages = {
'Not found': 404,
'Unauthorized': 403
@@ -12,5 +62,10 @@ module.exports = (err, req, res, next) => {
const code = messages[err.message] || 500
log.error(`Serving an error page for ${req.url}`, err)
- res.status(code).render(`errors/${code}`, {err, template: stringTemplate})
+ const inlined = await loadInlineAssets()
+ res.status(code).render(`errors/${code}`, {
+ inlineCSS: inlined.css,
+ err,
+ template: inlined.stringTemplate
+ })
}
diff --git a/server/utils.js b/server/utils.js
index 071e2313..78e8a1bb 100644
--- a/server/utils.js
+++ b/server/utils.js
@@ -1,9 +1,11 @@
'use strict'
const fs = require('fs')
const path = require('path')
+const {promisify} = require('util')
const yaml = require('js-yaml')
const { get: deepProp } = require('lodash')
const merge = require('deepmerge')
+const mime = require('mime-types')
const log = require('./logger')
@@ -101,3 +103,22 @@ exports.stringTemplate = (configPath, ...args) => {
return ''
}
+
+// When we stop supporting Node 8, let's drop promisify for fs.promises.readFile
+const readFileAsync = promisify(fs.readFile)
+exports.readFileAsync = readFileAsync
+exports.assetDataURI = async (filePath) => {
+ // If the path starts with `/assets`, look in the app’s public directory
+ const publicPath = filePath.replace(/^\/assets/, '/public')
+
+ // We're using path.posix.basename instead of just path.basename here, because
+ // publicPath is definitely formatted in the POSIX style, and we want
+ // consistent output across *nix and Windows. For reference:
+ // https://nodejs.org/api/path.html#path_windows_vs_posix
+ const mimeType = mime.lookup(path.posix.basename(publicPath))
+ const fullPath = path.join(__dirname, '..', publicPath)
+
+ const data = await readFileAsync(fullPath, { encoding: 'base64' })
+ const src = `data:${mimeType};base64,${data}`
+ return src
+}
diff --git a/styles/errors.scss b/styles/errors.scss
new file mode 100644
index 00000000..8e4568ef
--- /dev/null
+++ b/styles/errors.scss
@@ -0,0 +1,18 @@
+// When serving error pages, the app inlines the styles below instead of
+// including the main stylesheet via a tag. This makes it possible to
+// style error pages for logged-out visitors. (The main stylesheet is behind
+// the authentication layer.)
+
+// top level templating values that can be overridden
+@import "vars";
+@import "theme";
+
+// core layout styles rarely overridden
+@import "core/mixins";
+@import "core/base";
+@import "core/furniture";
+@import "core/pages";
+@import "core/errata";
+
+// final stylesheet with any additional styles for custom logic
+@import "custom";
diff --git a/styles/partials/core/_errata.scss b/styles/partials/core/_errata.scss
new file mode 100644
index 00000000..5b4b2508
--- /dev/null
+++ b/styles/partials/core/_errata.scss
@@ -0,0 +1,3 @@
+.masthead .user-tools {
+ display: none;
+}
diff --git a/test/functional/pages.test.js b/test/functional/pages.test.js
index 1f8c078d..bf7f3e9c 100644
--- a/test/functional/pages.test.js
+++ b/test/functional/pages.test.js
@@ -109,6 +109,17 @@ describe('Server responses', () => {
)
})
})
+
+ it('should render an inline