Skip to content

Commit

Permalink
Added Captcha to data attribute forms
Browse files Browse the repository at this point in the history
ref BAE-370

Enables Captcha (when labs flag and config entry set) in data-attribute
forms within Portal.
  • Loading branch information
sam-lord authored Jan 27, 2025
1 parent 439bbf8 commit 2f63fa2
Show file tree
Hide file tree
Showing 5 changed files with 206 additions and 6 deletions.
28 changes: 22 additions & 6 deletions apps/portal/src/data-attributes.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
/* eslint-disable no-console */
import {getCheckoutSessionDataFromPlanAttribute, getUrlHistory} from './utils/helpers';
import {getCheckoutSessionDataFromPlanAttribute, getUrlHistory, hasCaptchaEnabled, getCaptchaSitekey} from './utils/helpers';
import {HumanReadableError, chooseBestErrorMessage} from './utils/errors';
import i18nLib from '@tryghost/i18n';

export async function formSubmitHandler({event, form, errorEl, siteUrl, submitHandler},
t = (str) => {
return str;
}) {
export async function formSubmitHandler(
{event, form, errorEl, siteUrl, captchaId, submitHandler},
t = str => str
) {
form.removeEventListener('submit', submitHandler);
event.preventDefault();
if (errorEl) {
Expand Down Expand Up @@ -66,6 +66,11 @@ export async function formSubmitHandler({event, form, errorEl, siteUrl, submitHa
});
const integrityToken = await integrityTokenRes.text();

if (captchaId) {
const {response} = await window.hcaptcha.execute(captchaId, {async: true});
reqBody.token = response;
}

const magicLinkRes = await fetch(`${siteUrl}/members/api/send-magic-link/`, {
method: 'POST',
headers: {
Expand Down Expand Up @@ -187,9 +192,20 @@ export function handleDataAttributes({siteUrl, site, member}) {
}
siteUrl = siteUrl.replace(/\/$/, '');
Array.prototype.forEach.call(document.querySelectorAll('form[data-members-form]'), function (form) {
let captchaId;
if (hasCaptchaEnabled({site})) {
const captchaSitekey = getCaptchaSitekey({site});
const captchaEl = document.createElement('div');
form.appendChild(captchaEl);
captchaId = window.hcaptcha.render(captchaEl, {
size: 'invisible',
sitekey: captchaSitekey
});
}

let errorEl = form.querySelector('[data-members-error]');
function submitHandler(event) {
formSubmitHandler({event, errorEl, form, siteUrl, submitHandler}, t);
formSubmitHandler({event, errorEl, form, siteUrl, captchaId, submitHandler}, t);
}
form.addEventListener('submit', submitHandler);
});
Expand Down
35 changes: 35 additions & 0 deletions apps/portal/src/tests/data-attributes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,16 @@ describe('Member Data attributes:', () => {
}];
});

// Mock hCaptcha
window.hcaptcha = {
execute: () => { }
};
jest.spyOn(window.hcaptcha, 'execute').mockImplementation(() => {
return Promise.resolve({
response: 'testresponse'
});
});

// Mock window.location
let locationMock = jest.fn();
delete window.location;
Expand Down Expand Up @@ -169,6 +179,31 @@ describe('Member Data attributes:', () => {
});
expect(window.fetch).toHaveBeenLastCalledWith('https://portal.localhost/members/api/send-magic-link/', {body: expectedBody, headers: {'Content-Type': 'application/json'}, method: 'POST'});
});

test('submits captcha response if captcha id specified', async () => {
const {event, form, errorEl, siteUrl, submitHandler} = getMockData();

await formSubmitHandler({event, form, errorEl, siteUrl, submitHandler, captchaId: '123123'});

expect(window.fetch).toHaveBeenCalledTimes(2);
const expectedBody = JSON.stringify({
email: '[email protected]',
emailType: 'signup',
labels: ['Gold'],
name: 'Jamie Larsen',
autoRedirect: true,
urlHistory: [{
path: '/blog/',
refMedium: null,
refSource: 'ghost-explore',
refUrl: 'https://example.com/blog/',
time: 1611234567890
}],
token: 'testresponse',
integrityToken: 'testtoken'
});
expect(window.fetch).toHaveBeenLastCalledWith('https://portal.localhost/members/api/send-magic-link/', {body: expectedBody, headers: {'Content-Type': 'application/json'}, method: 'POST'});
});
});

describe('data-members-plan', () => {
Expand Down
8 changes: 8 additions & 0 deletions ghost/core/core/frontend/helpers/ghost_head.js
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,10 @@ function getTinybirdTrackerScript(dataRoot) {
return `<script defer src="${scriptUrl}" data-storage="localStorage" data-host="${endpoint}" data-token="${token}" ${tbParams}></script>`;
}

function getHCaptchaScript() {
return `<script defer async src="https://js.hcaptcha.com/1/api.js"></script>`;
}

/**
* **NOTE**
* Express adds `_locals`, see https://github.com/expressjs/express/blob/4.15.4/lib/response.js#L962.
Expand Down Expand Up @@ -353,6 +357,10 @@ module.exports = async function ghost_head(options) { // eslint-disable-line cam
head.push(getTinybirdTrackerScript(dataRoot));
}

if (labs.isSet('captcha') && config.get('captcha:enabled')) {
head.push(getHCaptchaScript());
}

// Check if if the request is for a site preview, in which case we **always** use the custom font values
// from the passed in data, even when they're empty strings or settings cache has values.
const isSitePreview = options.data?.site?._preview ?? false;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,108 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`{{ghost_head}} helper CAPTCHA does not return CAPTCHA script when disabled 1 1`] = `
Object {
"rendered": "<meta name=\\"description\\" content=\\"site description\\">
<link rel=\\"canonical\\" href=\\"http://127.0.0.1:2369/\\">
<meta name=\\"referrer\\" content=\\"no-referrer-when-downgrade\\">

<meta property=\\"og:site_name\\" content=\\"Ghost\\">
<meta property=\\"og:type\\" content=\\"website\\">
<meta property=\\"og:title\\" content=\\"Ghost\\">
<meta property=\\"og:description\\" content=\\"site description\\">
<meta property=\\"og:url\\" content=\\"http://127.0.0.1:2369/\\">
<meta property=\\"og:image\\" content=\\"http://127.0.0.1:2369/content/images/site-cover.png\\">
<meta name=\\"twitter:card\\" content=\\"summary_large_image\\">
<meta name=\\"twitter:title\\" content=\\"Ghost\\">
<meta name=\\"twitter:description\\" content=\\"site description\\">
<meta name=\\"twitter:url\\" content=\\"http://127.0.0.1:2369/\\">
<meta name=\\"twitter:image\\" content=\\"http://127.0.0.1:2369/content/images/site-cover.png\\">

<script type=\\"application/ld+json\\">
{
\\"@context\\": \\"https://schema.org\\",
\\"@type\\": \\"WebSite\\",
\\"publisher\\": {
\\"@type\\": \\"Organization\\",
\\"name\\": \\"Ghost\\",
\\"url\\": \\"http://127.0.0.1:2369/\\",
\\"logo\\": {
\\"@type\\": \\"ImageObject\\",
\\"url\\": \\"http://127.0.0.1:2369/favicon.ico\\"
}
},
\\"url\\": \\"http://127.0.0.1:2369/\\",
\\"name\\": \\"Ghost\\",
\\"image\\": {
\\"@type\\": \\"ImageObject\\",
\\"url\\": \\"http://127.0.0.1:2369/content/images/site-cover.png\\"
},
\\"mainEntityOfPage\\": \\"http://127.0.0.1:2369/\\",
\\"description\\": \\"site description\\"
}
</script>

<meta name=\\"generator\\" content=\\"Ghost 4.3\\">
<link rel=\\"alternate\\" type=\\"application/rss+xml\\" title=\\"Ghost\\" href=\\"http://localhost:65530/rss/\\">

<script defer src=\\"https://cdn.jsdelivr.net/ghost/sodo-search@~[[VERSION]]/umd/sodo-search.min.js\\" data-key=\\"xyz\\" data-styles=\\"https://cdn.jsdelivr.net/ghost/sodo-search@~[[VERSION]]/umd/main.css\\" data-sodo-search=\\"http://127.0.0.1:2369/\\" crossorigin=\\"anonymous\\"></script>

<link href=\\"http://127.0.0.1:2369/webmentions/receive/\\" rel=\\"webmention\\">",
}
`;

exports[`{{ghost_head}} helper CAPTCHA returns CAPTCHA script when enabled 1 1`] = `
Object {
"rendered": "<meta name=\\"description\\" content=\\"site description\\">
<link rel=\\"canonical\\" href=\\"http://127.0.0.1:2369/\\">
<meta name=\\"referrer\\" content=\\"no-referrer-when-downgrade\\">

<meta property=\\"og:site_name\\" content=\\"Ghost\\">
<meta property=\\"og:type\\" content=\\"website\\">
<meta property=\\"og:title\\" content=\\"Ghost\\">
<meta property=\\"og:description\\" content=\\"site description\\">
<meta property=\\"og:url\\" content=\\"http://127.0.0.1:2369/\\">
<meta property=\\"og:image\\" content=\\"http://127.0.0.1:2369/content/images/site-cover.png\\">
<meta name=\\"twitter:card\\" content=\\"summary_large_image\\">
<meta name=\\"twitter:title\\" content=\\"Ghost\\">
<meta name=\\"twitter:description\\" content=\\"site description\\">
<meta name=\\"twitter:url\\" content=\\"http://127.0.0.1:2369/\\">
<meta name=\\"twitter:image\\" content=\\"http://127.0.0.1:2369/content/images/site-cover.png\\">

<script type=\\"application/ld+json\\">
{
\\"@context\\": \\"https://schema.org\\",
\\"@type\\": \\"WebSite\\",
\\"publisher\\": {
\\"@type\\": \\"Organization\\",
\\"name\\": \\"Ghost\\",
\\"url\\": \\"http://127.0.0.1:2369/\\",
\\"logo\\": {
\\"@type\\": \\"ImageObject\\",
\\"url\\": \\"http://127.0.0.1:2369/favicon.ico\\"
}
},
\\"url\\": \\"http://127.0.0.1:2369/\\",
\\"name\\": \\"Ghost\\",
\\"image\\": {
\\"@type\\": \\"ImageObject\\",
\\"url\\": \\"http://127.0.0.1:2369/content/images/site-cover.png\\"
},
\\"mainEntityOfPage\\": \\"http://127.0.0.1:2369/\\",
\\"description\\": \\"site description\\"
}
</script>

<meta name=\\"generator\\" content=\\"Ghost 4.3\\">
<link rel=\\"alternate\\" type=\\"application/rss+xml\\" title=\\"Ghost\\" href=\\"http://localhost:65530/rss/\\">

<script defer src=\\"https://cdn.jsdelivr.net/ghost/sodo-search@~[[VERSION]]/umd/sodo-search.min.js\\" data-key=\\"xyz\\" data-styles=\\"https://cdn.jsdelivr.net/ghost/sodo-search@~[[VERSION]]/umd/main.css\\" data-sodo-search=\\"http://127.0.0.1:2369/\\" crossorigin=\\"anonymous\\"></script>

<link href=\\"http://127.0.0.1:2369/webmentions/receive/\\" rel=\\"webmention\\">
<script defer async src=\\"https://js.hcaptcha.com/1/api.js\\"></script>",
}
`;

exports[`{{ghost_head}} helper accent_color attaches style tag to existing script/style tag 1 1`] = `
Object {
"rendered": "<meta name=\\"description\\" content=\\"all about our site\\">
Expand Down
38 changes: 38 additions & 0 deletions ghost/core/test/unit/frontend/helpers/ghost_head.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1382,6 +1382,44 @@ describe('{{ghost_head}} helper', function () {
});
});

describe('CAPTCHA', function () {
beforeEach(function () {
configUtils.set({
captcha: {
enabled: true
}
});
});

it('returns CAPTCHA script when enabled', async function () {
sinon.stub(labs, 'isSet').withArgs('captcha').returns(true);

const rendered = await testGhostHead(testUtils.createHbsResponse({
locals: {
relativeUrl: '/',
context: ['home', 'index'],
safeVersion: '4.3'
}
}));

rendered.should.match(/hcaptcha/);
});

it('does not return CAPTCHA script when disabled', async function () {
sinon.stub(labs, 'isSet').withArgs('captcha').returns(false);

const rendered = await testGhostHead(testUtils.createHbsResponse({
locals: {
relativeUrl: '/',
context: ['home', 'index'],
safeVersion: '4.3'
}
}));

rendered.should.not.match(/hcaptcha/);
});
});

describe('attribution scripts', function () {
it('is included when tracking setting is enabled', async function () {
settingsCache.get.withArgs('members_track_sources').returns(true);
Expand Down

0 comments on commit 2f63fa2

Please sign in to comment.