diff --git a/sonar-project.properties b/sonar-project.properties index 95989d5a..07d6ad4e 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -20,4 +20,5 @@ sonar.coverage.exclusions=\ sonar.exclusions=\ website/node_modules/**,\ - website/coverage/** \ No newline at end of file + website/coverage/**,\ + website/modules/@apostrophecms/form-widget/views/recaptcha-script.html \ No newline at end of file diff --git a/website/modules/@apostrophecms/form-widget/views/recaptcha-script.html b/website/modules/@apostrophecms/form-widget/views/recaptcha-script.html new file mode 100644 index 00000000..d1ecc995 --- /dev/null +++ b/website/modules/@apostrophecms/form-widget/views/recaptcha-script.html @@ -0,0 +1 @@ + diff --git a/website/modules/@apostrophecms/form-widget/views/widget.html b/website/modules/@apostrophecms/form-widget/views/widget.html index 0274bf23..b739ba5d 100644 --- a/website/modules/@apostrophecms/form-widget/views/widget.html +++ b/website/modules/@apostrophecms/form-widget/views/widget.html @@ -20,22 +20,24 @@ method="post" action="/api/v1/@apostrophecms/form/submit" > - {% area form, 'contents' %} {% if recaptchaReady %} - - {% endif %} - - @@ -50,16 +52,6 @@

- {% if recaptchaSite %} - - {% endif %} -

{ } }; +const submitRouteHandler = function (self) { + return async function (req, res) { + try { + const formData = req?.body?.data ?? null; + if (!formData) { + return res.status(400).json({ error: 'Invalid form data' }); + } + + const globalDoc = await self.apos.global.find(req).toObject(); + const recaptchaToken = formData['g-recaptcha-response']; + if (globalDoc.useRecaptcha && globalDoc.recaptchaSecret) { + const result = await verifyRecaptcha({ + secret: globalDoc.recaptchaSecret, + token: recaptchaToken, + remoteip: + req.headers['x-forwarded-for']?.split(',').shift().trim() || req.ip, + }); + if (!result.success) { + return res.status(400).json({ error: result.error }); + } + } + + const result = await self.formSubmissionHandler.handle(formData); + if (!result) { + return res.status(500).json({ error: 'Form submission failed' }); + } + + return res.json({ success: true }); + } catch (error) { + self.apos.util.error('Form submission error:', error); + return res.status(500).json({ error: 'An error occurred' }); + } + }; +}; + module.exports = { improve: '@apostrophecms/form', fields: { @@ -86,24 +122,7 @@ module.exports = { routes(self) { return { post: { - submit: async (req, res) => { - try { - const formData = req?.body?.data ?? null; - if (!formData) { - return res.status(400).json({ error: 'Invalid form data' }); - } - - const result = await self.formSubmissionHandler.handle(formData); - if (!result) { - return res.status(500).json({ error: 'Form submission failed' }); - } - - return res.json({ success: true }); - } catch (error) { - self.apos.util.error('Form submission error:', error); - return res.status(500).json({ error: 'An error occurred' }); - } - }, + submit: submitRouteHandler(self), }, }; }, diff --git a/website/modules/@apostrophecms/form/lib/formatForSpreadsheet.js b/website/modules/@apostrophecms/form/lib/formatForSpreadsheet.js index 69d8a124..f26263d3 100644 --- a/website/modules/@apostrophecms/form/lib/formatForSpreadsheet.js +++ b/website/modules/@apostrophecms/form/lib/formatForSpreadsheet.js @@ -5,7 +5,9 @@ const generateHeaders = (formData) => { const { _id, ...formFields } = formData; for (const key of Object.keys(formFields)) { - headers.push(formatHeaderName(key)); + if (key !== 'g-recaptcha-response') { + headers.push(formatHeaderName(key)); + } } return headers; @@ -18,11 +20,13 @@ const generateRowData = (formData) => { const { _id, ...formFields } = formData; - for (const value of Object.values(formFields)) { - if (Array.isArray(value)) { - rowData.push(value.join(', ')); - } else { - rowData.push(value); + for (const [key, value] of Object.entries(formFields)) { + if (key !== 'g-recaptcha-response') { + if (Array.isArray(value)) { + rowData.push(value.join(', ')); + } else { + rowData.push(value); + } } } diff --git a/website/modules/@apostrophecms/form/lib/verifyRecaptcha.js b/website/modules/@apostrophecms/form/lib/verifyRecaptcha.js new file mode 100644 index 00000000..0508982a --- /dev/null +++ b/website/modules/@apostrophecms/form/lib/verifyRecaptcha.js @@ -0,0 +1,70 @@ +const fetch = require('node-fetch'); +const AbortController = require('abort-controller'); + +const validateRecaptchaParams = function ({ secret, token, remoteip }) { + if (!secret || secret.trim() === '') { + return { success: false, error: 'Missing reCAPTCHA secret.' }; + } + if (!token || token.trim() === '') { + return { success: false, error: 'Missing reCAPTCHA token.' }; + } + if (!remoteip || remoteip.trim() === '') { + return { success: false, error: 'Missing remote IP address.' }; + } + return null; +}; + +const sendRecaptchaRequest = async function ({ secret, token, remoteip }) { + const params = new URLSearchParams(); + params.append('secret', secret); + params.append('response', token); + params.append('remoteip', remoteip); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + try { + const response = await fetch( + 'https://www.google.com/recaptcha/api/siteverify', + { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params, + signal: controller.signal, + }, + ); + clearTimeout(timeoutId); + + if (!response.ok) { + return { + success: false, + error: `HTTP error! status: ${response.status}`, + }; + } + const data = await response.json(); + if (!data.success) { + return { + success: false, + error: 'reCAPTCHA verification failed.', + details: data, + }; + } + return { success: true, details: data }; + } catch (error) { + clearTimeout(timeoutId); + if (error.name === 'AbortError') { + return { success: false, error: 'reCAPTCHA verification timed out.' }; + } + return { success: false, error: `Network error: ${error.message}` }; + } +}; + +const verifyRecaptcha = function ({ secret, token, remoteip }) { + const validationError = validateRecaptchaParams({ secret, token, remoteip }); + if (validationError) { + return validationError; + } + return sendRecaptchaRequest({ secret, token, remoteip }); +}; + +module.exports = { verifyRecaptcha }; diff --git a/website/modules/@apostrophecms/form/lib/verifyRecaptcha.test.js b/website/modules/@apostrophecms/form/lib/verifyRecaptcha.test.js new file mode 100644 index 00000000..1e170928 --- /dev/null +++ b/website/modules/@apostrophecms/form/lib/verifyRecaptcha.test.js @@ -0,0 +1,82 @@ +const { verifyRecaptcha } = require('./verifyRecaptcha'); + +jest.mock('node-fetch'); +const fetch = require('node-fetch'); + +describe('verifyRecaptcha', () => { + beforeEach(() => { + fetch.mockReset(); + }); + + it('should fail if token is missing', async () => { + const result = await verifyRecaptcha({ + secret: 'test', + token: '', + remoteip: '127.0.0.1', + }); + expect(result.success).toBe(false); + expect(result.error).toBe('Missing reCAPTCHA token.'); + }); + + it('should fail if Google returns error', async () => { + fetch.mockResolvedValueOnce({ + ok: true, + json: () => ({ success: false }), + }); + const result = await verifyRecaptcha({ + secret: 'test', + token: 'sometoken', + remoteip: '127.0.0.1', + }); + expect(result.success).toBe(false); + expect(result.error).toBe('reCAPTCHA verification failed.'); + }); + + it('should succeed if Google returns success', async () => { + fetch.mockResolvedValueOnce({ + ok: true, + json: () => ({ success: true }), + }); + const result = await verifyRecaptcha({ + secret: 'test', + token: 'sometoken', + remoteip: '127.0.0.1', + }); + expect(result.success).toBe(true); + }); + + it('should fail if Google returns HTTP error', async () => { + fetch.mockResolvedValueOnce({ + ok: false, + status: 500, + json: () => ({}), + }); + const result = await verifyRecaptcha({ + secret: 'test', + token: 'sometoken', + remoteip: '127.0.0.1', + }); + expect(result.success).toBe(false); + expect(result.error).toMatch(/HTTP error/u); + }); + + it('should fail if secret is missing', async () => { + const result = await verifyRecaptcha({ + secret: '', + token: 'sometoken', + remoteip: '127.0.0.1', + }); + expect(result.success).toBe(false); + expect(result.error).toBe('Missing reCAPTCHA secret.'); + }); + + it('should fail if remoteip is missing', async () => { + const result = await verifyRecaptcha({ + secret: 'test', + token: 'sometoken', + remoteip: '', + }); + expect(result.success).toBe(false); + expect(result.error).toBe('Missing remote IP address.'); + }); +}); diff --git a/website/modules/asset/ui/src/js/formValidation.js b/website/modules/asset/ui/src/js/formValidation.js index e594252c..cc6b4c06 100644 --- a/website/modules/asset/ui/src/js/formValidation.js +++ b/website/modules/asset/ui/src/js/formValidation.js @@ -1,5 +1,6 @@ const { validateField } = require('./formValidator'); const { showValidationError, clearValidationError } = require('./domHelpers'); +const { addRecaptchaValidationHandlers } = require('./recaptchaValidation'); // Test-specific DOM helpers const testShowValidationError = (field, message) => { @@ -210,6 +211,28 @@ const sendFormData = (form, formData) => { const handleFormSubmit = (event, form, validateFieldFn) => { event.preventDefault(); + + let hasError = false; + + // ReCAPTCHA validation (client-side) + const recaptchaWidget = form.querySelector('.g-recaptcha'); + const recaptchaError = document.querySelector( + '[data-apos-form-recaptcha-error]', + ); + if ( + typeof window.grecaptcha !== 'undefined' && + recaptchaWidget && + !window.grecaptcha.getResponse() + ) { + if (recaptchaError) { + recaptchaError.classList.remove('apos-form-hidden'); + recaptchaError.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + hasError = true; + } else if (recaptchaError) { + recaptchaError.classList.add('apos-form-hidden'); + } + // Disable submit button(s) to prevent multiple submissions const submitButtons = form.querySelectorAll( 'button[type="submit"], input[type="submit"]', @@ -217,12 +240,22 @@ const handleFormSubmit = (event, form, validateFieldFn) => { submitButtons.forEach((btn) => (btn.disabled = true)); validateForm(form, validateFieldFn) - .then((isValid) => onValidateForm(isValid, form, validateFieldFn)) + .then((isValid) => { + if (!isValid) { + hasError = true; + } + if (!hasError) { + return onValidateForm(true, form, validateFieldFn); + } + return null; + }) .finally(() => { // Re-enable submit button(s) after processing submitButtons.forEach((btn) => (btn.disabled = false)); }) .catch(() => false); + + return true; }; const initFormWithValidation = (form, validateFieldFn) => { @@ -240,6 +273,9 @@ const initFormWithValidation = (form, validateFieldFn) => { }, true, ); + + // Add reCAPTCHA validation handlers + addRecaptchaValidationHandlers(form); }; module.exports = { initFormValidation }; diff --git a/website/modules/asset/ui/src/js/recaptchaValidation.js b/website/modules/asset/ui/src/js/recaptchaValidation.js new file mode 100644 index 00000000..4e1ade7e --- /dev/null +++ b/website/modules/asset/ui/src/js/recaptchaValidation.js @@ -0,0 +1,79 @@ +const { clearValidationError } = require('./domHelpers'); + +const handleRecaptchaValueChange = ( + recaptchaResponse, + form, + clearValidationErrorFn = clearValidationError, +) => { + if (recaptchaResponse.value) { + clearValidationErrorFn(recaptchaResponse); + const recaptchaError = form.querySelector( + '[data-apos-form-recaptcha-error]', + ); + if (recaptchaError) { + recaptchaError.classList.add('apos-form-hidden'); + } + } +}; + +const observeRecaptcha = (form, cleanupRef, pollIntervalRef) => { + const recaptchaResponse = form.querySelector('#g-recaptcha-response'); + if (recaptchaResponse) { + const handleInput = () => { + handleRecaptchaValueChange(recaptchaResponse, form); + }; + recaptchaResponse.addEventListener('input', handleInput); + let lastValue = recaptchaResponse.value; + pollIntervalRef.current = setInterval(() => { + if (!document.body.contains(recaptchaResponse)) { + clearInterval(pollIntervalRef.current); + recaptchaResponse.removeEventListener('input', handleInput); + return; + } + if (recaptchaResponse.value && recaptchaResponse.value !== lastValue) { + lastValue = recaptchaResponse.value; + handleRecaptchaValueChange(recaptchaResponse, form); + } + }, 500); + cleanupRef.current = () => { + if (pollIntervalRef.current) clearInterval(pollIntervalRef.current); + recaptchaResponse.removeEventListener('input', handleInput); + }; + } +}; + +const pollForRecaptcha = ( + form, + observeFn, + pollForRecaptchaTimeoutRef, + cleanupRef, +) => { + let pollCount = 0; + const poll = function () { + if (form.querySelector('#g-recaptcha-response')) { + observeFn(); + } else if (pollCount < 20) { + pollCount += 1; + pollForRecaptchaTimeoutRef.current = setTimeout(poll, 100); + } + }; + poll(); +}; + +const addRecaptchaValidationHandlers = (form) => { + const pollIntervalRef = { current: null }; + const pollForRecaptchaTimeoutRef = { current: null }; + const cleanupRef = { current: null }; + + const observeFn = () => observeRecaptcha(form, cleanupRef, pollIntervalRef); + pollForRecaptcha(form, observeFn, pollForRecaptchaTimeoutRef, cleanupRef); + + return () => { + if (pollIntervalRef.current) clearInterval(pollIntervalRef.current); + if (pollForRecaptchaTimeoutRef.current) + clearTimeout(pollForRecaptchaTimeoutRef.current); + if (cleanupRef.current) cleanupRef.current(); + }; +}; + +module.exports = { addRecaptchaValidationHandlers, handleRecaptchaValueChange }; diff --git a/website/modules/asset/ui/src/js/recaptchaValidation.test.js b/website/modules/asset/ui/src/js/recaptchaValidation.test.js new file mode 100644 index 00000000..ab85ca34 --- /dev/null +++ b/website/modules/asset/ui/src/js/recaptchaValidation.test.js @@ -0,0 +1,42 @@ +const { handleRecaptchaValueChange } = require('./recaptchaValidation'); + +describe('handleRecaptchaValueChange', () => { + let form = null; + let recaptchaTextarea = null; + let errorMsg = null; + let clearValidationErrorFn = null; + + beforeEach(() => { + document.body.innerHTML = ` +

+ +

Error

+
+ `; + form = document.getElementById('test-form'); + recaptchaTextarea = document.getElementById('g-recaptcha-response'); + errorMsg = form.querySelector('[data-apos-form-recaptcha-error]'); + clearValidationErrorFn = jest.fn(); + }); + + afterEach(() => { + jest.clearAllMocks(); + document.body.innerHTML = ''; + }); + + it('calls clearValidationError and hides error if value is set', () => { + errorMsg.classList.remove('apos-form-hidden'); + recaptchaTextarea.value = 'token'; + handleRecaptchaValueChange(recaptchaTextarea, form, clearValidationErrorFn); + expect(clearValidationErrorFn).toHaveBeenCalledWith(recaptchaTextarea); + expect(errorMsg.classList.contains('apos-form-hidden')).toBe(true); + }); + + it('does nothing if value is empty', () => { + errorMsg.classList.remove('apos-form-hidden'); + recaptchaTextarea.value = ''; + handleRecaptchaValueChange(recaptchaTextarea, form, clearValidationErrorFn); + expect(clearValidationErrorFn).not.toHaveBeenCalled(); + expect(errorMsg.classList.contains('apos-form-hidden')).toBe(false); + }); +}); diff --git a/website/modules/asset/ui/src/js/validationSchemas.js b/website/modules/asset/ui/src/js/validationSchemas.js index e179049c..480ed2f4 100644 --- a/website/modules/asset/ui/src/js/validationSchemas.js +++ b/website/modules/asset/ui/src/js/validationSchemas.js @@ -65,6 +65,11 @@ const fieldSpecificSchemas = { return internationalPattern.test(value) || localPattern.test(value); }, ), + + 'g-recaptcha-response': yup + .string() + .required('Please complete the reCAPTCHA') + .min(70, 'Invalid reCAPTCHA token'), }; const fallbackSchemas = { diff --git a/website/modules/asset/ui/src/scss/_form.scss b/website/modules/asset/ui/src/scss/_form.scss index fd34a95c..ac9704d6 100644 --- a/website/modules/asset/ui/src/scss/_form.scss +++ b/website/modules/asset/ui/src/scss/_form.scss @@ -1,4 +1,5 @@ .sf-contact-form { + position: relative; margin-top: 32px; @include breakpoint-medium { @@ -10,13 +11,13 @@ min-width: 295px; max-width: 900px; margin: 0 auto; - padding: 23px 15px 7px; + padding: 23px 15px; border: 1px solid $gray-border; margin-bottom: 84px; @include font-settings(14px, 150%, $font-weight-medium); @include breakpoint-medium { - padding: 24px 44px 6px; + padding: 24px 44px; margin-bottom: 248px; } @@ -32,6 +33,31 @@ bottom: -136px; } } + + .g-recaptcha { + position: relative; + margin-top: 24px; + } + .validation-error { + bottom: -4px; + left: 5px; + @include breakpoint-medium { + left: 25px; + } + } + + .apos-form-error { + bottom: -12px; + left: 5px; + } + } + + .apos-form-captcha-error { + bottom: 0px; + left: 18px; + @include breakpoint-medium { + left: 48px; + } } label { @@ -153,14 +179,10 @@ } } -.validation-error { +.validation-error, +.apos-form-error, +.apos-form-captcha-error { position: absolute; - top: 75px; - left: 5px; - @include breakpoint-medium { - top: 94px; - left: 25px; - } color: $error-color; font-size: 12px; margin-top: -5px; @@ -168,7 +190,3 @@ font-weight: 500; line-height: 110%; } - -.apos-form-error { - font-size: 12px; -} diff --git a/website/package-lock.json b/website/package-lock.json index cb7c020f..f81ca546 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -12,6 +12,7 @@ "@apostrophecms/form": "^1.4.2", "@apostrophecms/import-export": "^3.2.0", "@barba/core": "^2.10.3", + "abort-controller": "^3.0.0", "apostrophe": "^4.17.0", "connect-redis": "^7.1.1", "dotenv": "^16.5.0", @@ -22,6 +23,7 @@ "lodash": "^4.17.21", "lozad": "^1.16.0", "mongodb": "^6.17.0", + "node-fetch": "^2.6.7", "normalize.css": "^8.0.1", "postmark": "^4.0.5", "swiper": "^11.2.6", @@ -2592,6 +2594,34 @@ "node": ">= 0.6" } }, + "node_modules/@google-cloud/storage/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/@google-cloud/storage/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT", + "optional": true + }, "node_modules/@google-cloud/storage/node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -2602,6 +2632,24 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/@google-cloud/storage/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/@google-cloud/storage/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/@google-cloud/storage/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -5025,7 +5073,6 @@ "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", "license": "MIT", - "optional": true, "dependencies": { "event-target-shim": "^5.0.0" }, @@ -9111,7 +9158,6 @@ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", "license": "MIT", - "optional": true, "engines": { "node": ">=6" } @@ -9811,6 +9857,48 @@ "node": ">=14" } }, + "node_modules/gaxios/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/gaxios/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/gaxios/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/gaxios/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/gcp-metadata": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", @@ -9868,6 +9956,52 @@ "node": ">= 6" } }, + "node_modules/gcp-metadata/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/gcp-metadata/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT", + "optional": true + }, + "node_modules/gcp-metadata/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/gcp-metadata/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -13073,9 +13207,9 @@ "optional": true }, "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", "license": "MIT", "dependencies": { "whatwg-url": "^5.0.0" diff --git a/website/package.json b/website/package.json index e25bf9c9..feae10da 100644 --- a/website/package.json +++ b/website/package.json @@ -41,6 +41,7 @@ "@apostrophecms/form": "^1.4.2", "@apostrophecms/import-export": "^3.2.0", "@barba/core": "^2.10.3", + "abort-controller": "^3.0.0", "apostrophe": "^4.17.0", "connect-redis": "^7.1.1", "dotenv": "^16.5.0", @@ -50,6 +51,7 @@ "jest-environment-jsdom": "^30.0.0-beta.3", "lodash": "^4.17.21", "lozad": "^1.16.0", + "node-fetch": "^2.6.7", "mongodb": "^6.17.0", "normalize.css": "^8.0.1", "postmark": "^4.0.5",