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 @@
-
-
-