@eeacms/volto-clms-theme 1.1.291 → 1.1.292

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. Dates are d
4
4
 
5
5
  Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
6
6
 
7
+ ### [1.1.292](https://github.com/eea/volto-clms-theme/compare/1.1.291...1.1.292) - 2 June 2026
8
+
9
+ #### :hammer_and_wrench: Others
10
+
11
+ - Refs #289838 - Implement CSP support in Volto's webpack implementation. [GhitaB - [`6a342dd`](https://github.com/eea/volto-clms-theme/commit/6a342dde51548f960e87a19fb6c44b0fccc64a1a)]
7
12
  ### [1.1.291](https://github.com/eea/volto-clms-theme/compare/1.1.290...1.1.291) - 22 May 2026
8
13
 
9
14
  ### [1.1.290](https://github.com/eea/volto-clms-theme/compare/1.1.289...1.1.290) - 21 May 2026
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eeacms/volto-clms-theme",
3
- "version": "1.1.291",
3
+ "version": "1.1.292",
4
4
  "description": "volto-clms-theme: Volto theme for CLMS site",
5
5
  "main": "src/index.js",
6
6
  "author": "CodeSyntax for the European Environment Agency",
@@ -79,6 +79,7 @@ class Html extends Component {
79
79
  store: PropTypes.shape({
80
80
  getState: PropTypes.func,
81
81
  }).isRequired,
82
+ nonce: PropTypes.string,
82
83
  };
83
84
 
84
85
  /**
@@ -94,10 +95,12 @@ class Html extends Component {
94
95
  criticalCss,
95
96
  apiPath,
96
97
  publicURL,
98
+ nonce,
97
99
  } = this.props;
98
100
  const head = Helmet.rewind();
99
101
  const bodyClass = join(BodyClass.rewind(), ' ');
100
102
  const htmlAttributes = head.htmlAttributes.toComponent();
103
+ const helmetScripts = head.script.toComponent();
101
104
 
102
105
  return (
103
106
  <html lang={htmlAttributes.lang}>
@@ -107,9 +110,14 @@ class Html extends Component {
107
110
  {head.title.toComponent()}
108
111
  {head.meta.toComponent()}
109
112
  {head.link.toComponent()}
110
- {head.script.toComponent()}
113
+ {React.Children.map(helmetScripts, (elem) =>
114
+ React.isValidElement(elem)
115
+ ? React.cloneElement(elem, { nonce })
116
+ : elem,
117
+ )}
111
118
 
112
119
  <script
120
+ nonce={nonce}
113
121
  dangerouslySetInnerHTML={{
114
122
  __html: `window.env = ${serialize({
115
123
  ...runtimeConfig,
@@ -138,6 +146,7 @@ class Html extends Component {
138
146
  <meta name="mobile-web-app-capable" content="yes" />
139
147
  {process.env.NODE_ENV === 'production' && criticalCss && (
140
148
  <style
149
+ nonce={nonce}
141
150
  dangerouslySetInnerHTML={{ __html: this.props.criticalCss }}
142
151
  />
143
152
  )}
@@ -159,6 +168,7 @@ class Html extends Component {
159
168
  criticalCss ? (
160
169
  <>
161
170
  <script
171
+ nonce={nonce}
162
172
  dangerouslySetInnerHTML={{
163
173
  __html: CRITICAL_CSS_TEMPLATE,
164
174
  }}
@@ -185,6 +195,7 @@ class Html extends Component {
185
195
  <div id="main" dangerouslySetInnerHTML={{ __html: markup }} />
186
196
  <div role="complementary" aria-label="Sidebar" id="sidebar" />
187
197
  <script
198
+ nonce={nonce}
188
199
  dangerouslySetInnerHTML={{
189
200
  __html: `window.__data=${serialize(
190
201
  loadReducers(store.getState()),
@@ -196,6 +207,7 @@ class Html extends Component {
196
207
  {this.props.extractScripts !== false
197
208
  ? extractor.getScriptElements().map((elem) =>
198
209
  React.cloneElement(elem, {
210
+ nonce,
199
211
  crossOrigin:
200
212
  process.env.NODE_ENV === 'production' ? undefined : 'true',
201
213
  }),
@@ -0,0 +1 @@
1
+ Customized for CSP nonce support.
@@ -0,0 +1,375 @@
1
+ /* eslint no-console: 0 */
2
+ import '@plone/volto/config'; // This is the bootstrap for the global config - server side
3
+ import { existsSync, lstatSync, readFileSync } from 'fs';
4
+ import React from 'react';
5
+ import { StaticRouter } from 'react-router-dom';
6
+ import { Provider } from 'react-intl-redux';
7
+ import express from 'express';
8
+ import { renderToString } from 'react-dom/server';
9
+ import { createMemoryHistory } from 'history';
10
+ import { parse as parseUrl } from 'url';
11
+ import { keys } from 'lodash';
12
+ import locale from 'locale';
13
+ import { detect } from 'detect-browser';
14
+ import path from 'path';
15
+ import { ChunkExtractor, ChunkExtractorManager } from '@loadable/server';
16
+ import { resetServerContext } from 'react-beautiful-dnd';
17
+ import { CookiesProvider } from 'react-cookie';
18
+ import cookiesMiddleware from 'universal-cookie-express';
19
+ import debug from 'debug';
20
+ import crypto from 'crypto';
21
+
22
+ import routes from '@root/routes';
23
+ import config from '@plone/volto/registry';
24
+
25
+ import {
26
+ flattenToAppURL,
27
+ Html,
28
+ Api,
29
+ persistAuthToken,
30
+ toBackendLang,
31
+ toGettextLang,
32
+ toReactIntlLang,
33
+ } from '@plone/volto/helpers';
34
+ import { changeLanguage } from '@plone/volto/actions';
35
+
36
+ import userSession from '@plone/volto/reducers/userSession/userSession';
37
+
38
+ import ErrorPage from '@plone/volto/error';
39
+
40
+ import languages from '@plone/volto/constants/Languages';
41
+
42
+ import configureStore from '@plone/volto/store';
43
+ import {
44
+ ReduxAsyncConnect,
45
+ loadOnServer,
46
+ } from '@plone/volto/helpers/AsyncConnect';
47
+
48
+ let locales = {};
49
+ const cspConfig = process.env.CSP_HEADER || config.settings.serverConfig?.csp;
50
+
51
+ if (config.settings) {
52
+ config.settings.supportedLanguages.forEach((lang) => {
53
+ const langFileName = toGettextLang(lang);
54
+ import('@root/../locales/' + langFileName + '.json').then((locale) => {
55
+ locales = { ...locales, [toReactIntlLang(lang)]: locale.default };
56
+ });
57
+ });
58
+ }
59
+
60
+ function reactIntlErrorHandler(error) {
61
+ debug('i18n')(error);
62
+ }
63
+
64
+ const supported = new locale.Locales(keys(languages), 'en');
65
+
66
+ const server = express()
67
+ .disable('x-powered-by')
68
+ .head('/*', function (req, res) {
69
+ // Support for HEAD requests. Required by start-test utility in CI.
70
+ res.send('');
71
+ })
72
+ .use(cookiesMiddleware());
73
+
74
+ const middleware = (config.settings.expressMiddleware || []).filter((m) => m);
75
+
76
+ server.all('*', setupServer);
77
+ if (middleware.length) server.use('/', middleware);
78
+
79
+ // eslint-disable-next-line no-unused-vars
80
+ server.use(function (err, req, res, next) {
81
+ if (err) {
82
+ const { store } = res.locals;
83
+ const errorPage = (
84
+ <Provider store={store} onError={reactIntlErrorHandler}>
85
+ <StaticRouter context={{}} location={req.url}>
86
+ <ErrorPage message={err.message} />
87
+ </StaticRouter>
88
+ </Provider>
89
+ );
90
+
91
+ res.set({
92
+ 'Cache-Control': 'public, max-age=60, no-transform',
93
+ });
94
+
95
+ /* Displays error in console
96
+ * TODO:
97
+ * - get ignored codes from Plone error_log
98
+ */
99
+ const ignoredErrors = [301, 302, 401, 404];
100
+ if (!ignoredErrors.includes(err.status)) console.error(err);
101
+
102
+ res
103
+ .status(err.status || 500) // If error happens in Volto code itself error status is undefined
104
+ .send(`<!doctype html> ${renderToString(errorPage)}`);
105
+ }
106
+ });
107
+
108
+ function buildCSPHeader(opts, nonce) {
109
+ const nonceValue = `'nonce-${nonce}'`;
110
+
111
+ if (typeof opts === 'string') {
112
+ return opts.replaceAll('{nonce}', nonceValue);
113
+ }
114
+
115
+ return Object.keys(opts)
116
+ .sort()
117
+ .reduce((acc, key) => {
118
+ const value = Array.isArray(opts[key]) ? opts[key].join(' ') : opts[key];
119
+ return [...acc, `${key} ${value.replaceAll('{nonce}', nonceValue)}`];
120
+ }, [])
121
+ .join('; ');
122
+ }
123
+
124
+ function setupServer(req, res, next) {
125
+ if (cspConfig) {
126
+ res.locals.nonce = crypto.randomBytes(16).toString('base64');
127
+ }
128
+
129
+ const api = new Api(req);
130
+
131
+ const lang = toReactIntlLang(
132
+ new locale.Locales(
133
+ req.universalCookies.get('I18N_LANGUAGE') ||
134
+ config.settings.defaultLanguage ||
135
+ req.headers['accept-language'],
136
+ )
137
+ .best(supported)
138
+ .toString(),
139
+ );
140
+
141
+ // Minimum initial state for the fake Redux store instance
142
+ const initialState = {
143
+ intl: {
144
+ defaultLocale: 'en',
145
+ locale: lang,
146
+ messages: locales[lang],
147
+ },
148
+ };
149
+
150
+ const history = createMemoryHistory({
151
+ initialEntries: [req.url],
152
+ });
153
+
154
+ // Create a fake Redux store instance for the `errorHandler` to render
155
+ // and for being used by the rest of the middlewares, if required
156
+ const store = configureStore(initialState, history, api);
157
+
158
+ function errorHandler(error) {
159
+ const errorPage = (
160
+ <Provider store={store} onError={reactIntlErrorHandler}>
161
+ <StaticRouter context={{}} location={req.url}>
162
+ <ErrorPage message={error.message} />
163
+ </StaticRouter>
164
+ </Provider>
165
+ );
166
+
167
+ res.set({
168
+ 'Cache-Control': 'public, max-age=60, no-transform',
169
+ });
170
+
171
+ /* Displays error in console
172
+ * TODO:
173
+ * - get ignored codes from Plone error_log
174
+ */
175
+ const ignoredErrors = [301, 302, 401, 404];
176
+ if (!ignoredErrors.includes(error.status)) console.error(error);
177
+
178
+ res
179
+ .status(error.status || 500) // If error happens in Volto code itself error status is undefined
180
+ .send(`<!doctype html> ${renderToString(errorPage)}`);
181
+ }
182
+
183
+ if (!process.env.RAZZLE_API_PATH && req.headers.host) {
184
+ res.locals.detectedHost = `${
185
+ req.headers['x-forwarded-proto'] || req.protocol
186
+ }://${req.headers.host}`;
187
+ config.settings.apiPath = res.locals.detectedHost;
188
+ config.settings.publicURL = res.locals.detectedHost;
189
+ }
190
+
191
+ res.locals = {
192
+ ...res.locals,
193
+ store,
194
+ api,
195
+ errorHandler,
196
+ };
197
+
198
+ next();
199
+ }
200
+
201
+ server.get('/*', (req, res) => {
202
+ const { errorHandler, nonce } = res.locals;
203
+
204
+ if (cspConfig) {
205
+ res.setHeader('Content-Security-Policy', buildCSPHeader(cspConfig, nonce));
206
+ }
207
+
208
+ const api = new Api(req);
209
+
210
+ const browserdetect = detect(req.headers['user-agent']);
211
+
212
+ const lang = toReactIntlLang(
213
+ new locale.Locales(
214
+ req.universalCookies.get('I18N_LANGUAGE') ||
215
+ config.settings.defaultLanguage ||
216
+ req.headers['accept-language'],
217
+ )
218
+ .best(supported)
219
+ .toString(),
220
+ );
221
+
222
+ const authToken = req.universalCookies.get('auth_token');
223
+ const initialState = {
224
+ userSession: { ...userSession(), token: authToken },
225
+ form: req.body,
226
+ intl: {
227
+ defaultLocale: 'en',
228
+ locale: lang,
229
+ messages: locales[lang],
230
+ },
231
+ browserdetect,
232
+ };
233
+
234
+ const history = createMemoryHistory({
235
+ initialEntries: [req.url],
236
+ });
237
+
238
+ // Create a new Redux store instance
239
+ const store = configureStore(initialState, history, api);
240
+
241
+ persistAuthToken(store, req);
242
+
243
+ // @loadable/server extractor
244
+ const buildDir = process.env.BUILD_DIR || 'build';
245
+ const extractor = new ChunkExtractor({
246
+ statsFile: path.resolve(path.join(buildDir, 'loadable-stats.json')),
247
+ entrypoints: ['client'],
248
+ });
249
+
250
+ const url = req.originalUrl || req.url;
251
+ const location = parseUrl(url);
252
+
253
+ loadOnServer({ store, location, routes, api })
254
+ .then(() => {
255
+ const initialLang =
256
+ req.universalCookies.get('I18N_LANGUAGE') ||
257
+ config.settings.defaultLanguage ||
258
+ req.headers['accept-language'];
259
+
260
+ // The content info is in the store at this point thanks to the asynconnect
261
+ // features, then we can force the current language info into the store when
262
+ // coming from an SSR request
263
+
264
+ // TODO: there is a bug here with content that, for any reason, doesn't
265
+ // present the language token field, for some reason. In this case, we
266
+ // should follow the cookie rather then switching the language
267
+ const contentLang = store.getState().content.get?.error
268
+ ? initialLang
269
+ : store.getState().content.data?.language?.token ||
270
+ config.settings.defaultLanguage;
271
+
272
+ if (toBackendLang(initialLang) !== contentLang && url !== '/') {
273
+ const newLang = toReactIntlLang(
274
+ new locale.Locales(contentLang).best(supported).toString(),
275
+ );
276
+ store.dispatch(changeLanguage(newLang, locales[newLang], req));
277
+ }
278
+
279
+ const context = {};
280
+ resetServerContext();
281
+ const markup = renderToString(
282
+ <ChunkExtractorManager extractor={extractor}>
283
+ <CookiesProvider cookies={req.universalCookies}>
284
+ <Provider store={store} onError={reactIntlErrorHandler}>
285
+ <StaticRouter context={context} location={req.url}>
286
+ <ReduxAsyncConnect routes={routes} helpers={api} />
287
+ </StaticRouter>
288
+ </Provider>
289
+ </CookiesProvider>
290
+ </ChunkExtractorManager>,
291
+ );
292
+
293
+ const readCriticalCss =
294
+ config.settings.serverConfig.readCriticalCss || defaultReadCriticalCss;
295
+
296
+ // If we are showing an "old browser" warning,
297
+ // make sure it doesn't get cached in a shared cache
298
+ const browserdetect = store.getState().browserdetect;
299
+ if (config.settings.notSupportedBrowsers.includes(browserdetect?.name)) {
300
+ res.set({
301
+ 'Cache-Control': 'private',
302
+ });
303
+ }
304
+
305
+ if (context.url) {
306
+ res.redirect(flattenToAppURL(context.url));
307
+ } else if (context.error_code) {
308
+ res.set({
309
+ 'Cache-Control': 'no-cache',
310
+ });
311
+
312
+ res.status(context.error_code).send(
313
+ `<!doctype html>
314
+ ${renderToString(
315
+ <Html
316
+ extractor={extractor}
317
+ nonce={nonce}
318
+ markup={markup}
319
+ store={store}
320
+ extractScripts={
321
+ config.settings.serverConfig.extractScripts?.errorPages ||
322
+ process.env.NODE_ENV !== 'production'
323
+ }
324
+ criticalCss={readCriticalCss(req)}
325
+ apiPath={res.locals.detectedHost || config.settings.apiPath}
326
+ publicURL={
327
+ res.locals.detectedHost || config.settings.publicURL
328
+ }
329
+ />,
330
+ )}
331
+ `,
332
+ );
333
+ } else {
334
+ res.status(200).send(
335
+ `<!doctype html>
336
+ ${renderToString(
337
+ <Html
338
+ extractor={extractor}
339
+ nonce={nonce}
340
+ markup={markup}
341
+ store={store}
342
+ criticalCss={readCriticalCss(req)}
343
+ apiPath={res.locals.detectedHost || config.settings.apiPath}
344
+ publicURL={
345
+ res.locals.detectedHost || config.settings.publicURL
346
+ }
347
+ />,
348
+ )}
349
+ `,
350
+ );
351
+ }
352
+ }, errorHandler)
353
+ .catch(errorHandler);
354
+ });
355
+
356
+ export const defaultReadCriticalCss = () => {
357
+ const { criticalCssPath } = config.settings.serverConfig;
358
+
359
+ const e = existsSync(criticalCssPath);
360
+ if (!e) return;
361
+
362
+ const f = lstatSync(criticalCssPath);
363
+ if (!f.isFile()) return;
364
+
365
+ return readFileSync(criticalCssPath, { encoding: 'utf-8' });
366
+ };
367
+
368
+ // Exposed for the console bootstrap info messages
369
+ server.apiPath = config.settings.apiPath;
370
+ server.devProxyToApiPath = config.settings.devProxyToApiPath;
371
+ server.proxyRewriteTarget = config.settings.proxyRewriteTarget;
372
+ server.publicURL = config.settings.publicURL;
373
+
374
+ export { buildCSPHeader };
375
+ export default server;
package/src/index.js CHANGED
@@ -68,6 +68,20 @@ import ImageView from '@plone/volto/components/theme/View/ImageView';
68
68
  import userSessionResetMiddleware from './store/userSessionResetMiddleware';
69
69
 
70
70
  const applyConfig = (config) => {
71
+ if (__SERVER__) {
72
+ const devsource = __DEVELOPMENT__
73
+ ? ` http://localhost:${parseInt(process.env.PORT || '3000') + 1}`
74
+ : '';
75
+
76
+ config.settings.serverConfig = {
77
+ ...config.settings.serverConfig,
78
+ csp: {
79
+ 'object-src': "'none'",
80
+ 'script-src': `'self' {nonce}${devsource}`,
81
+ },
82
+ };
83
+ }
84
+
71
85
  config.views = {
72
86
  ...config.views,
73
87
  contentTypesViews: {