feat(site-builder): webhook destination controls on the contact form
ContactForm gains four craft props (destinationType, webhookUrl, webhookSecretId, webhookAuthMode), all present in craft.props defaults so FormStylePanel's `nodeProps.X !== undefined` gates actually render their controls. The controls live in FormStylePanel (RightPanel renders only GuidedStyles, so related.settings would be dead UI). relayFormWiring widens the marker to optionally carry type/url/secret/ authmode BETWEEN `id` and `recipient`, which is where FormRelayRewrite.php's parser looks. A marker with no type is byte-identical to what shipped before -- pinned by a test that diffs an explicit-email form against one with no destination props at all, since every already-published site depends on that shape continuing to provision an email endpoint. Every optional attribute value goes through one escaping site (markerAttr -> escapeAttr); type and authmode are additionally allowlisted, so a case-drifted "Bearer" reaches the relay as the exact literal it compares against instead of being silently downgraded to unsigned. The raw shared secret is never a prop: it is held in WebhookSecretField's local state, POSTed to /api/form-webhook-secret.php on blur, and only the returned opaque id is persisted. The field is write-only (set / replace / remove, never view) because the endpoint has no read route, and the endpoint's 429 cap message is surfaced verbatim so a customer can act on it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.' };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user