Files
site-builder/craft/src/utils/form-webhook-secret.ts
T
shadowdaoandClaude Opus 5 d1c57db967 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>
2026-08-10 15:45:05 -07:00

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.' };
}
}