Merge PR #27: webhook destination controls for contact forms

Adds the webhook destination option to the contact form. Legacy markers stay byte-identical; the raw secret never reaches a Craft prop.

Pairs with the whp-side branch of the same name.
This commit was merged in pull request #27.
This commit is contained in:
2026-08-11 00:42:02 +00:00
10 changed files with 1010 additions and 9 deletions
@@ -196,3 +196,93 @@ 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.
//
// FROZEN LITERAL, not a self-comparison. Comparing two head-revision outputs
// to each other only catches a drift that affects ONE of them -- a uniform
// change passes it. This string was captured from `071f3447` (the revision
// deployed to production before this feature) and is the actual reference:
// 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"-->';
const legacy = toHtml({ ...defaultProps, recipientEmail: 'a@example.com', thankYouUrl: '/thx' }, '', 'n1');
expect(legacy.html.startsWith(`${FROZEN_LEGACY_MARKER}<form `)).toBe(true);
// ...and the new props, set to their defaults, change nothing about it.
const explicit = toHtml(
{ ...defaultProps, destinationType: 'email', webhookUrl: '', webhookSecretId: '',
webhookAuthMode: 'signature', recipientEmail: 'a@example.com', thankYouUrl: '/thx' }, '', 'n1');
expect(explicit.html).toBe(legacy.html);
});
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,125 @@ 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' };
/**
* Does this look like a webhook URL the publish step will accept?
*
* Mirrors the shape `FormRelayProvisioner::upsertWebhookToken()` enforces
* (absolute https, a host, no whitespace/control characters) closely enough to
* warn in the panel. It is a HINT, not a gate -- the server-side check is the
* real one, and this deliberately never edits or blocks the value.
*/
export function isHttpsWebhookUrl(value: unknown): boolean {
const v = typeof value === 'string' ? value.trim() : '';
if (v === '' || /[\s\x00-\x1F\x7F]/.test(v)) return false;
try {
const u = new URL(v);
return u.protocol === 'https:' && u.hostname !== '';
} catch {
return false;
}
}
/* ---------- 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 / clear, never "view".
MOUNT THIS WITH `key={selectedId}`. GuidedStyles renders <FormStylePanel>
with no key, so a selection change re-renders this component rather than
remounting it, and React keeps `draft`/`status`. A failed store deliberately
RETAINS the draft (so a 429 or a network blip doesn't make the customer
retype a pasted key) -- which means without the key, clicking a second
contact form shows node A's raw secret in node B's field, and the next blur
assigns the returned id to the wrong form and burns a slot against the
per-site cap. The `status` banner leaks the same way. */
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>
{/* "Clear", not "Remove": this only drops the form's reference to the
key. The stored key file stays on the server and still counts
toward the per-site cap -- nothing deletes one, so a button
labelled Remove would be telling the customer they had reclaimed
a slot right up until the 429 that says otherwise. */}
<button
data-testid="webhook-secret-remove"
onClick={onRemoved}
style={{ ...moveBtnStyle, flex: 'none', padding: '3px 8px' }}
>
Clear
</button>
</div>
<p style={hintStyle}>
Clearing stops this form using the secret; the stored key stays on the server.
</p>
</>
)}
{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 +272,94 @@ 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}
/>
{/* The publish step DOES refuse a blank/non-https URL -- loudly, but
into an error_log the customer never reads, leaving them with a
form that just doesn't work. Warn here instead. Deliberately a
warning only: not blanking the value and not blocking the
publish, since either would trade a loud server-side refusal for
a silently inert form. */}
{!isHttpsWebhookUrl(nodeProps.webhookUrl) && (
<p data-testid="webhook-url-warning" style={{ ...hintStyle, color: '#fbbf24' }}>
{nodeProps.webhookUrl
? 'This must be an absolute https:// URL — submissions to this form won\'t be delivered until it is.'
: 'Enter the https:// URL to POST submissions to — this form won\'t deliver anything until you do.'}
</p>
)}
<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
/* Remounts on every selection change, so a retained draft (and its
status banner) can never follow the customer to another form --
see the comment on WebhookSecretField. */
key={selectedId}
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,323 @@
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, isHttpsWebhookUrl } 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 Clear 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();
});
test('the Clear action says what it does: the stored key stays on the server', () => {
lastProps = contactFormProps({ destinationType: 'webhook', webhookSecretId: 'whs_42_deadbeefdeadbeef' });
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
// "Remove" would be a lie: nothing deletes the key file, and it keeps
// counting toward the per-site cap the customer eventually 429s against.
expect(testid('webhook-secret-remove')!.textContent).toBe('Clear');
expect(container.textContent).toContain('the stored key stays on the server');
});
});
describe('FormStylePanel: a retained secret draft never follows the selection to another form', () => {
/* GuidedStyles renders <FormStylePanel> with no key, so a selection change
re-renders rather than remounts. A failed store deliberately KEEPS the
draft, so without a remount the next form's field would open holding the
previous form's raw secret -- and the next blur would POST it and assign
the returned id to the wrong node. */
test('after a failed store on node A, selecting node B shows an empty field and no status', 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 nodeA = contactFormProps({ destinationType: 'webhook', webhookUrl: 'https://a.example/x' });
lastProps = nodeA;
render(<FormStylePanel selectedId="nodeA" nodeProps={nodeA} />);
const input = testid('webhook-secret-input') as HTMLInputElement;
setValue(input, RAW_SECRET);
await blur(input);
// Precondition: the draft really was retained on node A (otherwise this
// test would pass for the wrong reason).
expect((testid('webhook-secret-input') as HTMLInputElement).value).toBe(RAW_SECRET);
expect(testid('webhook-secret-error')).toBeTruthy();
const nodeB = contactFormProps({ destinationType: 'webhook', webhookUrl: 'https://b.example/y' });
lastProps = nodeB;
rerender(<FormStylePanel selectedId="nodeB" nodeProps={nodeB} />);
expect((testid('webhook-secret-input') as HTMLInputElement).value).toBe('');
expect(container.textContent).not.toContain(RAW_SECRET);
expect(testid('webhook-secret-error')).toBeNull();
});
test('a "Secret stored." banner does not follow the selection either', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true, status: 200,
json: async () => ({ success: true, secret_id: 'whs_42_deadbeefdeadbeef' }),
}));
lastProps = contactFormProps({ destinationType: 'webhook' });
render(<FormStylePanel selectedId="nodeA" nodeProps={lastProps} />);
const input = testid('webhook-secret-input') as HTMLInputElement;
setValue(input, RAW_SECRET);
await blur(input);
expect(testid('webhook-secret-saved')).toBeTruthy();
const nodeB = contactFormProps({ destinationType: 'webhook' });
lastProps = nodeB;
rerender(<FormStylePanel selectedId="nodeB" nodeProps={nodeB} />);
expect(testid('webhook-secret-saved')).toBeNull();
expect(testid('webhook-secret-status')).toBeNull();
});
});
describe('isHttpsWebhookUrl / the inline URL warning', () => {
test.each([
['https://hooks.example.com/x', true],
['https://hooks.example.com/x?a=1&b=2', true],
['http://hooks.example.com/x', false],
['hooks.example.com/x', false],
['/relative/path', false],
['', false],
[' ', false],
['https://hooks.example.com/x\nHost: evil', false],
['javascript:alert(1)', false],
])('%s -> %s', (value, expected) => {
expect(isHttpsWebhookUrl(value)).toBe(expected);
});
test('a blank URL warns that nothing will be delivered', () => {
lastProps = contactFormProps({ destinationType: 'webhook' });
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
expect(testid('webhook-url-warning')!.textContent).toContain("won't deliver anything");
});
test('a non-https URL warns without altering the value', () => {
lastProps = contactFormProps({ destinationType: 'webhook', webhookUrl: 'http://hooks.example.com/x' });
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
expect(testid('webhook-url-warning')!.textContent).toContain('absolute https:// URL');
// Warning only -- the panel must not blank or rewrite the prop, which would
// turn the publish step's loud refusal into a silently inert form.
expect(lastProps.webhookUrl).toBe('http://hooks.example.com/x');
expect((testid('webhook-url') as HTMLInputElement).value).toBe('http://hooks.example.com/x');
});
test('a valid https URL shows no warning', () => {
lastProps = contactFormProps({ destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x' });
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
expect(testid('webhook-url-warning')).toBeNull();
});
});
@@ -35,4 +35,10 @@ describe('HtmlStylePanel', () => {
expect(container.textContent).not.toContain('Padding');
expect(container.textContent).not.toContain('Border Radius');
});
test('renders the security advisory', () => {
render(<HtmlStylePanel selectedId="n1" nodeProps={{ code: '<p>x</p>', style: {} }} />);
expect(container.textContent).toContain('Use this block with care.');
expect(container.textContent).toContain('Scripts and event handlers are stripped');
});
});
@@ -25,6 +25,13 @@ export const HtmlStylePanel: React.FC<{ selectedId: string; nodeProps: Record<st
Style this block inside your own markup a wrapper set here would show
in the editor but not on the published page.
</p>
<p style={{ fontSize: 10, color: 'var(--color-text-dim)', lineHeight: 1.4, padding: '0 2px' }}>
<strong>Use this block with care.</strong> It renders your markup as-is
on the published site. Scripts and event handlers are stripped
automatically, but anything that survives forms, iframes, images
can still send data to wherever it points. Only paste code you
understand or trust.
</p>
</>
);
};
+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.' };
}
}