feat(forms): captcha is mandatory on every relay form; remove the toggle
relayFormWiring always emits captcha="1" for relay-wired forms (legacy captcha prop ignored, old projects with captcha:false load fine). CaptchaMock always shown for relay-wired forms; FormStylePanel toggle replaced by a read-only note. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0185ukXgKV8v9BUrQWXSUW4P
This commit is contained in:
1 parent
92da689e62
commit
bb97e5b396
10 files changed
+62
-59
No files matched your search
@@ -6,7 +6,7 @@ const toHtml = (ContactForm as any).toHtml;
|
||||
describe('ContactForm.toHtml relay wiring', () => {
|
||||
test('with recipientEmail: emits marker, placeholder action, honeypot', () => {
|
||||
const { html } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '');
|
||||
expect(html).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" recipient="a@b.com" thankyou="\/thx"-->/);
|
||||
expect(html).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" captcha="1" recipient="a@b.com" thankyou="\/thx"-->/);
|
||||
expect(html).toMatch(/action="__WHP_FORM_ACTION__F_[0-9a-z]+__"/);
|
||||
expect(html).toContain('method="POST"');
|
||||
expect(html).toContain('name="_gotcha"');
|
||||
@@ -244,7 +244,9 @@ describe('ContactForm.toHtml destination marker', () => {
|
||||
// if it has to be edited, every already-published site's forms have changed
|
||||
// shape and that is the thing to stop, not the test.
|
||||
const FROZEN_LEGACY_MARKER =
|
||||
'<!--WHP-FORM id="F_3hodg" recipient="a@example.com" thankyou="/thx"-->';
|
||||
'<!--WHP-FORM id="F_3hodg" captcha="1" recipient="a@example.com" thankyou="/thx"-->';
|
||||
// (captcha="1" was added when captcha became mandatory on every relay form: that is the one deliberate
|
||||
// shape change since 071f3447; the fid and everything else is unchanged.)
|
||||
|
||||
const legacy = toHtml({ ...defaultProps, recipientEmail: 'a@example.com', thankYouUrl: '/thx' }, '', 'n1');
|
||||
expect(legacy.html.startsWith(`${FROZEN_LEGACY_MARKER}<form `)).toBe(true);
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { CaptchaMock } from './CaptchaMock';
|
||||
import { relayFormWiring } from '../../utils/form-relay-wiring';
|
||||
import { relayFormWiring, isRelayWired } from '../../utils/form-relay-wiring';
|
||||
import { escapeHtml, escapeAttr, slugId, cssValue, sanitizeInputType } from '../../utils/escape';
|
||||
import { tokenRef } from '../../utils/tokens';
|
||||
import { textOnSurface } from '../../utils/color';
|
||||
@@ -55,7 +55,7 @@ interface ContactFormProps {
|
||||
webhookUrl?: string;
|
||||
webhookSecretId?: string;
|
||||
webhookAuthMode?: 'signature' | 'bearer';
|
||||
/** ALTCHA captcha (relay path). undefined = legacy design: no marker attr, stored flag untouched. */
|
||||
/** @deprecated Captcha is mandatory on relay forms. Old saved designs may still hold true/false here; it is ignored. */
|
||||
captcha?: boolean;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
@@ -76,7 +76,8 @@ export const ContactForm: UserComponent<ContactFormProps> = ({
|
||||
submitText = 'Send Message',
|
||||
submitColor = tokenRef('primary'),
|
||||
formAction = '#',
|
||||
captcha,
|
||||
recipientEmail,
|
||||
destinationType,
|
||||
successMessage = 'Thank you! We\'ll get back to you soon.',
|
||||
style = {},
|
||||
labelColor = tokenRef('text'),
|
||||
@@ -154,7 +155,7 @@ export const ContactForm: UserComponent<ContactFormProps> = ({
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{captcha === true && <CaptchaMock />}
|
||||
{isRelayWired(recipientEmail, destinationType) && <CaptchaMock />}
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
@@ -202,7 +203,6 @@ ContactForm.craft = {
|
||||
webhookUrl: '',
|
||||
webhookSecretId: '',
|
||||
webhookAuthMode: 'signature',
|
||||
captcha: true,
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
@@ -275,7 +275,6 @@ ContactForm.craft = {
|
||||
secretId: props.webhookSecretId,
|
||||
authMode: props.webhookAuthMode,
|
||||
},
|
||||
props.captcha,
|
||||
);
|
||||
|
||||
// The form-sender relay delivers success via a full-page 303 redirect
|
||||
|
||||
@@ -6,7 +6,7 @@ const toHtml = (FormContainer as any).toHtml;
|
||||
describe('FormContainer.toHtml relay wiring', () => {
|
||||
test('with recipientEmail: marker + placeholder action + honeypot, forces POST', () => {
|
||||
const { html } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', method: 'GET' }, '<input name="email">');
|
||||
expect(html).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" recipient="a@b.com" thankyou="\/thx"-->/);
|
||||
expect(html).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" captcha="1" recipient="a@b.com" thankyou="\/thx"-->/);
|
||||
expect(html).toMatch(/action="__WHP_FORM_ACTION__F_[0-9a-z]+__"/);
|
||||
expect(html).toContain('method="POST"'); // relay forces POST even though method=GET
|
||||
expect(html).toContain('name="_gotcha"');
|
||||
|
||||
@@ -4,14 +4,14 @@ import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||
import { Container } from '../layout/Container';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { CaptchaMock } from './CaptchaMock';
|
||||
import { relayFormWiring } from '../../utils/form-relay-wiring';
|
||||
import { relayFormWiring, isRelayWired } from '../../utils/form-relay-wiring';
|
||||
import { sanitizeFormMethod } from '../../utils/escape';
|
||||
|
||||
interface FormContainerProps {
|
||||
action?: string;
|
||||
method?: 'GET' | 'POST';
|
||||
recipientEmail?: string;
|
||||
/** ALTCHA captcha (relay path). undefined = legacy design: no marker attr, stored flag untouched. */
|
||||
/** @deprecated Captcha is mandatory on relay forms. Old saved designs may still hold true/false here; it is ignored. */
|
||||
captcha?: boolean;
|
||||
thankYouUrl?: string;
|
||||
style?: CSSProperties;
|
||||
@@ -27,7 +27,7 @@ export const FormContainer: UserComponent<FormContainerProps> = ({
|
||||
action = '#',
|
||||
method = 'POST',
|
||||
style = {},
|
||||
captcha,
|
||||
recipientEmail,
|
||||
}) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
|
||||
@@ -50,7 +50,7 @@ export const FormContainer: UserComponent<FormContainerProps> = ({
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '16px', padding: '0' }}
|
||||
tag="div"
|
||||
/>
|
||||
{captcha === true && <CaptchaMock style={{ marginTop: 12 }} />}
|
||||
{isRelayWired(recipientEmail) && <CaptchaMock style={{ marginTop: 12 }} />}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -64,7 +64,6 @@ FormContainer.craft = {
|
||||
method: 'POST',
|
||||
recipientEmail: '',
|
||||
thankYouUrl: '',
|
||||
captcha: true,
|
||||
style: {
|
||||
padding: '24px',
|
||||
backgroundColor: tokenRef('bg'),
|
||||
@@ -91,7 +90,7 @@ FormContainer.craft = {
|
||||
padding: '24px',
|
||||
...props.style,
|
||||
});
|
||||
const { useRelay, marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.action, nodeId, undefined, props.captcha);
|
||||
const { useRelay, marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.action, nodeId);
|
||||
const method = useRelay ? 'POST' : sanitizeFormMethod(props.method); // relay requires POST
|
||||
const body = honeypot + childrenHtml; // honeypot as first child
|
||||
return {
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('SubscribeForm.toHtml is functional (not a dead POST)', () => {
|
||||
|
||||
test('with recipientEmail: emits the relay marker, placeholder action, and honeypot -- a working submission path', () => {
|
||||
const { html } = toHtml({ recipientEmail: 'news@example.com', thankYouUrl: '/thanks' }, '', 'node-sub1');
|
||||
expect(html).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" recipient="news@example.com" thankyou="\/thanks"-->/);
|
||||
expect(html).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" captcha="1" recipient="news@example.com" thankyou="\/thanks"-->/);
|
||||
expect(html).toMatch(/action="__WHP_FORM_ACTION__F_[0-9a-z]+__"/);
|
||||
expect(html).toContain('name="_gotcha"');
|
||||
const mid = html.match(/id="(F_[0-9a-z]+)"/)![1];
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { CaptchaMock } from './CaptchaMock';
|
||||
import { relayFormWiring } from '../../utils/form-relay-wiring';
|
||||
import { relayFormWiring, isRelayWired } from '../../utils/form-relay-wiring';
|
||||
import { escapeHtml, escapeAttr } from '../../utils/escape';
|
||||
import { tokenRef } from '../../utils/tokens';
|
||||
import { textOnSurface } from '../../utils/color';
|
||||
@@ -26,7 +26,7 @@ interface SubscribeFormProps {
|
||||
* published form then has no working action at all, which is the bug
|
||||
* this prop exists to fix. */
|
||||
recipientEmail?: string;
|
||||
/** ALTCHA captcha (relay path). undefined = legacy design: no marker attr, stored flag untouched. */
|
||||
/** @deprecated Captcha is mandatory on relay forms. Old saved designs may still hold true/false here; it is ignored. */
|
||||
captcha?: boolean;
|
||||
thankYouUrl?: string;
|
||||
animation?: string;
|
||||
@@ -43,7 +43,7 @@ export const SubscribeForm: UserComponent<SubscribeFormProps> = ({
|
||||
buttonColor = tokenRef('primary'),
|
||||
layout = 'inline',
|
||||
style = {},
|
||||
captcha,
|
||||
recipientEmail,
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
@@ -120,7 +120,7 @@ export const SubscribeForm: UserComponent<SubscribeFormProps> = ({
|
||||
{buttonText}
|
||||
</button>
|
||||
</form>
|
||||
{captcha === true && <CaptchaMock style={{ margin: '12px auto 0' }} />}
|
||||
{isRelayWired(recipientEmail) && <CaptchaMock style={{ margin: '12px auto 0' }} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -138,7 +138,6 @@ SubscribeForm.craft = {
|
||||
style: { backgroundColor: tokenRef('bg') },
|
||||
recipientEmail: '',
|
||||
thankYouUrl: '',
|
||||
captcha: true,
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
@@ -208,7 +207,7 @@ SubscribeForm.craft = {
|
||||
// `formAction`-less relay wiring (fallbackAction undefined -> '#') keeps
|
||||
// the old no-recipient case visually identical (action="#") while making
|
||||
// the relay path actually functional once an admin sets an email.
|
||||
const { marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, undefined, nodeId, undefined, props.captcha);
|
||||
const { marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, undefined, nodeId);
|
||||
|
||||
return {
|
||||
html: `${marker}<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>
|
||||
|
||||
@@ -13,24 +13,22 @@ const base = { recipientEmail: 'a@b.com', thankYouUrl: '/thx' };
|
||||
|
||||
describe.each(cases)('%s captcha marker attribute', (_n, toHtml, extra) => {
|
||||
const run = (p: any) => toHtml({ ...extra, ...base, ...p }, '', 'node1').html as string;
|
||||
test('true emits captcha="1" between id and recipient', () => {
|
||||
expect(run({ captcha: true })).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" captcha="1" recipient="a@b.com" thankyou="\/thx"-->/);
|
||||
const MARK = /<!--WHP-FORM id="F_[0-9a-z]+" captcha="1" recipient="a@b.com" thankyou="\/thx"-->/;
|
||||
test('relay form always emits captcha="1" between id and recipient', () => {
|
||||
expect(run({ captcha: true })).toMatch(MARK);
|
||||
});
|
||||
test('explicit false emits captcha="0"', () => {
|
||||
expect(run({ captcha: false })).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" captcha="0" recipient=/);
|
||||
test.each([[false], [undefined], ['yes'], [1], [0], [null]])('mandatory: legacy prop %p still emits captcha="1" (no opt-out)', (v) => {
|
||||
const html = run({ captcha: v });
|
||||
expect(html).toMatch(MARK);
|
||||
expect(html).not.toContain('captcha="0"');
|
||||
expect(html).toBe(run({ captcha: true }));
|
||||
});
|
||||
test('undefined (legacy design) is byte-identical to the legacy marker', () => {
|
||||
const html = run({});
|
||||
expect(html).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" recipient="a@b.com" thankyou="\/thx"-->/);
|
||||
expect(html).not.toContain('captcha');
|
||||
expect(run({ captcha: undefined })).toBe(html);
|
||||
});
|
||||
test('non-boolean junk emits nothing', () => {
|
||||
expect(run({ captcha: 'yes' })).not.toContain('captcha');
|
||||
expect(run({ captcha: 1 })).not.toContain('captcha');
|
||||
test('no captcha prop at all (new design) emits captcha="1"', () => {
|
||||
expect(run({})).toMatch(MARK);
|
||||
});
|
||||
test('no relay (no recipient): no marker at all, even with captcha true', () => {
|
||||
expect(toHtml({ ...extra, captcha: true }, '', 'n').html).not.toContain('WHP-FORM');
|
||||
expect(toHtml({ ...extra, captcha: false }, '', 'n').html).not.toContain('captcha');
|
||||
});
|
||||
test('never emits widget markup (injected at publish)', () => {
|
||||
expect(run({ captcha: true })).not.toMatch(/altcha-widget|challenge\.php|widget\.php/);
|
||||
@@ -42,8 +40,18 @@ test('webhook marker keeps captcha attr inside the strict-regex attribute zone',
|
||||
expect(m).toMatch(/^<!--WHP-FORM id="[^"]+"(?: [a-z]+="[^"]*")* captcha="1" recipient=/);
|
||||
});
|
||||
|
||||
describe('new forms default to captcha on', () => {
|
||||
test.each([ContactForm, SubscribeForm, FormContainer])('%#', (C: any) => {
|
||||
expect(C.craft.props.captcha).toBe(true);
|
||||
describe('captcha is not a configurable prop any more', () => {
|
||||
test.each([ContactForm, SubscribeForm, FormContainer])('%#: no captcha default in craft.props', (C: any) => {
|
||||
expect(C.craft.props.captcha).toBeUndefined();
|
||||
});
|
||||
test('relayFormWiring ignores the legacy 6th argument', () => {
|
||||
const on = relayFormWiring('a@b.com', '', undefined, 'n', undefined).marker;
|
||||
expect(relayFormWiring('a@b.com', '', undefined, 'n', undefined, false).marker).toBe(on);
|
||||
expect(on).toContain(' captcha="1"');
|
||||
});
|
||||
test('a form with an old captcha:false still renders its HTML without error', () => {
|
||||
for (const [, toHtml, extra] of cases) {
|
||||
expect(() => toHtml({ ...extra, ...base, captcha: false }, '', 'n1')).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -369,19 +369,10 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.captcha !== undefined && (
|
||||
{nodeProps.recipientEmail !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="form-captcha"
|
||||
checked={nodeProps.captcha === true}
|
||||
onChange={(e) => setProp('captcha', e.target.checked)}
|
||||
/>
|
||||
Spam protection (captcha)
|
||||
</label>
|
||||
<p style={hintStyle}>
|
||||
Adds a privacy-friendly ALTCHA check to the published form (served by the contact-form relay; visitors need JavaScript). Only applies when submissions are sent through the relay.
|
||||
<p style={hintStyle} data-testid="form-captcha-note">
|
||||
Spam protection (captcha) is always on for forms.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -54,7 +54,7 @@ describe('relayFormWiring email destination stays byte-identical to the legacy s
|
||||
expect(undef.marker).toBe(legacy.marker);
|
||||
expect(email.marker).toBe(legacy.marker);
|
||||
expect(email.actionAttr).toBe(legacy.actionAttr);
|
||||
expect(legacy.marker).toMatch(/^<!--WHP-FORM id="F_[0-9a-z]+" recipient="a@b\.com" thankyou="\/thx"-->$/);
|
||||
expect(legacy.marker).toMatch(/^<!--WHP-FORM id="F_[0-9a-z]+" captcha="1" recipient="a@b\.com" thankyou="\/thx"-->$/);
|
||||
});
|
||||
|
||||
test('an email form with no recipient is still not a relay at all', () => {
|
||||
@@ -78,7 +78,7 @@ describe('relayFormWiring webhook marker matches the publish-side parser exactly
|
||||
|
||||
test('the optional attributes sit BETWEEN id and recipient, lowercase-named', () => {
|
||||
const attrs = w.marker.match(PUBLISH_MARKER_RE)![2];
|
||||
expect(attrs).toBe(' type="webhook" url="https://hooks.example.com/x" secret="whs_7_abc123" authmode="bearer"');
|
||||
expect(attrs).toBe(' type="webhook" url="https://hooks.example.com/x" secret="whs_7_abc123" authmode="bearer" captcha="1"');
|
||||
});
|
||||
|
||||
test('a webhook with no recipient still emits a relay marker', () => {
|
||||
|
||||
@@ -63,6 +63,11 @@ function markerAttr(name: string, value: string): string {
|
||||
return ` ${name}="${escapeAttr(value)}"`;
|
||||
}
|
||||
|
||||
/** Is a form wired to the relay (recipient set, or webhook destination)? Captcha is always on for these. */
|
||||
export function isRelayWired(recipientEmail?: string, destinationType?: string): boolean {
|
||||
return !!recipientEmail || allowlist(destinationType, DESTINATION_TYPES, 'email') === 'webhook';
|
||||
}
|
||||
|
||||
export interface RelayWiring {
|
||||
/** true when a recipient is set (relay path); false = legacy formAction fallback */
|
||||
useRelay: boolean;
|
||||
@@ -82,10 +87,10 @@ export interface RelayWiring {
|
||||
* the marker/placeholder id deterministically and uniquely --
|
||||
* see `scopeId` in ./escape. Falls back to a stable hash of the
|
||||
* recipient/thankYouUrl/fallbackAction when omitted (never random).
|
||||
* @param captcha ALTCHA captcha intent for the relay (whp: FormRelayRewrite). `true` emits
|
||||
* `captcha="1"`, EXPLICIT `false` emits `captcha="0"` (user un-ticked: the
|
||||
* relay must clear a stored flag), anything else (undefined/legacy) emits NO
|
||||
* attribute, so legacy saved designs stay byte-identical and keep their stored flag.
|
||||
* @param _legacyCaptcha IGNORED. Captcha is MANDATORY on every relay form (no opt-out): a relay-wired form
|
||||
* always emits `captcha="1"`, whatever an old saved design holds in its `captcha` prop
|
||||
* (undefined, true, false or junk). Kept as a parameter only so existing call sites and
|
||||
* old projects keep compiling/loading; the publish step ignores the attribute value too.
|
||||
* @param destination optional destination descriptor. Omitted, or `type: 'email'`,
|
||||
* yields the LEGACY narrow marker byte for byte -- every
|
||||
* already-published site depends on that shape continuing to
|
||||
@@ -111,7 +116,7 @@ export function relayFormWiring(
|
||||
fallbackAction: string | undefined,
|
||||
nodeId?: string,
|
||||
destination?: FormDestination,
|
||||
captcha?: boolean,
|
||||
_legacyCaptcha?: unknown,
|
||||
): RelayWiring {
|
||||
const destType = allowlist(destination?.type, DESTINATION_TYPES, 'email');
|
||||
const isWebhook = destType === 'webhook';
|
||||
@@ -141,8 +146,8 @@ export function relayFormWiring(
|
||||
+ markerAttr('authmode', allowlist(destination?.authMode, AUTH_MODES, 'signature'))
|
||||
: '';
|
||||
|
||||
const captchaAttr = captcha === true ? markerAttr('captcha', '1')
|
||||
: captcha === false ? markerAttr('captcha', '0') : '';
|
||||
// Always on: the widget, flag and script are applied together at publish (whp: FormRelayRewrite).
|
||||
const captchaAttr = markerAttr('captcha', '1');
|
||||
|
||||
return {
|
||||
useRelay: true,
|
||||
|
||||
Reference in new issue
Block a user