Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
f474602
enhancement form validation
VitalyyP Jul 1, 2025
6829362
remove commented code
VitalyyP Jul 1, 2025
6102d90
remove thanks alert
VitalyyP Jul 2, 2025
6611ef9
Add reCAPTCHA to form submission
IhorMasechko Jul 3, 2025
2d92e5d
Merged with main branch
IhorMasechko Jul 3, 2025
431b416
Fix lint errors
IhorMasechko Jul 3, 2025
f68f03e
Add reCAPTCHA verification to form submission
VitalyyP Jul 4, 2025
cecf63b
Fix spreadsheet formatting to skip recaptcha field
VitalyyP Jul 4, 2025
6d73488
Downgrade node-fetch to v2.7.0 for compatibility
VitalyyP Jul 4, 2025
7ff0436
Remove custom-form module and references
VitalyyP Jul 4, 2025
867d8e7
Improve reCAPTCHA error handling and validation in form widget
VitalyyP Jul 4, 2025
a9f3349
Refactor reCAPTCHA error markup and adjust error position in form
VitalyyP Jul 4, 2025
22826bb
Add reCAPTCHA validation to form handling
VitalyyP Jul 4, 2025
69a164a
Improve reCAPTCHA verification and validation
VitalyyP Jul 4, 2025
f09adac
Use x-forwarded-for for recaptcha remoteip
VitalyyP Jul 4, 2025
f5dd2ba
Refactor and extend reCAPTCHA validation
VitalyyP Jul 4, 2025
22cc595
Fix recaptcha validation to scope query to form element
VitalyyP Jul 4, 2025
33fc6cb
Refactor reCAPTCHA validation and update node-fetch version
VitalyyP Jul 4, 2025
56418a8
Update node-fetch version in website dependencies
VitalyyP Jul 4, 2025
8c87275
Refactor recaptcha validation handlers to use arrow functions
VitalyyP Jul 4, 2025
073a037
test commit
VitalyyP Jul 4, 2025
d80452e
Remove commented-out positioning styles from form error classes
VitalyyP Jul 4, 2025
9e18656
Revert "test commit"
VitalyyP Jul 4, 2025
4db794e
Merge branch 'main' into 659-admin-recaptcha-config
Anton-88 Jul 4, 2025
b80ab64
Merge branch 'main' into 659-admin-recaptcha-config
yuramax Jul 5, 2025
9538251
Merge branch '659-admin-recaptcha-config' of github.com:speedandfunct…
yuramax Jul 5, 2025
39f14a6
Update form widget template - SonarQube rule can be ignored for this …
yuramax Jul 5, 2025
4171c1a
Extract reCAPTCHA script to separate file and exclude from SonarQube …
yuramax Jul 5, 2025
a345527
Fix include path for recaptcha script - use relative path
yuramax Jul 5, 2025
86d6b41
Revert to inline script with SonarQube ignore comments - more reliabl…
yuramax Jul 5, 2025
456194b
Extract reCAPTCHA script to separate include file
yuramax Jul 5, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion sonar-project.properties
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@ sonar.coverage.exclusions=\

sonar.exclusions=\
website/node_modules/**,\
website/coverage/**
website/coverage/**,\
website/modules/@apostrophecms/form-widget/views/recaptcha-script.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
42 changes: 17 additions & 25 deletions website/modules/@apostrophecms/form-widget/views/widget.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,24 @@
method="post"
action="/api/v1/@apostrophecms/form/submit"
>
{% area form, 'contents' %} {% if recaptchaReady %}
<noscript>
<p>{{ __t('aposForm:widgetNoScript') }}</p>
</noscript>
{% endif %}

<button
type="submit"
class="sf-button"
{%
if
recaptchaReady
%}disabled{%
endif
%}
{% area form, 'contents' %} {% if recaptchaReady %} {% include
"recaptcha-script.html" %}
<div
class="g-recaptcha"
data-sitekey="{{ recaptchaSite }}"
data-size="compact"
></div>
{% if recaptchaSite %}
<p
role="alert"
data-apos-form-recaptcha-error
class="apos-form-hidden apos-form-captcha-error {{ prependIfPrefix('__error') }}"
>
Please confirm you are not a robot.
</p>
{% endif %} {% endif %}

<button type="submit" class="sf-button">
{{ form.submitLabel or __t('aposForm:widgetSubmit') }}
</button>
</form>
Expand All @@ -50,16 +52,6 @@
<span data-apos-form-global-error></span>
</p>

{% if recaptchaSite %}
<p
role="alert"
data-apos-form-recaptcha-error
class="apos-form-hidden apos-form-error {{ prependIfPrefix('__error') }}"
>
{{ __t('aposForm:widgetCaptchaError') }}
</p>
{% endif %}

<p
class="apos-form-hidden {{ prependIfPrefix('__spinner') }}"
data-apos-form-spinner
Expand Down
55 changes: 37 additions & 18 deletions website/modules/@apostrophecms/form/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const GoogleSheetsFormSubmissionHandler = require('./lib/GoogleSheetsFormSubmiss
const GoogleSheetsErrorHandler = require('./lib/GoogleSheetsErrorHandler');
const { formatForSpreadsheet } = require('./lib/formatForSpreadsheet');
const { getSheetsAuthConfig } = require('./lib/getSheetsAuthConfig');
const { verifyRecaptcha } = require('./lib/verifyRecaptcha');

const VALIDATION_INSTRUCTIONS =
'For proper validation, place the name, email, and phone number fields at the beginning of the form, in this exact order. Use a text input for each. Add all other fields afterward.';
Expand All @@ -13,6 +14,41 @@ const validateSubmissionSuccess = (result) => {
}
};

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: {
Expand Down Expand Up @@ -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),
},
};
},
Expand Down
16 changes: 10 additions & 6 deletions website/modules/@apostrophecms/form/lib/formatForSpreadsheet.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}
}
}

Expand Down
70 changes: 70 additions & 0 deletions website/modules/@apostrophecms/form/lib/verifyRecaptcha.js
Original file line number Diff line number Diff line change
@@ -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 };
82 changes: 82 additions & 0 deletions website/modules/@apostrophecms/form/lib/verifyRecaptcha.test.js
Original file line number Diff line number Diff line change
@@ -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.');
});
});
38 changes: 37 additions & 1 deletion website/modules/asset/ui/src/js/formValidation.js
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down Expand Up @@ -210,19 +211,51 @@ 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"]',
);
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) => {
Expand All @@ -240,6 +273,9 @@ const initFormWithValidation = (form, validateFieldFn) => {
},
true,
);

// Add reCAPTCHA validation handlers
addRecaptchaValidationHandlers(form);
};

module.exports = { initFormValidation };
Loading
Loading