Webhook destination controls for contact forms #27

Merged
jknapp merged 3 commits from feat/form-webhook-delivery into main 2026-08-11 00:42:03 +00:00
8 changed files with 831 additions and 9 deletions
Showing only changes of commit d1c57db967 - Show all commits
@@ -196,3 +196,83 @@ describe('ContactForm.craft.props includes animation/visibility defaults', () =>
});
});
});
/* ---------- Webhook destination (Task 10) ----------
This file builds props inline rather than spreading a shared object, so
`defaultProps` is introduced here for the destination cases only; every
pre-existing test above is untouched. */
const defaultProps = { fields: [] as any[], formAction: '#' };
describe('ContactForm.craft.props includes the destination defaults', () => {
// The trap this pins: FormStylePanel renders each destination control behind
// `nodeProps.X !== undefined`, so a prop omitted from these defaults yields an
// invisible control and the whole feature looks like it does nothing.
test('destinationType/webhookUrl/webhookSecretId/webhookAuthMode are all present', () => {
expect(ContactForm.craft!.props).toMatchObject({
destinationType: 'email',
webhookUrl: '',
webhookSecretId: '',
webhookAuthMode: 'signature',
});
});
test('no craft prop holds a raw secret -- only an id', () => {
const keys = Object.keys(ContactForm.craft!.props as object);
expect(keys).toContain('webhookSecretId');
expect(keys.filter((k) => /secret/i.test(k))).toEqual(['webhookSecretId']);
});
});
describe('ContactForm.toHtml destination marker', () => {
test('email destination emits the legacy marker unchanged', () => {
const out = toHtml(
{ ...defaultProps, destinationType: 'email', recipientEmail: 'a@example.com', thankYouUrl: '' }, '');
expect(out.html).toContain('<!--WHP-FORM');
expect(out.html).toContain('recipient="a@example.com"');
expect(out.html).not.toContain('type="webhook"');
});
test('BYTE-IDENTITY: an email destination emits exactly what a pre-feature form emits', () => {
// The guarantee every already-published site depends on: a marker with no
// `type` still provisions an email endpoint, so its bytes must not drift by
// so much as a space. Compared against a form that has no destination props
// at all (a page saved before this feature existed).
const legacy = toHtml({ ...defaultProps, recipientEmail: 'a@example.com', thankYouUrl: '/thx' }, '', 'n1');
const explicit = toHtml(
{ ...defaultProps, destinationType: 'email', webhookUrl: '', webhookSecretId: '',
webhookAuthMode: 'signature', recipientEmail: 'a@example.com', thankYouUrl: '/thx' }, '', 'n1');
expect(explicit.html).toBe(legacy.html);
// ...and the marker itself is the exact narrow shape, anchored.
expect(legacy.html).toMatch(/^<!--WHP-FORM id="F_[0-9a-z]+" recipient="a@example\.com" thankyou="\/thx"--><form /);
});
test('webhook destination emits type, url, secret id and auth mode', () => {
const out = toHtml(
{ ...defaultProps, destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x',
webhookSecretId: 'sec-1', webhookAuthMode: 'bearer', recipientEmail: 'fb@example.com' }, '');
expect(out.html).toContain('type="webhook"');
expect(out.html).toContain('url="https://hooks.example.com/x"');
expect(out.html).toContain('secret="sec-1"');
expect(out.html).toContain('authmode="bearer"');
expect(out.html).toContain('recipient="fb@example.com"');
});
test('a raw secret value is never emitted, only its id', () => {
const out = toHtml(
{ ...defaultProps, destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x',
webhookSecretId: 'sec-1', webhookSecret: 'SUPERSECRET' } as any, '');
// Non-vacuous: the marker IS emitted (so there is something that could have
// carried the secret) and carries the id, but not the value.
expect(out.html).toContain('secret="sec-1"');
expect(out.html).not.toContain('SUPERSECRET');
});
test('a webhook form with no fallback email still emits a marker (never a bare formAction)', () => {
const out = toHtml(
{ ...defaultProps, destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x' }, '');
expect(out.html).toContain('type="webhook"');
expect(out.html).toContain('recipient=""');
expect(out.html).toMatch(/action="__WHP_FORM_ACTION__F_[0-9a-z]+__"/);
expect(out.html).toContain('name="_gotcha"');
});
});
+27 -1
View File
@@ -34,6 +34,16 @@ interface ContactFormProps {
inputBorder?: string;
recipientEmail?: string;
thankYouUrl?: string;
/* ---- Submission destination (see utils/form-relay-wiring.ts) ----
'email' (default) reproduces the legacy marker exactly. 'webhook' widens it
with the url / secret id / auth mode below.
There is deliberately NO raw-secret prop: craft props are serialised into
the saved project and into published output, so the secret is POSTed to
/api/form-webhook-secret.php and only the returned opaque id is kept. */
destinationType?: 'email' | 'webhook';
webhookUrl?: string;
webhookSecretId?: string;
webhookAuthMode?: 'signature' | 'bearer';
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
@@ -171,6 +181,12 @@ ContactForm.craft = {
inputBorder: '#d1d5db',
recipientEmail: '',
thankYouUrl: '',
// Present (not omitted) so FormStylePanel's `nodeProps.X !== undefined`
// gates actually render the destination controls.
destinationType: 'email',
webhookUrl: '',
webhookSecretId: '',
webhookAuthMode: 'signature',
animation: '',
animationDelay: '',
hideOnDesktop: false,
@@ -233,7 +249,17 @@ ContactForm.craft = {
alignSelf: 'flex-start',
});
const { marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.formAction, nodeId);
// Only the webhook SECRET ID travels here -- there is no prop holding the raw
// secret, by construction (see ContactFormProps).
const { marker, actionAttr, honeypot } = relayFormWiring(
props.recipientEmail, props.thankYouUrl, props.formAction, nodeId,
{
type: props.destinationType,
url: props.webhookUrl,
secretId: props.webhookSecretId,
authMode: props.webhookAuthMode,
},
);
// The form-sender relay delivers success via a full-page 303 redirect
// (to thankYouUrl or a hosted thanks.php page) -- there is no in-page JS
@@ -1,5 +1,6 @@
import React from 'react';
import React, { useState } from 'react';
import { useEditor } from '@craftjs/core';
import { storeWebhookSecret } from '../../../utils/form-webhook-secret';
import {
BG_COLORS,
SPACING_PRESETS,
@@ -58,12 +59,87 @@ const SPACING_SIDE_KEYS: { side: 'top' | 'right' | 'bottom' | 'left'; suffix: 'T
{ side: 'left', suffix: 'Left' },
];
const hintStyle: React.CSSProperties = { fontSize: 10, color: '#71717a', margin: '4px 0 0' };
/* ---------- Webhook shared secret: WRITE-ONLY field ----------
The raw secret lives in this component's local state and nowhere else. On
blur it is POSTed to the panel endpoint, which returns an opaque id; only
that id is handed to `onStored` (and thence to a craft prop). The field is
then cleared, because there is no read route and nothing to show back --
the UI offers set / replace / remove, never "view". */
export const WebhookSecretField: React.FC<{
secretId: string;
onStored: (secretId: string) => void;
onRemoved: () => void;
}> = ({ secretId, onStored, onRemoved }) => {
const [draft, setDraft] = useState('');
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const [error, setError] = useState('');
const save = async () => {
const raw = draft.trim();
if (raw === '' || status === 'saving') return;
setStatus('saving');
setError('');
const result = await storeWebhookSecret(raw);
if (result.ok && result.secretId) {
// Only the id crosses this line. The raw value is dropped here and is
// never written to a prop, to storage, or back into the input.
onStored(result.secretId);
setDraft('');
setStatus('saved');
} else {
setStatus('error');
setError(result.error || 'Could not store the secret.');
}
};
return (
<div style={sectionGap}>
<label style={labelStyle} htmlFor="whp-webhook-secret">Shared secret (optional)</label>
<input
id="whp-webhook-secret"
data-testid="webhook-secret-input"
type="password"
autoComplete="new-password"
value={draft}
onChange={(e) => { setDraft(e.target.value); if (status !== 'idle') { setStatus('idle'); setError(''); } }}
onBlur={() => { void save(); }}
placeholder={secretId ? 'Paste a new secret to replace' : 'Paste the secret from your receiver'}
style={inputStyle}
/>
{secretId && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4 }}>
<span data-testid="webhook-secret-status" style={{ fontSize: 10, color: '#22c55e' }}>Secret stored</span>
<button
data-testid="webhook-secret-remove"
onClick={onRemoved}
style={{ ...moveBtnStyle, flex: 'none', padding: '3px 8px' }}
>
Remove
</button>
</div>
)}
{status === 'saving' && <p style={hintStyle}>Storing</p>}
{status === 'saved' && <p data-testid="webhook-secret-saved" style={{ ...hintStyle, color: '#22c55e' }}>Secret stored.</p>}
{status === 'error' && <p data-testid="webhook-secret-error" style={{ ...hintStyle, color: '#f87171' }}>{error}</p>}
<p style={hintStyle}>
Stored on the server and never shown again paste a new one to replace it. Used to sign
(or authorise) each delivery so your receiver can verify it came from this site.
</p>
</div>
);
};
/* ---------- FORM ---------- */
export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor();
const { setProp, setPropStyle } = useNodeProp(selectedId);
const style = nodeProps.style || {};
// ContactForm only: FormContainer/SubscribeForm have no destinationType prop,
// so their relay controls stay exactly as they were.
const isWebhook = nodeProps.destinationType === 'webhook';
const updateField = (index: number, patch: Record<string, any>) => {
actions.setProp(selectedId, (props: any) => {
@@ -158,14 +234,77 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
</CollapsibleSection>
)}
{/* Destination: email (default, unchanged behaviour) or webhook. Gated on
the `destinationType` default in ContactForm.craft.props -- a prop
missing from those defaults is `undefined` here and the control would
simply never render. */}
{nodeProps.destinationType !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Send submissions to</label>
<div style={{ display: 'flex', gap: 4 }}>
{[{ v: 'email', l: 'Email' }, { v: 'webhook', l: 'Webhook' }].map((o) => (
<button
key={o.v}
data-testid={`destination-${o.v}`}
onClick={() => setProp('destinationType', o.v)}
style={btnActiveStyle((nodeProps.destinationType || 'email') === o.v)}
>
{o.l}
</button>
))}
</div>
</div>
)}
{nodeProps.destinationType !== undefined && isWebhook && (
<>
<div style={sectionGap}>
<label style={labelStyle}>Webhook URL</label>
<input
type="text"
data-testid="webhook-url"
value={nodeProps.webhookUrl || ''}
onChange={(e) => setProp('webhookUrl', e.target.value)}
placeholder="https://hooks.example.com/..."
style={inputStyle}
/>
<p style={hintStyle}>
Must be an absolute <strong>https://</strong> URL. Each submission is POSTed as JSON;
failures are retried, then emailed to the fallback address below.
</p>
</div>
<div style={sectionGap}>
<label style={labelStyle}>Authentication</label>
<select
data-testid="webhook-authmode"
value={nodeProps.webhookAuthMode || 'signature'}
onChange={(e) => setProp('webhookAuthMode', e.target.value)}
style={{ ...inputStyle, cursor: 'pointer' }}
>
<option value="signature">Signature (HMAC-SHA256 header)</option>
<option value="bearer">Bearer token (Authorization header)</option>
</select>
</div>
<WebhookSecretField
secretId={nodeProps.webhookSecretId || ''}
onStored={(id) => setProp('webhookSecretId', id)}
onRemoved={() => setProp('webhookSecretId', '')}
/>
</>
)}
{/* Contact-form relay: where submissions are emailed. Present on ContactForm
and FormContainer (both have recipientEmail/thankYouUrl props). */}
and FormContainer (both have recipientEmail/thankYouUrl props). With a
webhook destination this same address is the FALLBACK the relay uses
when delivery is exhausted. */}
{nodeProps.recipientEmail !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Send submissions to (email)</label>
<label style={labelStyle}>{isWebhook ? 'Fallback email (if the webhook fails)' : 'Send submissions to (email)'}</label>
<input type="email" value={nodeProps.recipientEmail || ''} onChange={(e) => setProp('recipientEmail', e.target.value)} placeholder="you@example.com" style={inputStyle} />
<p style={{ fontSize: 10, color: '#71717a', margin: '4px 0 0' }}>
Emailed via the site's contact-form relay (an admin must enable it in Server Settings). Leave blank to use the Form Action URL instead.
<p style={hintStyle}>
{isWebhook
? 'Emailed here if the webhook keeps failing after retries. Leave blank to skip the fallback.'
: "Emailed via the site's contact-form relay (an admin must enable it in Server Settings). Leave blank to use the Form Action URL instead."}
</p>
</div>
)}
@@ -0,0 +1,222 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
/* Same DOM harness + craftjs mock as NavStylePanel.test.tsx (no
@testing-library/react in this repo). `lastProps` IS the node's prop bag: the
mocked setProp mutates it exactly as Craft.js would, which is what lets the
"no raw secret ever reaches a prop" assertion below be a real check on
everything the panel writes rather than on a hand-picked key. */
const setPropSpy = vi.fn((_id: string, updater: (p: any) => void) => {
updater(lastProps);
});
let lastProps: any;
vi.mock('@craftjs/core', () => ({
useEditor: () => ({ actions: { setProp: setPropSpy } }),
}));
import { FormStylePanel } from './FormStylePanel';
const RAW_SECRET = 'hunter2-SUPER-SECRET-VALUE';
let container: HTMLDivElement;
let root: Root;
function render(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(ui);
});
}
function rerender(ui: React.ReactElement) {
act(() => { root.render(ui); });
}
function unmount() {
act(() => { root.unmount(); });
container.remove();
}
function setValue(el: HTMLInputElement | HTMLSelectElement, value: string) {
const proto = el instanceof HTMLSelectElement ? window.HTMLSelectElement.prototype : window.HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, 'value')!.set!;
act(() => {
setter.call(el, value);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
});
}
function click(el: Element | null) {
if (!el) throw new Error('element not found');
act(() => { (el as HTMLElement).dispatchEvent(new MouseEvent('click', { bubbles: true })); });
}
/* React 17+ implements onBlur with the native `focusout` event (which bubbles),
not `blur`. */
async function blur(el: Element) {
await act(async () => {
el.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
await Promise.resolve();
await Promise.resolve();
});
}
const testid = (id: string) => container.querySelector(`[data-testid="${id}"]`);
function contactFormProps(over: Record<string, any> = {}) {
return {
fields: [],
style: {},
recipientEmail: '',
thankYouUrl: '',
destinationType: 'email',
webhookUrl: '',
webhookSecretId: '',
webhookAuthMode: 'signature',
...over,
};
}
beforeEach(() => {
setPropSpy.mockClear();
(window as any).WHP_CONFIG = { apiUrl: '/api/site-builder.php', csrfToken: 'tok', siteId: 42 };
});
afterEach(() => {
if (container) unmount();
delete (window as any).WHP_CONFIG;
vi.unstubAllGlobals();
});
describe('FormStylePanel destination selector', () => {
test('choosing Webhook writes destinationType and reveals the webhook fields', () => {
lastProps = contactFormProps();
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
expect(testid('webhook-url')).toBeNull();
click(testid('destination-webhook'));
expect(lastProps.destinationType).toBe('webhook');
rerender(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
expect(testid('webhook-url')).toBeTruthy();
expect(testid('webhook-authmode')).toBeTruthy();
expect(testid('webhook-secret-input')).toBeTruthy();
});
test('the URL and auth-mode controls write their props', () => {
lastProps = contactFormProps({ destinationType: 'webhook' });
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
setValue(testid('webhook-url') as HTMLInputElement, 'https://hooks.example.com/x');
expect(lastProps.webhookUrl).toBe('https://hooks.example.com/x');
setValue(testid('webhook-authmode') as HTMLSelectElement, 'bearer');
expect(lastProps.webhookAuthMode).toBe('bearer');
});
test('the recipient field is relabelled as the fallback address for a webhook', () => {
lastProps = contactFormProps({ destinationType: 'webhook' });
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
const labels = Array.from(container.querySelectorAll('label')).map((l) => l.textContent);
expect(labels.some((t) => t?.includes('Fallback email'))).toBe(true);
expect(labels.some((t) => t === 'Send submissions to (email)')).toBe(false);
});
test('a component without destinationType (FormContainer) shows neither the selector nor the webhook fields', () => {
lastProps = { recipientEmail: '', thankYouUrl: '', style: {} };
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
expect(testid('destination-webhook')).toBeNull();
expect(testid('webhook-url')).toBeNull();
const labels = Array.from(container.querySelectorAll('label')).map((l) => l.textContent);
expect(labels).toContain('Send submissions to (email)');
});
});
describe('FormStylePanel webhook secret field is WRITE-ONLY', () => {
test('blurring the secret POSTs it and persists ONLY the returned id', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true, status: 200,
json: async () => ({ success: true, secret_id: 'whs_42_deadbeefdeadbeef' }),
});
vi.stubGlobal('fetch', fetchMock);
lastProps = contactFormProps({ destinationType: 'webhook' });
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
const input = testid('webhook-secret-input') as HTMLInputElement;
setValue(input, RAW_SECRET);
await blur(input);
// The flow really ran (otherwise the assertions below would pass vacuously).
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(JSON.parse(fetchMock.mock.calls[0][1].body).secret).toBe(RAW_SECRET);
expect(lastProps.webhookSecretId).toBe('whs_42_deadbeefdeadbeef');
// THE PROPERTY: nothing the panel wrote to the node's props contains the raw
// secret, under any key -- craft props are serialised into the saved project
// and into published output.
expect(JSON.stringify(lastProps)).not.toContain(RAW_SECRET);
for (const [, updater] of setPropSpy.mock.calls) {
const probe: any = {};
(updater as (p: any) => void)(probe);
expect(JSON.stringify(probe)).not.toContain(RAW_SECRET);
}
// ...and the input is cleared, so it isn't sitting in the DOM either.
expect((testid('webhook-secret-input') as HTMLInputElement).value).toBe('');
expect(testid('webhook-secret-saved')).toBeTruthy();
});
test('typing a secret without blurring writes nothing at all', () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
lastProps = contactFormProps({ destinationType: 'webhook' });
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
setValue(testid('webhook-secret-input') as HTMLInputElement, RAW_SECRET);
expect(fetchMock).not.toHaveBeenCalled();
expect(JSON.stringify(lastProps)).not.toContain(RAW_SECRET);
});
test('a failed store surfaces the endpoint message and persists no id', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false, status: 429,
json: async () => ({ success: false, error: 'Too many webhook secrets stored for this site — please contact support.' }),
}));
lastProps = contactFormProps({ destinationType: 'webhook' });
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
const input = testid('webhook-secret-input') as HTMLInputElement;
setValue(input, RAW_SECRET);
await blur(input);
expect(testid('webhook-secret-error')!.textContent).toContain('Too many webhook secrets stored for this site');
expect(lastProps.webhookSecretId).toBe('');
expect(JSON.stringify(lastProps)).not.toContain(RAW_SECRET);
});
test('the stored secret is never shown -- only its state, with a Remove action', () => {
lastProps = contactFormProps({ destinationType: 'webhook', webhookSecretId: 'whs_42_deadbeefdeadbeef' });
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
expect(testid('webhook-secret-status')!.textContent).toContain('Secret stored');
// The field is a password input that starts empty: there is no read route to
// populate it from, and nothing anywhere renders the value.
const input = testid('webhook-secret-input') as HTMLInputElement;
expect(input.type).toBe('password');
expect(input.value).toBe('');
click(testid('webhook-secret-remove'));
expect(lastProps.webhookSecretId).toBe('');
});
test('blurring an empty secret field posts nothing', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
lastProps = contactFormProps({ destinationType: 'webhook' });
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
await blur(testid('webhook-secret-input')!);
expect(fetchMock).not.toHaveBeenCalled();
});
});
+117
View File
@@ -36,3 +36,120 @@ describe('relayFormWiring deterministic + unique fid (thread node id, no Math.ra
expect(w1.actionAttr).toBe(w2.actionAttr);
});
});
/* ---------- Webhook destination (Task 10) ---------- */
/** The publish-side parser, verbatim from web-files/libs/FormRelayRewrite.php's
* fs_rewrite_contact_forms() -- ported to JS so a drift in the emitter's
* attribute ORDER, NAMES or QUOTING fails here rather than at publish time,
* where it either leaks the recipient address or refuses the publish. */
const PUBLISH_MARKER_RE =
/^<!--WHP-FORM id="([^"]+)"((?: [a-z]+="[^"]*")*) recipient="([^"]*)" thankyou="([^"]*)"-->$/;
describe('relayFormWiring email destination stays byte-identical to the legacy shape', () => {
test('no destination arg, type undefined, and type "email" all produce the same marker', () => {
const legacy = relayFormWiring('a@b.com', '/thx', '/act', 'n1');
const undef = relayFormWiring('a@b.com', '/thx', '/act', 'n1', {});
const email = relayFormWiring('a@b.com', '/thx', '/act', 'n1', { type: 'email', url: 'https://x.example/y', secretId: 'whs_1_abc', authMode: 'bearer' });
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"-->$/);
});
test('an email form with no recipient is still not a relay at all', () => {
const w = relayFormWiring('', '/thx', '/legacy', 'n1', { type: 'email' });
expect(w.useRelay).toBe(false);
expect(w.marker).toBe('');
});
});
describe('relayFormWiring webhook marker matches the publish-side parser exactly', () => {
const w = relayFormWiring('fb@b.com', '/thx', '#', 'n1', {
type: 'webhook', url: 'https://hooks.example.com/x', secretId: 'whs_7_abc123', authMode: 'bearer',
});
test('the whole marker parses with FormRelayRewrite.php\'s pattern', () => {
const m = w.marker.match(PUBLISH_MARKER_RE);
expect(m).not.toBeNull();
expect(m![3]).toBe('fb@b.com');
expect(m![4]).toBe('/thx');
});
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"');
});
test('a webhook with no recipient still emits a relay marker', () => {
const bare = relayFormWiring('', '', '#', 'n1', { type: 'webhook', url: 'https://hooks.example.com/x' });
expect(bare.useRelay).toBe(true);
expect(bare.marker).toMatch(PUBLISH_MARKER_RE);
expect(bare.marker).toContain('authmode="signature"');
});
test('two webhook forms with no node id and different urls get different fids', () => {
const a = relayFormWiring('', '', '#', undefined, { type: 'webhook', url: 'https://a.example/x' });
const b = relayFormWiring('', '', '#', undefined, { type: 'webhook', url: 'https://b.example/x' });
expect(a.marker).not.toBe(b.marker);
});
});
describe('relayFormWiring escapes EVERY marker attribute value', () => {
/* The property the publish-time strip is built on: no raw `"`, `<`, `>` or a
literal `-->` may reach a marker attribute value. An unescaped one truncates
the strip mid-marker and ships the recipient address in the page source, or
trips the post-condition and refuses the publish outright. */
const hostile = 'a"b<c>d\'e-->f';
const w = relayFormWiring(`${hostile}@x.com`, hostile, '#', 'n1', {
type: 'webhook', url: `https://x/${hostile}`, secretId: hostile, authMode: 'bearer',
});
test('the marker still parses as ONE marker (nothing escaped out of a value)', () => {
expect(w.marker).toMatch(PUBLISH_MARKER_RE);
});
test.each([
['url', `https://x/${hostile}`],
['secret', hostile],
['recipient', `${hostile}@x.com`],
['thankyou', hostile],
])('%s carries no raw ", <, > or -->', (name) => {
const value = w.marker.match(new RegExp(` ${name}="([^"]*)"`))![1];
expect(value).not.toMatch(/["<>]/);
expect(value).not.toContain('-->');
expect(value).toContain('&quot;');
expect(value).toContain('&lt;');
expect(value).toContain('&gt;');
});
test('the raw hostile string never appears anywhere in the marker', () => {
expect(w.marker).not.toContain(hostile);
});
});
describe('relayFormWiring allowlists type and authmode instead of trusting them', () => {
test('type is matched case-insensitively -- "Webhook" is a webhook, not a silent email', () => {
const w = relayFormWiring('a@b.com', '', '#', 'n1', { type: 'WebHook', url: 'https://x/y' });
expect(w.marker).toContain('type="webhook"');
});
test('an unknown type falls back to the legacy email marker', () => {
const w = relayFormWiring('a@b.com', '', '#', 'n1', { type: 'slack', url: 'https://x/y' } as any);
expect(w.marker).not.toContain('type=');
expect(w.marker).toBe(relayFormWiring('a@b.com', '', '#', 'n1').marker);
});
test('a hostile authMode collapses to signature and cannot break out of the attribute', () => {
const w = relayFormWiring('a@b.com', '', '#', 'n1', {
type: 'webhook', url: 'https://x/y', authMode: 'bearer" onx="1',
});
expect(w.marker).toContain('authmode="signature"');
expect(w.marker).toMatch(PUBLISH_MARKER_RE);
});
test('"Bearer" is promoted to the exact literal the relay compares against', () => {
const w = relayFormWiring('a@b.com', '', '#', 'n1', { type: 'webhook', url: 'https://x/y', authMode: ' Bearer ' });
expect(w.marker).toContain('authmode="bearer"');
});
});
+98 -3
View File
@@ -11,6 +11,58 @@
import { escapeAttr, safeUrl, scopeId } from './escape';
/**
* Where a form's submissions go. Optional on every call site: a form that
* passes nothing here (or `type: 'email'`) emits the LEGACY marker, byte for
* byte -- see `relayFormWiring` below.
*/
export interface FormDestination {
/** 'webhook' (case-insensitive) selects the webhook path; anything else = email. */
type?: string;
/** Absolute https URL the relay POSTs to. Validated at publish time. */
url?: string;
/** Opaque id minted by /api/form-webhook-secret.php. NEVER the raw secret. */
secretId?: string;
/** 'bearer' or 'signature' (HMAC, the default). */
authMode?: string;
}
/** The two destination types this emitter knows how to describe. */
const DESTINATION_TYPES = ['email', 'webhook'] as const;
/** The two auth modes the relay implements (FormRelayProvisioner::upsertWebhookToken). */
const AUTH_MODES = ['signature', 'bearer'] as const;
/**
* Allowlist a destination type / auth mode rather than escaping it.
*
* Same reasoning as `sanitizeInputType`/`sanitizeFormMethod` in ./escape: these
* props are declared as unions in TS but arrive raw from a deserialized saved
* state or the AI `update_props` path, and the only legitimate values are a
* fixed pair. Narrowing here also stops a case-drifted `"Bearer"` from being
* silently downgraded to `signature` by the publish step (which compares
* `=== 'bearer'` exactly) -- the customer would see unsigned deliveries with
* nothing in the UI to explain it.
*/
function allowlist<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
const v = (typeof value === 'string' ? value : '').trim().toLowerCase();
return (allowed as readonly string[]).includes(v) ? (v as T) : fallback;
}
/**
* Emit one ` name="value"` marker attribute.
*
* THE SINGLE ESCAPING SITE for every optional marker attribute. The publish-time
* strip (whp: web-files/libs/FormRelayRewrite.php) has been hardened six times
* over exactly this: an unescaped `<`, `>` or a literal `-->` inside a marker
* attribute value truncates the strip mid-marker and leaks the customer's
* recipient address into their public page source, or trips the post-condition
* and refuses the publish outright. `escapeAttr` removing `"` is also what keeps
* each value inside the `[^"]*` the publish-side parser expects.
*/
function markerAttr(name: string, value: string): string {
return ` ${name}="${escapeAttr(value)}"`;
}
export interface RelayWiring {
/** true when a recipient is set (relay path); false = legacy formAction fallback */
useRelay: boolean;
@@ -30,20 +82,63 @@ 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 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
* provision an email endpoint.
*
* The wide (webhook) marker keeps the optional attributes BETWEEN `id` and
* `recipient`, which is where the publish-side parser looks for them:
*
* /<!--WHP-FORM id="([^"]+)"((?: [a-z]+="[^"]*")*) recipient="([^"]*)" thankyou="([^"]*)"-->/
*
* (FormRelayRewrite.php). Attribute NAMES must therefore be lowercase, and every
* VALUE must be free of `"` -- both guaranteed here, the latter by `markerAttr`.
*
* A webhook marker is emitted whenever the customer selected webhook, even with a
* blank URL: the publish step then refuses that one endpoint and logs it (the form
* publishes inert). Falling back to the email path instead would deliver mail to a
* customer who configured a webhook, with nothing anywhere to explain it -- the
* exact silent degradation the publish-side `type` normalisation exists to stop.
*/
export function relayFormWiring(
recipientEmail: string | undefined,
thankYouUrl: string | undefined,
fallbackAction: string | undefined,
nodeId?: string,
destination?: FormDestination,
): RelayWiring {
if (!recipientEmail) {
const destType = allowlist(destination?.type, DESTINATION_TYPES, 'email');
const isWebhook = destType === 'webhook';
// No destination at all: nothing to deliver to, so no relay (unchanged).
if (!recipientEmail && !isWebhook) {
return { useRelay: false, marker: '', actionAttr: escapeAttr(safeUrl(fallbackAction || '#')), honeypot: '' };
}
const fid = scopeId(nodeId, `${recipientEmail}::${thankYouUrl || ''}::${fallbackAction || ''}`, 'F');
// Webhook config participates in the fallback seed so two webhook forms with
// no node id and no recipient don't collide on one fid. Appended only in the
// webhook branch, so the legacy seed -- and therefore every legacy fid -- is
// unchanged.
const seed = `${recipientEmail || ''}::${thankYouUrl || ''}::${fallbackAction || ''}`
+ (isWebhook ? `::webhook::${destination?.url || ''}::${destination?.secretId || ''}` : '');
const fid = scopeId(nodeId, seed, 'F');
// The `url` value is NOT routed through `safeUrl`: it is never a live sink
// (it lands in an HTML comment that the publish step strips), and blanking it
// here would silently turn a mistyped destination into an inert form with no
// log line. The publish step validates it properly -- absolute https, no
// control characters -- and refuses loudly when it doesn't hold.
const extraAttrs = isWebhook
? markerAttr('type', 'webhook')
+ markerAttr('url', destination?.url || '')
+ markerAttr('secret', destination?.secretId || '')
+ markerAttr('authmode', allowlist(destination?.authMode, AUTH_MODES, 'signature'))
: '';
return {
useRelay: true,
marker: `<!--WHP-FORM id="${fid}" recipient="${escapeAttr(recipientEmail)}" thankyou="${escapeAttr(thankYouUrl || '')}"-->`,
marker: `<!--WHP-FORM id="${fid}"${extraAttrs} recipient="${escapeAttr(recipientEmail || '')}" thankyou="${escapeAttr(thankYouUrl || '')}"-->`,
actionAttr: `__WHP_FORM_ACTION__${fid}__`,
honeypot: `<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px" aria-hidden="true">`,
};
@@ -0,0 +1,79 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { storeWebhookSecret, webhookSecretEndpoint } from './form-webhook-secret';
const CFG = { apiUrl: '/api/site-builder.php', csrfToken: 'tok-123', siteId: 42 };
beforeEach(() => {
(window as any).WHP_CONFIG = { ...CFG };
});
afterEach(() => {
delete (window as any).WHP_CONFIG;
vi.unstubAllGlobals();
});
describe('webhookSecretEndpoint', () => {
test('derives the sibling endpoint from the configured API url', () => {
expect(webhookSecretEndpoint('/api/site-builder.php')).toBe('/api/form-webhook-secret.php');
expect(webhookSecretEndpoint('https://panel.example.com/api/site-builder'))
.toBe('https://panel.example.com/api/form-webhook-secret.php');
});
test('falls back to the absolute path when there is no configured url', () => {
expect(webhookSecretEndpoint(undefined)).toBe('/api/form-webhook-secret.php');
expect(webhookSecretEndpoint('')).toBe('/api/form-webhook-secret.php');
});
});
describe('storeWebhookSecret', () => {
test('POSTs the secret with the CSRF header and returns only the id', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true, status: 200,
json: async () => ({ success: true, secret_id: 'whs_42_abcdef0123456789' }),
});
vi.stubGlobal('fetch', fetchMock);
const result = await storeWebhookSecret('SUPERSECRET');
expect(result).toEqual({ ok: true, secretId: 'whs_42_abcdef0123456789' });
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe('/api/form-webhook-secret.php');
// POST only -- the endpoint has no read route by design.
expect(opts.method).toBe('POST');
expect(opts.headers['X-CSRF-Token']).toBe('tok-123');
expect(JSON.parse(opts.body)).toEqual({ site_id: 42, secret: 'SUPERSECRET' });
});
test('passes the 429 cap message through so a customer can act on it', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false, status: 429,
json: async () => ({ success: false, error: 'Too many webhook secrets stored for this site — please contact support.' }),
}));
const result = await storeWebhookSecret('s');
expect(result.ok).toBe(false);
expect(result.error).toContain('Too many webhook secrets stored for this site');
});
test('a success:false body is a failure even with HTTP 200', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true, status: 200, json: async () => ({ success: false, error: 'Invalid CSRF token' }),
}));
expect(await storeWebhookSecret('s')).toEqual({ ok: false, error: 'Invalid CSRF token' });
});
test('a network failure resolves with an error rather than throwing', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
const result = await storeWebhookSecret('s');
expect(result.ok).toBe(false);
expect(result.secretId).toBeUndefined();
});
test('standalone mode (no WHP_CONFIG) never posts anywhere', async () => {
delete (window as any).WHP_CONFIG;
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const result = await storeWebhookSecret('s');
expect(result.ok).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});
});
+64
View File
@@ -0,0 +1,64 @@
/* ---------- Contact-form webhook secret: WRITE-ONLY client ----------
Posts a raw shared secret to the panel endpoint and gets back an opaque id.
The raw secret is never stored anywhere on the client: it is passed to
`storeWebhookSecret` from local component state, and only the returned
`secret_id` is ever written to a craft prop. Craft props are serialised into
the saved project and into published output, which is the wrong tier for a
credential -- that is the whole reason this endpoint exists.
THERE IS NO READ. The panel endpoint (web-files/api/form-webhook-secret.php)
is POST-only by design: a "show me my secret" route would re-open the exact
problem this closes. Rotation is another POST, which mints a NEW id. So the
UI can offer set / replace / remove, and never "view". */
export interface StoreSecretResult {
ok: boolean;
/** Present only on success -- `whs_<siteId>_<hex>`. */
secretId?: string;
/** Customer-facing message on failure (the endpoint's own, when it sent one). */
error?: string;
}
/**
* Derive the secret endpoint from the configured API url, so a deployment that
* moves the panel API (or the vite dev proxy) doesn't need a second constant
* kept in sync: `/api/site-builder.php` -> `/api/form-webhook-secret.php`.
*/
export function webhookSecretEndpoint(apiUrl?: string): string {
const base = typeof apiUrl === 'string' ? apiUrl.trim() : '';
if (base.includes('/')) return base.replace(/[^/]*$/, 'form-webhook-secret.php');
return '/api/form-webhook-secret.php';
}
/**
* Store a raw webhook secret for the current site; resolves with its opaque id.
*
* Never throws and never returns the secret. Errors are returned as text fit to
* show a customer -- including the endpoint's 429 ("Too many webhook secrets
* stored for this site"), which is actionable (contact support) and so is
* passed through rather than flattened into a generic failure.
*/
export async function storeWebhookSecret(secret: string): Promise<StoreSecretResult> {
const cfg = (window as any).WHP_CONFIG;
if (!cfg) {
return { ok: false, error: 'Saving a webhook secret needs the builder to be open inside the control panel.' };
}
try {
const resp = await fetch(webhookSecretEndpoint(cfg.apiUrl), {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': cfg.csrfToken },
body: JSON.stringify({ site_id: cfg.siteId, secret }),
});
const data = await resp.json().catch(() => null);
if (resp.ok && data && data.success === true && typeof data.secret_id === 'string' && data.secret_id !== '') {
return { ok: true, secretId: data.secret_id };
}
const message = data && typeof data.error === 'string' && data.error !== ''
? data.error
: `Could not store the secret (HTTP ${resp.status}).`;
return { ok: false, error: message };
} catch {
return { ok: false, error: 'Could not reach the control panel to store the secret.' };
}
}