@eeacms/volto-cca-policy 0.1.74 → 0.1.76

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.
@@ -0,0 +1,347 @@
1
+ /**
2
+ * Api middleware.
3
+ * @module middleware/api
4
+ */
5
+
6
+ import Cookies from 'universal-cookie';
7
+ import jwtDecode from 'jwt-decode';
8
+ import { compact, flatten, union } from 'lodash';
9
+ import { matchPath } from 'react-router';
10
+ import qs from 'query-string';
11
+
12
+ import config from '@plone/volto/registry';
13
+
14
+ import {
15
+ GET_CONTENT,
16
+ LOGIN,
17
+ RESET_APIERROR,
18
+ SET_APIERROR,
19
+ } from '@plone/volto/constants/ActionTypes';
20
+ import { changeLanguage } from '@plone/volto/actions';
21
+ import {
22
+ toGettextLang,
23
+ toReactIntlLang,
24
+ getCookieOptions,
25
+ } from '@plone/volto/helpers';
26
+ let socket = null;
27
+
28
+ /**
29
+ *
30
+ * Add configured expanders to an api call for an action
31
+ * Requirements:
32
+ *
33
+ * - It should add the expanders set in the config settings
34
+ * - It should preserve any query if present
35
+ * - It should preserve (and add) any expand parameter (if present) e.g. translations
36
+ * - It should take use the correct codification for arrays in querystring (repeated parameter for each member of the array)
37
+ *
38
+ * @function addExpandersToPath
39
+ * @param {string} path The url/path including the querystring
40
+ * @param {*} type The action type
41
+ * @returns {string} The url/path with the configured expanders added to the query string
42
+ */
43
+ export function addExpandersToPath(path, type, isAnonymous) {
44
+ const { settings } = config;
45
+ const { apiExpanders = [] } = settings;
46
+
47
+ const {
48
+ url,
49
+ query: { expand, ...query },
50
+ } = qs.parseUrl(path, { decode: false });
51
+
52
+ const expandersFromConfig = apiExpanders
53
+ .filter((expand) => matchPath(url, expand.match) && expand[type])
54
+ .map((expand) => expand[type]);
55
+
56
+ const expandMerge = compact(
57
+ union([expand, ...flatten(expandersFromConfig)]),
58
+ ).filter((item) => !(item === 'types' && isAnonymous)); // Remove types expander if isAnonymous
59
+
60
+ const stringifiedExpand = qs.stringify(
61
+ { expand: expandMerge },
62
+ {
63
+ arrayFormat: 'comma',
64
+ encode: false,
65
+ },
66
+ );
67
+
68
+ const querystringFromConfig = apiExpanders
69
+ .filter((expand) => matchPath(url, expand.match) && expand[type])
70
+ .reduce((acc, expand) => ({ ...acc, ...expand?.['querystring'] }), {});
71
+
72
+ const queryMerge = { ...query, ...querystringFromConfig };
73
+
74
+ const stringifiedQuery = qs.stringify(queryMerge, {
75
+ encode: false,
76
+ });
77
+
78
+ if (stringifiedQuery && stringifiedExpand) {
79
+ return `${url}?${stringifiedExpand}&${stringifiedQuery}`;
80
+ } else if (!stringifiedQuery && stringifiedExpand) {
81
+ return `${url}?${stringifiedExpand}`;
82
+ } else if (stringifiedQuery && !stringifiedExpand) {
83
+ return `${url}?${stringifiedQuery}`;
84
+ } else {
85
+ return url;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Send a message on a websocket.
91
+ * @function sendOnSocket
92
+ * @param {Object} request Request object.
93
+ * @returns {Promise} message is send
94
+ */
95
+ function sendOnSocket(request) {
96
+ return new Promise((resolve, reject) => {
97
+ switch (socket.readyState) {
98
+ case socket.CONNECTING:
99
+ socket.addEventListener('open', () => resolve(socket));
100
+ socket.addEventListener('error', reject);
101
+ break;
102
+ case socket.OPEN:
103
+ resolve(socket);
104
+ break;
105
+ default:
106
+ reject();
107
+ break;
108
+ }
109
+ }).then(() => {
110
+ socket.send(JSON.stringify(request));
111
+ });
112
+ }
113
+
114
+ /**
115
+ * Api middleware.
116
+ * @function
117
+ * @param {Object} api Api object.
118
+ * @returns {Promise} Action promise.
119
+ */
120
+ const apiMiddlewareFactory = (api) => ({ dispatch, getState }) => (next) => (
121
+ action,
122
+ ) => {
123
+ const { settings } = config;
124
+
125
+ const isAnonymous = !getState().userSession.token;
126
+
127
+ if (typeof action === 'function') {
128
+ return action(dispatch, getState);
129
+ }
130
+
131
+ const { request, type, mode = 'parallel', ...rest } = action;
132
+ const { subrequest } = action; // We want subrequest remains in `...rest` above
133
+
134
+ let actionPromise;
135
+
136
+ if (!request) {
137
+ return next(action);
138
+ }
139
+
140
+ next({ ...rest, type: `${type}_PENDING` });
141
+
142
+ if (socket) {
143
+ actionPromise = Array.isArray(request)
144
+ ? Promise.all(
145
+ request.map((item) =>
146
+ sendOnSocket({
147
+ ...item,
148
+ path: addExpandersToPath(item.path, type, isAnonymous),
149
+ id: type,
150
+ }),
151
+ ),
152
+ )
153
+ : sendOnSocket({
154
+ ...request,
155
+ path: addExpandersToPath(request.path, type, isAnonymous),
156
+ id: type,
157
+ });
158
+ } else {
159
+ actionPromise = Array.isArray(request)
160
+ ? mode === 'serial'
161
+ ? request.reduce((prevPromise, item) => {
162
+ return prevPromise.then((acc) => {
163
+ return api[item.op](
164
+ addExpandersToPath(item.path, type, isAnonymous),
165
+ {
166
+ data: item.data,
167
+ type: item.type,
168
+ headers: item.headers,
169
+ params: request.params,
170
+ checkUrl: settings.actions_raising_api_errors.includes(
171
+ action.type,
172
+ ),
173
+ },
174
+ ).then((reqres) => {
175
+ return [...acc, reqres];
176
+ });
177
+ });
178
+ }, Promise.resolve([]))
179
+ : Promise.all(
180
+ request.map((item) =>
181
+ api[item.op](addExpandersToPath(item.path, type, isAnonymous), {
182
+ data: item.data,
183
+ type: item.type,
184
+ headers: item.headers,
185
+ params: request.params,
186
+ checkUrl: settings.actions_raising_api_errors.includes(
187
+ action.type,
188
+ ),
189
+ }),
190
+ ),
191
+ )
192
+ : api[request.op](addExpandersToPath(request.path, type, isAnonymous), {
193
+ data: request.data,
194
+ type: request.type,
195
+ headers: request.headers,
196
+ params: request.params,
197
+ checkUrl: settings.actions_raising_api_errors.includes(action.type),
198
+ });
199
+ actionPromise.then(
200
+ (result) => {
201
+ const { settings } = config;
202
+ if (getState().apierror.connectionRefused) {
203
+ next({
204
+ ...rest,
205
+ type: RESET_APIERROR,
206
+ });
207
+ }
208
+ if (type === GET_CONTENT) {
209
+ // customization original: result?.language?.token
210
+ const lang = result?.language?.token || result.language || '';
211
+ // end customization
212
+ if (
213
+ lang &&
214
+ getState().intl.locale !== toReactIntlLang(lang) &&
215
+ !subrequest &&
216
+ config.settings.supportedLanguages.includes(lang)
217
+ ) {
218
+ const langFileName = toGettextLang(lang);
219
+ import('~/../locales/' + langFileName + '.json').then((locale) => {
220
+ dispatch(changeLanguage(lang, locale.default));
221
+ });
222
+ }
223
+ }
224
+ if (type === LOGIN && settings.websockets) {
225
+ const cookies = new Cookies();
226
+ cookies.set(
227
+ 'auth_token',
228
+ result.token,
229
+ getCookieOptions({
230
+ expires: new Date(jwtDecode(result.token).exp * 1000),
231
+ }),
232
+ );
233
+ api.get('/@wstoken').then((res) => {
234
+ socket = new WebSocket(
235
+ `${settings.apiPath.replace('http', 'ws')}/@ws?ws_token=${
236
+ res.token
237
+ }`,
238
+ );
239
+ socket.onmessage = (message) => {
240
+ const packet = JSON.parse(message.data);
241
+ if (packet.error) {
242
+ dispatch({
243
+ type: `${packet.id}_FAIL`,
244
+ error: packet.error,
245
+ });
246
+ } else {
247
+ dispatch({
248
+ type: `${packet.id}_SUCCESS`,
249
+ result: JSON.parse(packet.data),
250
+ });
251
+ }
252
+ };
253
+ });
254
+ }
255
+ try {
256
+ return next({ ...rest, result, type: `${type}_SUCCESS` });
257
+ } catch (error) {
258
+ // There was an exception while processing reducers or downstream middleware.
259
+ next({
260
+ ...rest,
261
+ error: { status: 500, error },
262
+ type: `${type}_FAIL`,
263
+ });
264
+ // Rethrow the original exception on the client side only,
265
+ // so it doesn't fall through to express on the server.
266
+ if (__CLIENT__) throw error;
267
+ }
268
+ },
269
+ (error) => {
270
+ // Only SSR can set ECONNREFUSED
271
+ if (error.code === 'ECONNREFUSED') {
272
+ next({
273
+ ...rest,
274
+ error,
275
+ statusCode: error.code,
276
+ connectionRefused: true,
277
+ type: SET_APIERROR,
278
+ });
279
+ }
280
+
281
+ // Response error is marked crossDomain if CORS error happen
282
+ else if (error.crossDomain) {
283
+ next({
284
+ ...rest,
285
+ error,
286
+ statusCode: 'CORSERROR',
287
+ connectionRefused: false,
288
+ type: SET_APIERROR,
289
+ });
290
+ }
291
+
292
+ // Check for actions who can raise api errors
293
+ if (settings.actions_raising_api_errors.includes(action.type)) {
294
+ // Gateway timeout
295
+ if (error?.response?.statusCode === 504) {
296
+ next({
297
+ ...rest,
298
+ error,
299
+ statusCode: error.code,
300
+ connectionRefused: true,
301
+ type: SET_APIERROR,
302
+ });
303
+ }
304
+
305
+ // Redirect
306
+ else if (error?.code === 301) {
307
+ next({
308
+ ...rest,
309
+ error,
310
+ statusCode: error.code,
311
+ connectionRefused: false,
312
+ type: SET_APIERROR,
313
+ });
314
+ }
315
+
316
+ // Redirect
317
+ else if (error?.code === 408) {
318
+ next({
319
+ ...rest,
320
+ error,
321
+ statusCode: error.code,
322
+ connectionRefused: false,
323
+ type: SET_APIERROR,
324
+ });
325
+ }
326
+
327
+ // Unauthorized
328
+ else if (error?.response?.statusCode === 401) {
329
+ next({
330
+ ...rest,
331
+ error,
332
+ statusCode: error.response,
333
+ message: error.response.body.message,
334
+ connectionRefused: false,
335
+ type: SET_APIERROR,
336
+ });
337
+ }
338
+ }
339
+ return next({ ...rest, error, type: `${type}_FAIL` });
340
+ },
341
+ );
342
+ }
343
+
344
+ return actionPromise;
345
+ };
346
+
347
+ export default apiMiddlewareFactory;
@@ -0,0 +1,354 @@
1
+ /* Original: https://github.com/plone/volto/blob/16.x.x/src/server.jsx */
2
+ /* Line: 59 - Fix crash when a supported language it's not in volto/locales folder */
3
+
4
+ /* eslint no-console: 0 */
5
+ import '@plone/volto/config'; // This is the bootstrap for the global config - server side
6
+ import { existsSync, lstatSync, readFileSync } from 'fs';
7
+ import React from 'react';
8
+ import { StaticRouter } from 'react-router-dom';
9
+ import { Provider } from 'react-intl-redux';
10
+ import express from 'express';
11
+ import { renderToString } from 'react-dom/server';
12
+ import { createMemoryHistory } from 'history';
13
+ import { parse as parseUrl } from 'url';
14
+ import { keys } from 'lodash';
15
+ import locale from 'locale';
16
+ import { detect } from 'detect-browser';
17
+ import path from 'path';
18
+ import { ChunkExtractor, ChunkExtractorManager } from '@loadable/server';
19
+ import { resetServerContext } from 'react-beautiful-dnd';
20
+ import { CookiesProvider } from 'react-cookie';
21
+ import cookiesMiddleware from 'universal-cookie-express';
22
+ import debug from 'debug';
23
+
24
+ import routes from '@root/routes';
25
+ import config from '@plone/volto/registry';
26
+
27
+ import {
28
+ flattenToAppURL,
29
+ Html,
30
+ Api,
31
+ persistAuthToken,
32
+ toBackendLang,
33
+ toGettextLang,
34
+ toReactIntlLang,
35
+ } from '@plone/volto/helpers';
36
+ import { changeLanguage } from '@plone/volto/actions';
37
+
38
+ import userSession from '@plone/volto/reducers/userSession/userSession';
39
+
40
+ import ErrorPage from '@plone/volto/error';
41
+
42
+ import languages from '@plone/volto/constants/Languages';
43
+
44
+ import configureStore from '@plone/volto/store';
45
+ import {
46
+ ReduxAsyncConnect,
47
+ loadOnServer,
48
+ } from '@plone/volto/helpers/AsyncConnect';
49
+
50
+ let locales = {};
51
+
52
+ if (config.settings) {
53
+ config.settings.supportedLanguages.forEach((lang) => {
54
+ const langFileName = toGettextLang(lang);
55
+ import('@root/../locales/' + langFileName + '.json')
56
+ .then((locale) => {
57
+ locales = { ...locales, [toReactIntlLang(lang)]: locale.default };
58
+ })
59
+ // start customization
60
+ .catch((error) => {
61
+ locales = { ...locales, [toReactIntlLang(lang)]: {} };
62
+ });
63
+ // end customization
64
+ });
65
+ }
66
+
67
+ function reactIntlErrorHandler(error) {
68
+ debug('i18n')(error);
69
+ }
70
+
71
+ const supported = new locale.Locales(keys(languages), 'en');
72
+
73
+ const server = express()
74
+ .disable('x-powered-by')
75
+ .head('/*', function (req, res) {
76
+ // Support for HEAD requests. Required by start-test utility in CI.
77
+ res.send('');
78
+ })
79
+ .use(cookiesMiddleware());
80
+
81
+ const middleware = (config.settings.expressMiddleware || []).filter((m) => m);
82
+
83
+ server.all('*', setupServer);
84
+ if (middleware.length) server.use('/', middleware);
85
+
86
+ server.use(function (err, req, res, next) {
87
+ if (err) {
88
+ const { store } = res.locals;
89
+ const errorPage = (
90
+ <Provider store={store} onError={reactIntlErrorHandler}>
91
+ <StaticRouter context={{}} location={req.url}>
92
+ <ErrorPage message={err.message} />
93
+ </StaticRouter>
94
+ </Provider>
95
+ );
96
+
97
+ res.set({
98
+ 'Cache-Control': 'public, max-age=60, no-transform',
99
+ });
100
+
101
+ /* Displays error in console
102
+ * TODO:
103
+ * - get ignored codes from Plone error_log
104
+ */
105
+ const ignoredErrors = [301, 302, 401, 404];
106
+ if (!ignoredErrors.includes(err.status)) console.error(err);
107
+
108
+ res
109
+ .status(err.status || 500) // If error happens in Volto code itself error status is undefined
110
+ .send(`<!doctype html> ${renderToString(errorPage)}`);
111
+ }
112
+ });
113
+
114
+ function setupServer(req, res, next) {
115
+ const api = new Api(req);
116
+
117
+ const lang = toReactIntlLang(
118
+ new locale.Locales(
119
+ req.universalCookies.get('I18N_LANGUAGE') ||
120
+ config.settings.defaultLanguage ||
121
+ req.headers['accept-language'],
122
+ )
123
+ .best(supported)
124
+ .toString(),
125
+ );
126
+
127
+ // Minimum initial state for the fake Redux store instance
128
+ const initialState = {
129
+ intl: {
130
+ defaultLocale: 'en',
131
+ locale: lang,
132
+ messages: locales[lang],
133
+ },
134
+ };
135
+
136
+ const history = createMemoryHistory({
137
+ initialEntries: [req.url],
138
+ });
139
+
140
+ // Create a fake Redux store instance for the `errorHandler` to render
141
+ // and for being used by the rest of the middlewares, if required
142
+ const store = configureStore(initialState, history, api);
143
+
144
+ function errorHandler(error) {
145
+ const errorPage = (
146
+ <Provider store={store} onError={reactIntlErrorHandler}>
147
+ <StaticRouter context={{}} location={req.url}>
148
+ <ErrorPage message={error.message} />
149
+ </StaticRouter>
150
+ </Provider>
151
+ );
152
+
153
+ res.set({
154
+ 'Cache-Control': 'public, max-age=60, no-transform',
155
+ });
156
+
157
+ /* Displays error in console
158
+ * TODO:
159
+ * - get ignored codes from Plone error_log
160
+ */
161
+ const ignoredErrors = [301, 302, 401, 404];
162
+ if (!ignoredErrors.includes(error.status)) console.error(error);
163
+
164
+ res
165
+ .status(error.status || 500) // If error happens in Volto code itself error status is undefined
166
+ .send(`<!doctype html> ${renderToString(errorPage)}`);
167
+ }
168
+
169
+ if (!process.env.RAZZLE_API_PATH && req.headers.host) {
170
+ res.locals.detectedHost = `${
171
+ req.headers['x-forwarded-proto'] || req.protocol
172
+ }://${req.headers.host}`;
173
+ config.settings.apiPath = res.locals.detectedHost;
174
+ config.settings.publicURL = res.locals.detectedHost;
175
+ }
176
+
177
+ res.locals = {
178
+ ...res.locals,
179
+ store,
180
+ api,
181
+ errorHandler,
182
+ };
183
+
184
+ next();
185
+ }
186
+
187
+ server.get('/*', (req, res) => {
188
+ const { errorHandler } = res.locals;
189
+
190
+ const api = new Api(req);
191
+
192
+ const browserdetect = detect(req.headers['user-agent']);
193
+
194
+ const lang = toReactIntlLang(
195
+ new locale.Locales(
196
+ req.universalCookies.get('I18N_LANGUAGE') ||
197
+ config.settings.defaultLanguage ||
198
+ req.headers['accept-language'],
199
+ )
200
+ .best(supported)
201
+ .toString(),
202
+ );
203
+
204
+ const authToken = req.universalCookies.get('auth_token');
205
+ const initialState = {
206
+ userSession: { ...userSession(), token: authToken },
207
+ form: req.body,
208
+ intl: {
209
+ defaultLocale: 'en',
210
+ locale: lang,
211
+ messages: locales[lang],
212
+ },
213
+ browserdetect,
214
+ };
215
+
216
+ const history = createMemoryHistory({
217
+ initialEntries: [req.url],
218
+ });
219
+
220
+ // Create a new Redux store instance
221
+ const store = configureStore(initialState, history, api);
222
+
223
+ persistAuthToken(store, req);
224
+
225
+ // @loadable/server extractor
226
+ const buildDir = process.env.BUILD_DIR || 'build';
227
+ const extractor = new ChunkExtractor({
228
+ statsFile: path.resolve(path.join(buildDir, 'loadable-stats.json')),
229
+ entrypoints: ['client'],
230
+ });
231
+
232
+ const url = req.originalUrl || req.url;
233
+ const location = parseUrl(url);
234
+
235
+ loadOnServer({ store, location, routes, api })
236
+ .then(() => {
237
+ const initialLang =
238
+ req.universalCookies.get('I18N_LANGUAGE') ||
239
+ config.settings.defaultLanguage ||
240
+ req.headers['accept-language'];
241
+
242
+ // The content info is in the store at this point thanks to the asynconnect
243
+ // features, then we can force the current language info into the store when
244
+ // coming from an SSR request
245
+
246
+ // TODO: there is a bug here with content that, for any reason, doesn't
247
+ // present the language token field, for some reason. In this case, we
248
+ // should follow the cookie rather then switching the language
249
+ const contentLang = store.getState().content.get?.error
250
+ ? initialLang
251
+ : store.getState().content.data?.language?.token ||
252
+ config.settings.defaultLanguage;
253
+
254
+ if (toBackendLang(initialLang) !== contentLang) {
255
+ const newLang = toReactIntlLang(
256
+ new locale.Locales(contentLang).best(supported).toString(),
257
+ );
258
+ store.dispatch(changeLanguage(newLang, locales[newLang], req));
259
+ }
260
+
261
+ const context = {};
262
+ resetServerContext();
263
+ const markup = renderToString(
264
+ <ChunkExtractorManager extractor={extractor}>
265
+ <CookiesProvider cookies={req.universalCookies}>
266
+ <Provider store={store} onError={reactIntlErrorHandler}>
267
+ <StaticRouter context={context} location={req.url}>
268
+ <ReduxAsyncConnect routes={routes} helpers={api} />
269
+ </StaticRouter>
270
+ </Provider>
271
+ </CookiesProvider>
272
+ </ChunkExtractorManager>,
273
+ );
274
+
275
+ const readCriticalCss =
276
+ config.settings.serverConfig.readCriticalCss || defaultReadCriticalCss;
277
+
278
+ // If we are showing an "old browser" warning,
279
+ // make sure it doesn't get cached in a shared cache
280
+ const browserdetect = store.getState().browserdetect;
281
+ if (config.settings.notSupportedBrowsers.includes(browserdetect?.name)) {
282
+ res.set({
283
+ 'Cache-Control': 'private',
284
+ });
285
+ }
286
+
287
+ if (context.url) {
288
+ res.redirect(flattenToAppURL(context.url));
289
+ } else if (context.error_code) {
290
+ res.set({
291
+ 'Cache-Control': 'no-cache',
292
+ });
293
+
294
+ res.status(context.error_code).send(
295
+ `<!doctype html>
296
+ ${renderToString(
297
+ <Html
298
+ extractor={extractor}
299
+ markup={markup}
300
+ store={store}
301
+ extractScripts={
302
+ config.settings.serverConfig.extractScripts?.errorPages ||
303
+ process.env.NODE_ENV !== 'production'
304
+ }
305
+ criticalCss={readCriticalCss(req)}
306
+ apiPath={res.locals.detectedHost || config.settings.apiPath}
307
+ publicURL={
308
+ res.locals.detectedHost || config.settings.publicURL
309
+ }
310
+ />,
311
+ )}
312
+ `,
313
+ );
314
+ } else {
315
+ res.status(200).send(
316
+ `<!doctype html>
317
+ ${renderToString(
318
+ <Html
319
+ extractor={extractor}
320
+ markup={markup}
321
+ store={store}
322
+ criticalCss={readCriticalCss(req)}
323
+ apiPath={res.locals.detectedHost || config.settings.apiPath}
324
+ publicURL={
325
+ res.locals.detectedHost || config.settings.publicURL
326
+ }
327
+ />,
328
+ )}
329
+ `,
330
+ );
331
+ }
332
+ }, errorHandler)
333
+ .catch(errorHandler);
334
+ });
335
+
336
+ export const defaultReadCriticalCss = () => {
337
+ const { criticalCssPath } = config.settings.serverConfig;
338
+
339
+ const e = existsSync(criticalCssPath);
340
+ if (!e) return;
341
+
342
+ const f = lstatSync(criticalCssPath);
343
+ if (!f.isFile()) return;
344
+
345
+ return readFileSync(criticalCssPath, { encoding: 'utf-8' });
346
+ };
347
+
348
+ // Exposed for the console bootstrap info messages
349
+ server.apiPath = config.settings.apiPath;
350
+ server.devProxyToApiPath = config.settings.devProxyToApiPath;
351
+ server.proxyRewriteTarget = config.settings.proxyRewriteTarget;
352
+ server.publicURL = config.settings.publicURL;
353
+
354
+ export default server;