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>
65 lines
2.9 KiB
TypeScript
65 lines
2.9 KiB
TypeScript
/* ---------- 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.' };
|
|
}
|
|
}
|