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