Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
1 change: 1 addition & 0 deletions website/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ function createAposConfig() {
'leadership-team-widget': {},
'table-widget': {},
'form-field-standardizer': {},
'custom-form': {},
},
};
}
Expand Down
112 changes: 54 additions & 58 deletions website/modules/@apostrophecms/form-widget/views/widget.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,73 +9,69 @@
<div class="{{ prependIfPrefix('') }}" data-apos-form-wrapper>
{% if not apos.area.isEmpty(form, 'intro') %} {% area form, 'intro' %} {%
endif %} {% if form %} {% set params = false %} {% if form.queryParamList %}
{% set params = '' %} {% for param in form.queryParamList %} {% if loop.last
{% set params = '' %} {% for param in form.queryParamList %} {% if loop.last
%} {% set params = params + param.key %} {% else %} {% set params = params +
param.key + ',' %} {% endif %} {% endfor %} {% endif %}

<form
class="sf-form {{ prependIfPrefix('__form') }}"
autocomplete="off"
novalidate
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
%}
<form
class="sf-form {{ prependIfPrefix('__form') }}"
autocomplete="off"
novalidate
method="post"
action="/api/v1/@apostrophecms/form/submit"
>
{{ form.submitLabel or __t('aposForm:widgetSubmit') }}
</button>
</form>
{% area form, 'contents' %}

<p
role="alert"
style="display: none"
data-apos-form-submit-error
class="apos-form-hidden apos-form-error {{ prependIfPrefix('__error') }}"
>
{{ __t('aposForm:widgetSubmitError') }}
<span data-apos-form-global-error></span>
</p>
{% if recaptchaReady %}
{# sonar-ignore-start #}
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
{# sonar-ignore-end #}
<div class="g-recaptcha"
data-sitekey="{{ recaptchaSite }}">
</div>
{% endif %}

{% if recaptchaSite %}
<p
role="alert"
data-apos-form-recaptcha-error
class="apos-form-hidden apos-form-error {{ prependIfPrefix('__error') }}"
>
{{ __t('aposForm:widgetCaptchaError') }}
</p>
{% endif %}
<button type="submit" class="sf-button">
{{ form.submitLabel or __t('aposForm:widgetSubmit') }}
</button>
</form>

<p
class="apos-form-hidden {{ prependIfPrefix('__spinner') }}"
data-apos-form-spinner
>
{{ __t('aposForm:widgetSubmitting') }}
</p>
<p
role="alert"
style="display: none"
data-apos-form-submit-error
class="apos-form-hidden apos-form-error {{ prependIfPrefix('__error') }}"
>
{{ __t('aposForm:widgetSubmitError') }}
<span data-apos-form-global-error></span>
</p>

<div
role="alert"
data-apos-form-thank-you
class="apos-form-hidden apos-form-submission-thank-you {{ prependIfPrefix('__submission-thank-you') }}"
>
<h3>{{ form.thankYouHeading or __t('aposForm:defaultThankYou') }}</h3>
{% 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
>
{{ __t('aposForm:widgetSubmitting') }}
</p>

<div
role="alert"
data-apos-form-thank-you
class="apos-form-hidden apos-form-submission-thank-you {{ prependIfPrefix('__submission-thank-you') }}"
>
<h3>{{ form.thankYouHeading or __t('aposForm:defaultThankYou') }}</h3>

{% if not apos.area.isEmpty(form, 'thankYouBody') %} {% area form,
{% if not apos.area.isEmpty(form, 'thankYouBody') %} {% area form,
'thankYouBody' %} {% endif %} {% if onSubmitSuccess %} {% endif %}
</div>
</div>
{% endif %}
</div>
54 changes: 36 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,40 @@ 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.ip,
Comment thread
VitalyyP marked this conversation as resolved.
Outdated
});
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 +121,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
36 changes: 36 additions & 0 deletions website/modules/@apostrophecms/form/lib/verifyRecaptcha.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
const fetch = require('node-fetch');

const verifyRecaptcha = async function ({ secret, token, remoteip }) {
if (!token || token.trim() === '') {
return { success: false, error: 'Missing reCAPTCHA token.' };
}
Comment thread
VitalyyP marked this conversation as resolved.
Outdated
const params = new URLSearchParams();
params.append('secret', secret);
params.append('response', token);
params.append('remoteip', remoteip);

const response = await fetch(
'https://www.google.com/recaptcha/api/siteverify',
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params,
timeout: 5000,
},
);
Comment thread
VitalyyP marked this conversation as resolved.
Outdated

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 };
};

module.exports = { verifyRecaptcha };
62 changes: 62 additions & 0 deletions website/modules/@apostrophecms/form/lib/verifyRecaptcha.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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);
});
});
5 changes: 5 additions & 0 deletions website/modules/asset/ui/src/js/validationSchemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ const fieldSpecificSchemas = {
return internationalPattern.test(value) || localPattern.test(value);
},
),

'g-recaptcha-response': yup
.string()
.required('Please complete the reCAPTCHA')
.min(10, 'Invalid reCAPTCHA token'),
Comment thread
VitalyyP marked this conversation as resolved.
Outdated
};

const fallbackSchemas = {
Expand Down
61 changes: 61 additions & 0 deletions website/modules/custom-form/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
const fetch = require('node-fetch');

const handleSubmit = async (self, req) => {
const global = await req.apos.global.get(req);
const recaptchaToken = req.body['g-recaptcha-response'];

const enableRecaptcha = global.useRecaptcha && global.recaptchaSecret;

if (enableRecaptcha) {
if (!recaptchaToken) {
return req.res.status(400).json({ error: 'Missing reCAPTCHA token.' });
}

try {
const params = new URLSearchParams();
params.append('secret', global.recaptchaSecret);
params.append('response', recaptchaToken);
params.append('remoteip', req.ip);

const response = await fetch(
'https://www.google.com/recaptcha/api/siteverify',
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params,
timeout: 5000,
},
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}

const data = await response.json();

if (!data.success) {
return req.res
.status(400)
.json({ error: 'reCAPTCHA verification failed.' });
}
} catch (err) {
// Logging the error for debugging purposes
/* eslint-disable-next-line no-console */
console.error('reCAPTCHA error:', err);

return req.res.status(500).json({ error: 'Error verifying reCAPTCHA.' });
}
}

return self.super.handlers.submit(req);
};

module.exports = {
extend: '@apostrophecms/form',
handlers(self) {
return {
submit: (req) => handleSubmit(self, req),
};
},
};
Loading