diff --git a/docs/docs/core/hooks/use-fetch-client.mdx b/docs/docs/core/hooks/use-fetch-client.mdx new file mode 100644 index 0000000000..83d28fe201 --- /dev/null +++ b/docs/docs/core/hooks/use-fetch-client.mdx @@ -0,0 +1,103 @@ +--- +title: useFetchClient +slug: /hooks/use-fetch-client +description: API reference for the useFetchClient hook in Strapi +tags: + - hooks + - axios + - data +--- + +## Usage + +The following example shows a basic way to use the `useFetchClient` hook to make a get request to a Strapi backend endpoint: + +```jsx +import {useState} from "react" +import useFetchClient from '@strapi/admin/admin/src/hooks/useFetchClient'; + +const Component = () => { + const [items, setItems] = useState([]); + const { get } = useFetchClient(); + const requestURL = "/some-endpoint"; + + const handleGetData = async () => { + const { data } = await get(requestURL); + setItems(data.items); + } + + return( +
+
+ { + items && items.map(item =>

{item.name}

)) + } +
+ +
+ ) +} +``` + +## Methods + +Essentially, this is an abstraction around the axios instance exposed by a hook. It provides a simple interface to handle API calls to the Strapi backend. +It handles request cancellations inside the hook with an [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController). This is typically triggered when the component is unmounted so all the requests that it is currently making are aborted. + +The hook exposes four methods: + +- **get(url, config)**: requires a relative url (`string`) and makes a `GET` request to the Strapi backend with an optional configuration `object` passed as second parameter. +- **post(url, data, config)**: requires a relative url (`string`) and makes a `POST` request with the required data `object` to the Strapi backend with the optional configuration `object` passed as third parameter. +- **put(url, data, config)**: requires a relative url (`string`) and makes a `PUT` request with the required data `object` to the Strapi backend with the optional configuration `object` passed as third parameter. +- **del(url, config)**: requires a relative url (`string`) and makes a `DELETE` request to the Strapi backend with an optional configuration `object` passed as second parameter. + +## Implementation details + +The following information is the internal additions we've added to the axios instance via two request interceptors. As well as an explanation of the `baseUrl`. + +### Base URL + +The default URL will be the one defined in the environment variable: `STRAPI_ADMIN_BACKEND_URL`. + +### Interceptors + +#### Request + +The request interceptor adds the following parameters to the header: + +```js +{ + Authorization: `Bearer `, + Accept: 'application/json', + 'Content-Type': 'application/json', +} +``` + +#### Response + +If everything works correctly, the response is returned as it comes from the backend. However, if it contains a **status code of 401** the authentication details will be removed from +the application storage and the window will be reloaded. + +:::caution +Have this in mind if using this hook in pages where the auth token is not available, such as `login`, because it will create an infinite loop. See the [Troubleshooting](#troubleshooting) section for more information. +::: + +## Further Reading + +- [axios instance API](https://axios-http.com/docs/instance) +- [AbortController](https://axios-http.com/docs/cancellation) + +## Troubleshooting + +### Authentication problems + +Trying to access a protected route from a context where the auth token is not available may lead to an infinite loop due to the response interceptor that +reloads the page when obtaining a 401 response. One option to avoid this from happening is to not consider a 401 response as an error, see below: + +```js +const { + data: { data: properties }, +} = await get(`/protected-endpoint`, { + validateStatus: (status) => status < 500, +}); +``` diff --git a/docs/sidebars.js b/docs/sidebars.js index c64e6f077a..6154e8ff2a 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -39,6 +39,17 @@ const sidebars = { }, ], }, + { + type: 'category', + label: 'Hooks', + items: [ + { + type: 'doc', + label: 'useFetchClient', + id: 'core/hooks/use-fetch-client', + }, + ], + }, { type: 'category', label: 'Content Type Builder', diff --git a/packages/core/admin/admin/src/content-manager/pages/App/LeftMenu/index.js b/packages/core/admin/admin/src/content-manager/pages/App/LeftMenu/index.js index b7e0ddcd2c..cdd05abe1b 100644 --- a/packages/core/admin/admin/src/content-manager/pages/App/LeftMenu/index.js +++ b/packages/core/admin/admin/src/content-manager/pages/App/LeftMenu/index.js @@ -22,7 +22,9 @@ import getTrad from '../../../utils/getTrad'; import { makeSelectModelLinks } from '../selectors'; const matchByTitle = (links, search) => - matchSorter(links, toLower(search), { keys: [(item) => toLower(item.title)] }); + search + ? matchSorter(links, toLower(search), { keys: [(item) => toLower(item.title)] }) + : sortBy(links, (object) => object.title.toLowerCase()); const LeftMenu = () => { const [search, setSearch] = useState(''); @@ -52,9 +54,7 @@ const LeftMenu = () => { defaultMessage: 'Collection Types', }, searchable: true, - links: sortBy(matchByTitle(intlCollectionTypeLinks, search), (object) => - object.title.toLowerCase() - ), + links: matchByTitle(intlCollectionTypeLinks, search), }, { id: 'singleTypes', @@ -63,9 +63,7 @@ const LeftMenu = () => { defaultMessage: 'Single Types', }, searchable: true, - links: sortBy(matchByTitle(intlSingleTypeLinks, search), (object) => - object.title.toLowerCase() - ), + links: matchByTitle(intlSingleTypeLinks, search), }, ]; diff --git a/packages/core/content-type-builder/admin/src/components/ContentTypeBuilderNav/useContentTypeBuilderMenu.js b/packages/core/content-type-builder/admin/src/components/ContentTypeBuilderNav/useContentTypeBuilderMenu.js index 16844b831e..a94b94a8a7 100644 --- a/packages/core/content-type-builder/admin/src/components/ContentTypeBuilderNav/useContentTypeBuilderMenu.js +++ b/packages/core/content-type-builder/admin/src/components/ContentTypeBuilderNav/useContentTypeBuilderMenu.js @@ -162,7 +162,7 @@ const useContentTypeBuilderMenu = () => { ]; const matchByTitle = (links) => - matchSorter(links, toLower(search), { keys: [(item) => toLower(item.title)] }); + search ? matchSorter(links, toLower(search), { keys: [(item) => toLower(item.title)] }) : links; const getMenu = () => { // Maybe we can do it simpler with matchsorter wildcards ? diff --git a/packages/core/content-type-builder/admin/src/components/FormModal/index.js b/packages/core/content-type-builder/admin/src/components/FormModal/index.js index 7d9eaa4cca..86e67dfecb 100644 --- a/packages/core/content-type-builder/admin/src/components/FormModal/index.js +++ b/packages/core/content-type-builder/admin/src/components/FormModal/index.js @@ -20,6 +20,7 @@ import { ModalLayout, ModalBody, ModalFooter } from '@strapi/design-system/Modal import { Tabs, Tab, TabGroup, TabPanels, TabPanel } from '@strapi/design-system/Tabs'; import { Flex } from '@strapi/design-system/Flex'; import { Stack } from '@strapi/design-system/Stack'; +import { isEqual } from 'lodash'; import pluginId from '../../pluginId'; import useDataManager from '../../hooks/useDataManager'; import useFormModalNavigation from '../../hooks/useFormModalNavigation'; @@ -790,13 +791,35 @@ const FormModal = () => { } }; + const handleConfirmClose = () => { + // eslint-disable-next-line no-alert + const confirm = window.confirm( + formatMessage({ + id: 'window.confirm.close-modal.file', + defaultMessage: 'Are you sure? Your changes will be lost.', + }) + ); + + if (confirm) { + onCloseModal(); + + dispatch({ + type: RESET_PROPS, + }); + } + }; + const handleClosed = () => { // Close the modal - onCloseModal(); - // Reset the reducer - dispatch({ - type: RESET_PROPS, - }); + if (!isEqual(modifiedData, initialData)) { + handleConfirmClose(); + } else { + onCloseModal(); + // Reset the reducer + dispatch({ + type: RESET_PROPS, + }); + } }; const sendAdvancedTabEvent = (tab) => { diff --git a/packages/core/email/server/services/email.js b/packages/core/email/server/services/email.js index 540585d69e..ed3e93e57b 100644 --- a/packages/core/email/server/services/email.js +++ b/packages/core/email/server/services/email.js @@ -1,6 +1,10 @@ 'use strict'; const _ = require('lodash'); +const { + template: { createStrictInterpolationRegExp }, + keysDeep, +} = require('@strapi/utils/'); const getProviderSettings = () => { return strapi.config.get('plugin.email'); @@ -26,10 +30,19 @@ const sendTemplatedEmail = (emailOptions = {}, emailTemplate = {}, data = {}) => ); } + const allowedInterpolationVariables = keysDeep(data); + const interpolate = createStrictInterpolationRegExp(allowedInterpolationVariables, 'g'); + const templatedAttributes = attributes.reduce( (compiled, attribute) => emailTemplate[attribute] - ? Object.assign(compiled, { [attribute]: _.template(emailTemplate[attribute])(data) }) + ? Object.assign(compiled, { + [attribute]: _.template(emailTemplate[attribute], { + interpolate, + evaluate: false, + escape: false, + })(data), + }) : compiled, {} ); diff --git a/packages/core/utils/lib/index.js b/packages/core/utils/lib/index.js index 7e3aa0f280..3d383e9c60 100644 --- a/packages/core/utils/lib/index.js +++ b/packages/core/utils/lib/index.js @@ -24,7 +24,7 @@ const { joinBy, toKebabCase, } = require('./string-formatting'); -const { removeUndefined } = require('./object-formatting'); +const { removeUndefined, keysDeep } = require('./object-formatting'); const { getConfigUrls, getAbsoluteAdminUrl, getAbsoluteServerUrl } = require('./config'); const { generateTimestampCode } = require('./code-generator'); const contentTypes = require('./content-types'); @@ -40,6 +40,7 @@ const traverseEntity = require('./traverse-entity'); const pipeAsync = require('./pipe-async'); const convertQueryParams = require('./convert-query-params'); const importDefault = require('./import-default'); +const template = require('./template'); module.exports = { yup, @@ -61,11 +62,13 @@ module.exports = { getConfigUrls, escapeQuery, removeUndefined, + keysDeep, getAbsoluteAdminUrl, getAbsoluteServerUrl, generateTimestampCode, stringIncludes, stringEquals, + template, isKebabCase, isCamelCase, toKebabCase, diff --git a/packages/core/utils/lib/object-formatting.js b/packages/core/utils/lib/object-formatting.js index 23c1326db8..24c1b74718 100644 --- a/packages/core/utils/lib/object-formatting.js +++ b/packages/core/utils/lib/object-formatting.js @@ -4,6 +4,12 @@ const _ = require('lodash'); const removeUndefined = (obj) => _.pickBy(obj, (value) => typeof value !== 'undefined'); +const keysDeep = (obj, path = []) => + !_.isObject(obj) + ? path.join('.') + : _.reduce(obj, (acc, next, key) => _.concat(acc, keysDeep(next, [...path, key])), []); + module.exports = { removeUndefined, + keysDeep, }; diff --git a/packages/core/utils/lib/template.js b/packages/core/utils/lib/template.js new file mode 100644 index 0000000000..35579cb566 --- /dev/null +++ b/packages/core/utils/lib/template.js @@ -0,0 +1,28 @@ +'use strict'; + +/** + * Create a strict interpolation RegExp based on the given variables' name + * + * @param {string[]} allowedVariableNames - The list of allowed variables + * @param {string} [flags] - The RegExp flags + */ +const createStrictInterpolationRegExp = (allowedVariableNames, flags) => { + const oneOfVariables = allowedVariableNames.join('|'); + + // 1. We need to match the delimiters: <%= ... %> + // 2. We accept any number of whitespaces characters before and/or after the variable name: \s* ... \s* + // 3. We only accept values from the variable list as interpolation variables' name: : (${oneOfVariables}) + return new RegExp(`<%=\\s*(${oneOfVariables})\\s*%>`, flags); +}; + +/** + * Create a loose interpolation RegExp to match as many groups as possible + * + * @param {string} [flags] - The RegExp flags + */ +const createLooseInterpolationRegExp = (flags) => new RegExp(/<%=([\s\S]+?)%>/, flags); + +module.exports = { + createStrictInterpolationRegExp, + createLooseInterpolationRegExp, +}; diff --git a/packages/plugins/users-permissions/server/controllers/validation/__tests__/email-template.test.js b/packages/plugins/users-permissions/server/controllers/validation/__tests__/email-template.test.js index f4e6b054f1..27ba81aca7 100644 --- a/packages/plugins/users-permissions/server/controllers/validation/__tests__/email-template.test.js +++ b/packages/plugins/users-permissions/server/controllers/validation/__tests__/email-template.test.js @@ -17,6 +17,11 @@ describe('isValidEmailTemplate', () => { expect(isValidEmailTemplate('<%CODE%>')).toBe(false); expect(isValidEmailTemplate('${CODE}')).toBe(false); expect(isValidEmailTemplate('${ CODE }')).toBe(false); + expect( + isValidEmailTemplate( + '<%=`${ console.log({ "remote-execution": { "foo": "bar" }/*<>%=*/ }) }`%>' + ) + ).toBe(false); }); test('Fails on non authorized keys', () => { diff --git a/packages/plugins/users-permissions/server/controllers/validation/email-template.js b/packages/plugins/users-permissions/server/controllers/validation/email-template.js index 071ad11a81..49b3f765f7 100644 --- a/packages/plugins/users-permissions/server/controllers/validation/email-template.js +++ b/packages/plugins/users-permissions/server/controllers/validation/email-template.js @@ -1,8 +1,17 @@ 'use strict'; -const _ = require('lodash'); +const { trim } = require('lodash/fp'); +const { + template: { createLooseInterpolationRegExp, createStrictInterpolationRegExp }, +} = require('@strapi/utils'); + +const invalidPatternsRegexes = [ + // Ignore "evaluation" patterns: <% ... %> + /<%[^=]([\s\S]*?)%>/m, + // Ignore basic string interpolations + /\${([^{}]*)}/m, +]; -const invalidPatternsRegexes = [/<%[^=]([^<>%]*)%>/m, /\${([^{}]*)}/m]; const authorizedKeys = [ 'URL', 'ADMIN_URL', @@ -19,27 +28,42 @@ const matchAll = (pattern, src) => { let match; const regexPatternWithGlobal = RegExp(pattern, 'g'); + // eslint-disable-next-line no-cond-assign while ((match = regexPatternWithGlobal.exec(src))) { const [, group] = match; - matches.push(_.trim(group)); + matches.push(trim(group)); } + return matches; }; const isValidEmailTemplate = (template) => { + // Check for known invalid patterns for (const reg of invalidPatternsRegexes) { if (reg.test(template)) { return false; } } - const matches = matchAll(/<%=([^<>%=]*)%>/, template); - for (const match of matches) { - if (!authorizedKeys.includes(match)) { - return false; - } + const interpolation = { + // Strict interpolation pattern to match only valid groups + strict: createStrictInterpolationRegExp(authorizedKeys), + // Weak interpolation pattern to match as many group as possible. + loose: createLooseInterpolationRegExp(), + }; + + // Compute both strict & loose matches + const strictMatches = matchAll(interpolation.strict, template); + const looseMatches = matchAll(interpolation.loose, template); + + // If we have more matches with the loose RegExp than with the strict one, + // then it means that at least one of the interpolation group is invalid + // Note: In the future, if we wanted to give more details for error formatting + // purposes, we could return the difference between the two arrays + if (looseMatches.length > strictMatches.length) { + return false; } return true; diff --git a/packages/plugins/users-permissions/server/services/user.js b/packages/plugins/users-permissions/server/services/user.js index c7a37abb79..27cf71abdd 100644 --- a/packages/plugins/users-permissions/server/services/user.js +++ b/packages/plugins/users-permissions/server/services/user.js @@ -109,17 +109,25 @@ module.exports = ({ strapi }) => ({ await this.edit(user.id, { confirmationToken }); const apiPrefix = strapi.config.get('api.rest.prefix'); - settings.message = await userPermissionService.template(settings.message, { - URL: urlJoin(getAbsoluteServerUrl(strapi.config), apiPrefix, '/auth/email-confirmation'), - SERVER_URL: getAbsoluteServerUrl(strapi.config), - ADMIN_URL: getAbsoluteAdminUrl(strapi.config), - USER: sanitizedUserInfo, - CODE: confirmationToken, - }); - settings.object = await userPermissionService.template(settings.object, { - USER: sanitizedUserInfo, - }); + try { + settings.message = await userPermissionService.template(settings.message, { + URL: urlJoin(getAbsoluteServerUrl(strapi.config), apiPrefix, '/auth/email-confirmation'), + SERVER_URL: getAbsoluteServerUrl(strapi.config), + ADMIN_URL: getAbsoluteAdminUrl(strapi.config), + USER: sanitizedUserInfo, + CODE: confirmationToken, + }); + + settings.object = await userPermissionService.template(settings.object, { + USER: sanitizedUserInfo, + }); + } catch { + strapi.log.error( + '[plugin::users-permissions.sendConfirmationEmail]: Failed to generate a template for "user confirmation email". Please make sure your email template is valid and does not contain invalid characters or patterns' + ); + return; + } // Send an email to the user. await strapi diff --git a/packages/plugins/users-permissions/server/services/users-permissions.js b/packages/plugins/users-permissions/server/services/users-permissions.js index 1389cd582e..3389cdb532 100644 --- a/packages/plugins/users-permissions/server/services/users-permissions.js +++ b/packages/plugins/users-permissions/server/services/users-permissions.js @@ -3,6 +3,11 @@ const _ = require('lodash'); const { filter, map, pipe, prop } = require('lodash/fp'); const urlJoin = require('url-join'); +const { + template: { createStrictInterpolationRegExp }, + errors, + keysDeep, +} = require('@strapi/utils'); const { getService } = require('../utils'); @@ -230,7 +235,15 @@ module.exports = ({ strapi }) => ({ }, template(layout, data) { - const compiledObject = _.template(layout); - return compiledObject(data); + const allowedTemplateVariables = keysDeep(data); + + // Create a strict interpolation RegExp based on possible variable names + const interpolate = createStrictInterpolationRegExp(allowedTemplateVariables, 'g'); + + try { + return _.template(layout, { interpolate, evaluate: false, escape: false })(data); + } catch (e) { + throw new errors.ApplicationError('Invalid email template'); + } }, }); diff --git a/packages/providers/upload-aws-s3/package.json b/packages/providers/upload-aws-s3/package.json index 7972e6921c..e5a12596bc 100644 --- a/packages/providers/upload-aws-s3/package.json +++ b/packages/providers/upload-aws-s3/package.json @@ -37,7 +37,7 @@ "test": "echo \"no tests yet\"" }, "dependencies": { - "aws-sdk": "2.1260.0", + "aws-sdk": "2.1287.0", "lodash": "4.17.21" }, "engines": { diff --git a/yarn.lock b/yarn.lock index 86c024e3a8..c61209b49b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7792,10 +7792,10 @@ available-typed-arrays@^1.0.5: resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7" integrity sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw== -aws-sdk@2.1260.0: - version "2.1260.0" - resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.1260.0.tgz#3dc18f49dd4aaa18e4e9787c62f1ad02e5aaac4c" - integrity sha512-iciXVukPbhmh44xcF+5/CO15jtESqRkXuEH54XaU8IpCzbYkAcPBaS29vLRN2SRuN1Dy2S3X7SaZZxFJWLAHrg== +aws-sdk@2.1287.0: + version "2.1287.0" + resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.1287.0.tgz#40296aadf34e7550e058f73f3363130d65ffb1c0" + integrity sha512-mtfDstUdFNn8FnBaXs2KAaQ0cgDIiwlqwC2UptUKWWrugjZHAoRacfD/6bnah1Kwhu43F9CDEe5QLHnQtymNkw== dependencies: buffer "4.9.2" events "1.1.1" @@ -15014,9 +15014,9 @@ json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1: integrity sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA== json5@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe" - integrity sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow== + version "1.0.2" + resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593" + integrity sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA== dependencies: minimist "^1.2.0" @@ -16463,12 +16463,7 @@ minimist-options@4.1.0: is-plain-obj "^1.1.0" kind-of "^6.0.3" -minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.3, minimist@^1.2.5, minimist@^1.2.6: - version "1.2.6" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" - integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== - -minimist@^1.2.0: +minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5, minimist@^1.2.6: version "1.2.7" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18" integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g== @@ -22367,19 +22362,7 @@ util@^0.11.0: dependencies: inherits "2.0.3" -util@^0.12.0, util@^0.12.4: - version "0.12.4" - resolved "https://registry.yarnpkg.com/util/-/util-0.12.4.tgz#66121a31420df8f01ca0c464be15dfa1d1850253" - integrity sha512-bxZ9qtSlGUWSOy9Qa9Xgk11kSslpuZwaxCg4sNIDj6FLucDab2JxnHwyNTCpHMtK1MjoQiWQ6DiUMZYbSrO+Sw== - dependencies: - inherits "^2.0.3" - is-arguments "^1.0.4" - is-generator-function "^1.0.7" - is-typed-array "^1.1.3" - safe-buffer "^5.1.2" - which-typed-array "^1.1.2" - -util@^0.12.3: +util@^0.12.0, util@^0.12.3, util@^0.12.4: version "0.12.5" resolved "https://registry.yarnpkg.com/util/-/util-0.12.5.tgz#5f17a6059b73db61a875668781a1c2b136bd6fbc" integrity sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==