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/client/coral-framework/services/i18n.js b/client/coral-framework/services/i18n.js index 03a0b4712..02258a585 100644 --- a/client/coral-framework/services/i18n.js +++ b/client/coral-framework/services/i18n.js @@ -1,7 +1,10 @@ -import ta from 'timeago.js'; +import { negotiateLanguages } from 'fluent-langneg/compat'; + import has from 'lodash/has'; import get from 'lodash/get'; import merge from 'lodash/merge'; +import first from 'lodash/first'; +import isUndefined from 'lodash/isUndefined'; import moment from 'moment'; import 'moment/locale/ar'; @@ -12,8 +15,8 @@ import 'moment/locale/fr'; import 'moment/locale/nl'; import 'moment/locale/pt-br'; -import { createStorage } from 'coral-framework/services/storage'; - +// timeago +import ta from 'timeago.js'; import arTA from 'timeago.js/locales/ar'; import daTA from 'timeago.js/locales/da'; import deTA from 'timeago.js/locales/de'; @@ -24,6 +27,7 @@ import pt_BRTA from 'timeago.js/locales/pt_BR'; import zh_CNTA from 'timeago.js/locales/zh_CN'; import zh_TWTA from 'timeago.js/locales/zh_TW'; +// locales import ar from '../../../locales/ar.yml'; import en from '../../../locales/en.yml'; import da from '../../../locales/da.yml'; @@ -35,8 +39,9 @@ import pt_BR from '../../../locales/pt_BR.yml'; import zh_CN from '../../../locales/zh_CN.yml'; import zh_TW from '../../../locales/zh_TW.yml'; -const defaultLanguage = process.env.TALK_DEFAULT_LANG; -const translations = { +export const defaultLocale = process.env.TALK_DEFAULT_LANG.replace(/-/g, '_'); + +export const translations = { ...ar, ...en, ...da, @@ -49,84 +54,62 @@ const translations = { ...zh_TW, }; -let lang; -let timeagoInstance; +export const supportedLocales = Object.keys(translations); -function setLocale(storage, locale) { - storage.setItem('locale', locale); -} +let LOCALE; +let TIMEAGO_INSTANCE; // detectLanguage will try to get the locale from storage if available, // otherwise will try to get it from the navigator, otherwise, it will fallback // to the default language. -function detectLanguage(storage) { - try { - const lang = storage.getItem('locale') || navigator.language; - if (lang) { - return lang; - } - } catch (err) { - console.warn( - 'Error while trying to detect language, will fallback to', - err - ); - } - - console.warn('Could not detect language, will fallback to', defaultLanguage); - return defaultLanguage; -} - -// getLocale will get the users locale from the local detector and parse it to a -// format we can work with. -function getLocale(storage) { - // Get the language from the local detector. - const lang = detectLanguage(storage); - - // Some language strings come with additional subtags as defined in: - // - // https://www.ietf.org/rfc/bcp/bcp47.txt - // - // So we should strip that off if we find it. - return lang.split('-')[0]; -} +const detectLanguage = () => + first( + negotiateLanguages(navigator.languages, supportedLocales, { + defaultLocale, + strategy: 'lookup', + }) + ); export function setupTranslations() { - // Setup the translation framework with the storage. - const storage = createStorage('localStorage'); + // locale + LOCALE = detectLanguage(); - const locale = getLocale(storage); - setLocale(storage, locale); - - // Setting moment - moment.locale(locale); - - // Extract language key. - lang = locale.split('-')[0]; - - // Check if we have a translation in this language. - if (!(lang in translations)) { - lang = defaultLanguage; - } + // moment + moment.locale(LOCALE); + // timeago ta.register('ar', arTA); ta.register('es', esTA); ta.register('da', daTA); ta.register('de', deTA); ta.register('fr', frTA); - ta.register('nl_NL', nlTA); - ta.register('pt_BR', pt_BRTA); - ta.register('zh_CN', zh_CNTA); - ta.register('zh_TW', zh_TWTA); - - timeagoInstance = ta(); + ta.register('nl-NL', nlTA); + ta.register('pt-BR', pt_BRTA); + ta.register('zh-CN', zh_CNTA); + ta.register('zh-TW', zh_TWTA); + TIMEAGO_INSTANCE = ta(); } +/** + * loadTranslations will load the new language pack into the existing ones. + * + * @param {Object} newTranslations translation object to merge into the existing + * languages. + */ export function loadTranslations(newTranslations) { + // Merge the new translations into the existing translations. merge(translations, newTranslations); + + // Push new languages into the supportedLocales array. + Object.keys(newTranslations).forEach(language => { + if (!supportedLocales.includes(language)) { + supportedLocales.push(language); + } + }); } export function timeago(time) { - return timeagoInstance.format(new Date(time), lang); + return TIMEAGO_INSTANCE.format(new Date(time), LOCALE); } /** @@ -140,24 +123,24 @@ export function timeago(time) { */ export function t(key, ...replacements) { let translation; - if (has(translations[lang], key)) { - translation = get(translations[lang], key); + if (has(translations[LOCALE], key)) { + translation = get(translations[LOCALE], key); } else if (has(translations['en'], key)) { translation = get(translations['en'], key); - console.warn(`${lang}.${key} language key not set`); + console.warn(`${LOCALE}.${key} language key not set`); } - if (translation) { - // replace any {n} with the arguments passed to this method - replacements.forEach((str, i) => { - translation = translation.replace(new RegExp(`\\{${i}\\}`, 'g'), str); - }); - - return translation; - } else { - console.warn(`${lang}.${key} and en.${key} language key not set`); + if (!translation) { + console.warn(`${LOCALE}.${key} and en.${key} language key not set`); return key; } + + // Handle replacements in the translation string. + return translation.replace( + /{(\d+)}/g, + (match, number) => + !isUndefined(replacements[number]) ? replacements[number] : match + ); } export default t; 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/docs/source/03-08-gdpr.md b/docs/source/03-08-gdpr.md index b6689be0e..e3fd8623f 100644 --- a/docs/source/03-08-gdpr.md +++ b/docs/source/03-08-gdpr.md @@ -11,9 +11,9 @@ can enable the following plugins: - [talk-plugin-local-auth](/talk/plugin/talk-plugin-local-auth) - to facilitate email changes and email association - [talk-plugin-profile-data](/talk/plugin/talk-plugin-profile-data) - to facilitate account download and deletion -Even if you don't reside in a location where GDPR will apply, it is recommended -to enable these features as a best practice to provide your users with control over their -own data. +Even if GDPR will not apply to you, it is recommended to enable these +features as a best practice to provide your users with control over their own +data. ## GPDR Feature Overview diff --git a/docs/source/integrating/authentication.md b/docs/source/integrating/authentication.md index b7340caf5..5b19768fe 100644 --- a/docs/source/integrating/authentication.md +++ b/docs/source/integrating/authentication.md @@ -81,5 +81,5 @@ example issuer and Talk must match: reference, the basic takeaway is that the secret used to sign the tokens issued by the issuer must be able to be verified by Talk. -For an example of implementing the plugin, refer to [`tokenUserNotFound`](/talk/reference/server/#tokenUserNotFound) +For an example of implementing the plugin, refer to [`tokenUserNotFound`](/talk/api/server/#tokenusernotfound) reference. diff --git a/locales/fi_FI.yml b/locales/fi_FI.yml index 71416b4ff..494a48cdb 100644 --- a/locales/fi_FI.yml +++ b/locales/fi_FI.yml @@ -413,7 +413,7 @@ fi_FI: title_reject: "Huomasimme sinun hylänneen käyttäjänimen" suspend_user: "Aseta väliaikainen käyttökielto" yes_suspend: "Kyllä, sulje väliaikaisesti" - email_message_reject: "Toinen yhteisön jäsen on ilmiantanut käyttäjänimesi ja sen perusteella nimi on hylätty. Et voi enää osallistua keskusteluun. Ole ystävällisesti yhteydessä meihin, jos sinulla on asiasta kysyttävää." + email_message_reject: "Toinen yhteisön jäsen on ilmiantanut käyttäjänimesi ja sen perusteella nimi on hylätty. Et voi enää osallistua keskusteluun. Ole ystävällisesti yhteydessä meihin, jos sinulla on asiasta kysyttävää." write_message: "Kirjoita viesti" send: Lähetä thank_you: "Arvostamme palautettasi. Moderaattorimme käy läpi tekemäsi ilmiannon." @@ -462,4 +462,4 @@ fi_FI: close: "Sulje asennusnäkymä" admin_sidebar: view_options: "Näytä asetukset" -sort_comments: "Järjestä kommentit" \ No newline at end of file + sort_comments: "Järjestä kommentit" 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 c387d6145..b21e2f78a 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,6 @@ "dependencies": { "@coralproject/gql-merge": "^0.1.0", "@coralproject/graphql-anywhere-optimized": "^0.1.0", - "accepts": "^1.3.4", "apollo-client": "^1.9.1", "apollo-engine": "^0.8.1", "apollo-server-express": "^1.2.0", @@ -91,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", @@ -108,6 +108,7 @@ "express-static-gzip": "^0.3.1", "extract-text-webpack-plugin": "^3.0.2", "file-loader": "^0.11.2", + "fluent-langneg": "^0.1.0", "form-data": "^2.3.1", "fs-extra": "^4.0.1", "graphql": "^0.10.1", 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.click_to_confirm') %> - -
-
- - - - \ 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') }}
+
+ {{ t('talk-plugin-notifications.unsubscribe_page.click_to_confirm') }} + +
+
+{% 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') %>

- -
-
- -
-
-
- - - - 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') }}

+ +
+
+ +
+
+
+{% 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/i18n.js b/services/i18n.js index 666f15a59..52c34a4ed 100644 --- a/services/i18n.js +++ b/services/i18n.js @@ -1,8 +1,11 @@ const fs = require('fs'); const path = require('path'); const debug = require('debug')('talk:services:i18n'); -const accepts = require('accepts'); -const { get, has, merge } = require('lodash'); +const { + acceptedLanguages, + negotiateLanguages, +} = require('fluent-langneg/compat'); +const { first, get, has, merge, isUndefined } = require('lodash'); const yaml = require('yamljs'); const plugins = require('./plugins'); const { DEFAULT_LANG } = require('../config'); @@ -11,7 +14,7 @@ const resolve = (...paths) => path.resolve(path.join(__dirname, '..', 'locales', ...paths)); // Load all the translations. -let translations = fs +const translations = fs .readdirSync(resolve()) // Resolve all the filenames relative the the locales directory. @@ -23,26 +26,22 @@ let translations = fs // Load the translation files from disk. .map(filename => fs.readFileSync(filename, 'utf8')) - // Load the translation files. - .reduce((packs, contents) => { - const pack = yaml.parse(contents); - - return merge(packs, pack); - }, {}); + // Load the translation files and merge the yaml into the existing packs. + .reduce((packs, contents) => merge(packs, yaml.parse(contents)), {}); // Create a list of all supported translations. -const languages = Object.keys(translations); +const supportedLocales = Object.keys(translations); // Move the default language to the front. -if (languages.includes(DEFAULT_LANG)) { - const from = languages.indexOf(DEFAULT_LANG); - languages.splice(from, 1); - languages.splice(0, 0, DEFAULT_LANG); +if (supportedLocales.includes(DEFAULT_LANG)) { + const from = supportedLocales.indexOf(DEFAULT_LANG); + supportedLocales.splice(from, 1); + supportedLocales.splice(0, 0, DEFAULT_LANG); } -debug(`loaded language sets for ${languages}`); +debug(`loaded language sets for ${supportedLocales}`); let loadedPluginTranslations = false; -const loadPluginTranslations = () => { +const lazyLoadPluginTranslations = () => { if (loadedPluginTranslations) { return; } @@ -55,7 +54,15 @@ const loadPluginTranslations = () => { const pack = yaml.parse(fs.readFileSync(filename, 'utf8')); - translations = merge(translations, pack); + // Merge the translations into the system translations. + merge(translations, pack); + + // Push new languages into the supportedLocales array. + Object.keys(pack).forEach(language => { + if (!supportedLocales.includes(language)) { + supportedLocales.push(language); + } + }); }); loadedPluginTranslations = true; @@ -64,7 +71,7 @@ const loadPluginTranslations = () => { const t = lang => (key, ...replacements) => { // Loads the translations into the translations array from plugins. This is // done lazily to ensure that we don't have an import cycle. - loadPluginTranslations(); + lazyLoadPluginTranslations(); let translation; if (has(translations[lang], key)) { @@ -74,16 +81,17 @@ const t = lang => (key, ...replacements) => { console.warn(`${lang}.${key} language key not set`); } - if (translation) { - // replace any {n} with the arguments passed to this method - replacements.forEach((str, i) => { - translation = translation.replace(new RegExp(`\\{${i}\\}`, 'g'), str); - }); - return translation; - } else { + if (!translation) { console.warn(`${lang}.${key} and en.${key} language key not set`); return key; } + + // Handle replacements in the translation string. + return translation.replace( + /{(\d+)}/g, + (match, number) => + !isUndefined(replacements[number]) ? replacements[number] : match + ); }; /** @@ -92,13 +100,19 @@ const t = lang => (key, ...replacements) => { */ const i18n = { request(req) { - debug(`possible languages given request '${accepts(req).languages()}'`); - const lang = accepts(req).language(languages); - debug(`parsed request language as '${lang}'`); - const language = lang ? lang : DEFAULT_LANG; - debug(`decided language as '${language}'`); + const acceptsLanguages = acceptedLanguages(req.headers['accept-language']); + debug(`possible languages given request '${acceptsLanguages}'`); - return t(language); + // negotiate the language. + const lang = first( + negotiateLanguages(acceptsLanguages, supportedLocales, { + defaultLocale: DEFAULT_LANG, + strategy: 'lookup', + }) + ); + debug(`decided language as '${lang}'`); + + return t(lang); }, t: t(DEFAULT_LANG), }; 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 %> -
-

<%= title %>

-
- - -
- - 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 %} +
+

{{ title }}

+
+
+{% 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 (skip === 0) { %> Create a random article<% } %> - <% assets.forEach(function (asset) { %> - -
-
<%= asset.title %>
- Created <%= asset.created_at.toLocaleString('en-US') %> -
- <%= asset.url %> -
- <% }) %> -
- <% 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 skip === 0 %} Create a random article{% endif %} + {% for asset in assets %} + +
+
{{ asset.title }}
+ Created {{ asset.created_at.toLocaleString('en-US') }} +
+ {{ asset.url }} +
+ {% endfor %} +
+ {% 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 13da4b2f7..a65a3524d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -167,7 +167,7 @@ abbrev@1, abbrev@^1.0.7: version "1.1.1" resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" -accepts@^1.3.4, accepts@~1.3.4: +accepts@~1.3.4: version "1.3.4" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.4.tgz#86246758c7dd6d21a6474ff084a4740ec05eb21f" dependencies: @@ -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" @@ -3975,6 +3981,10 @@ flexbuffer@0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/flexbuffer/-/flexbuffer-0.0.6.tgz#039fdf23f8823e440c38f3277e6fef1174215b30" +fluent-langneg@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/fluent-langneg/-/fluent-langneg-0.1.0.tgz#aa12054fbfa4b728daec38331efc12f01faae93a" + flush-write-stream@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.0.2.tgz#c81b90d8746766f1a609a46809946c45dd8ae417"