- F-06: throttle the public create per IP (20/h) and per email (5/h) before the paid API call and the PII write. - F-11: wrap the create path in try/catch - on an unexpected error fail closed for that submission (no quote-less entry), alert admins, keep the form working. - F-14: abort the StartQuote when the write-ahead insert fails (no unaudited call); fire the failure alert even when storage is disabled (build an in-memory tx). - F-10/F-17: treat a 2xx with a missing/empty quoteRef as ambiguous (possible_duplicate=1), not a hard failure - a quote may exist. - F-07: write created_at/delivered_at/event timestamps in UTC to match the gmdate() retention/dedupe cutoffs; display in site-local via get_date_from_gmt. - F-09: log (no silent return) when async record_feed_result cannot resolve a delivered transaction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
433 lines
20 KiB
PHP
433 lines
20 KiB
PHP
<?php
|
|
/**
|
|
* Submission - the no-loss / no-duplicate control core.
|
|
*
|
|
* The gated INSTANDA create lives in gform_validation (NOT process_feed) so that a
|
|
* failure can re-render the form with every value intact and save no entry. On
|
|
* success, validation passes, the entry saves, and the add-on's process_feed()
|
|
* records the result natively. gform_confirmation then redirects to the quote URL.
|
|
*
|
|
* Guards against duplicates: final-page-only + a once-per-submission lock +
|
|
* an internal dedupe key (INSTANDA has no server-side idempotency).
|
|
*
|
|
* @package ESP\Instanda
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace ESP\Instanda;
|
|
|
|
final class Submission {
|
|
|
|
private const LOCK_TTL = 60; // seconds
|
|
private const RESULT_TTL = 120;
|
|
private const STASH_TTL = 300;
|
|
public const DEDUPE_WINDOW = 1800; // 30 min - collapse identical resubmits to the existing quote
|
|
private const RL_IP_MAX = 20; // valid submissions per IP per hour
|
|
private const RL_EMAIL_MAX = 5; // valid submissions per email per hour
|
|
private const RL_WINDOW = 3600; // rate-limit window (seconds)
|
|
|
|
/** Wire var key => feed meta key (the mapped GF field id is stored under each). */
|
|
private const FIELD_KEYS = [
|
|
'role', 'vendor_type', 'performer_type', 'activity_type', 'location_state',
|
|
'max_attendance', 'event_start', 'event_end', 'customer_email',
|
|
];
|
|
|
|
private ?string $pending_message = null;
|
|
|
|
/** Request-scoped dedupe of the just-succeeded submission, for process_feed/entry-created. */
|
|
private static ?string $current_dedupe = null;
|
|
|
|
public static function current_dedupe(): ?string {
|
|
return self::$current_dedupe;
|
|
}
|
|
|
|
/* ----------------------------------------------------------- gform_validation */
|
|
|
|
/** @param array{is_valid:bool,form:array} $validation_result */
|
|
public function validate(array $validation_result): array {
|
|
$form = $validation_result['form'];
|
|
|
|
$feed = $this->active_feed($form);
|
|
if ($feed === null) {
|
|
return $validation_result; // GATE 1 - form has no INSTANDA feed
|
|
}
|
|
if (empty($validation_result['is_valid'])) {
|
|
return $validation_result; // GATE 2 - anti-spam / native validation already failed
|
|
}
|
|
if (!$this->is_last_page($form)) {
|
|
return $validation_result; // GATE 3 - only act on the final page
|
|
}
|
|
|
|
// Fail CLOSED for this one submission if the create path throws (no quote-less
|
|
// entry saved), keep the form working for everyone else, and surface the failure.
|
|
try {
|
|
return $this->process($validation_result, $form, $feed);
|
|
} catch (\Throwable $e) {
|
|
Guard::report($e);
|
|
$this->record_hard_failure($e);
|
|
$this->pending_message = 'We could not start your quote just now. Please try again.';
|
|
return $this->mark_form_invalid($validation_result);
|
|
}
|
|
}
|
|
|
|
/** The gated INSTANDA create - runs only after GATE 1-3 pass, inside validate()'s guard. */
|
|
private function process(array $validation_result, array $form, array $feed): array {
|
|
$token = $this->submission_token($form);
|
|
if (!$this->acquire_lock($token)) {
|
|
return $this->replay($token, $validation_result); // GATE 4 - within-request double-fire
|
|
}
|
|
|
|
$store = new Transaction_Store();
|
|
$values = $this->extract_values($form, $feed);
|
|
$dedupe = $this->content_dedupe_key($form, $values); // content-addressed, stable across requests
|
|
|
|
// Cross-request duplicate guard: an identical submission already delivered
|
|
// recently -> reuse its quote instead of creating a second one at INSTANDA.
|
|
$existing = $store->recent_delivered_by_dedupe($dedupe, self::DEDUPE_WINDOW);
|
|
if ($existing !== null) {
|
|
$this->stash_success($dedupe, (string) $existing->quote_ref, $existing->quote_url);
|
|
return $this->remember($token, $validation_result, true);
|
|
}
|
|
|
|
// Race guard: serialize concurrent identical submits (atomic where a persistent
|
|
// object cache is present, e.g. production; degrades to a transient lock locally).
|
|
if (!$this->acquire_content_lock($dedupe)) {
|
|
$this->pending_message = 'We are finalizing your quote - please wait a moment and try again.';
|
|
return $this->remember($token, $this->mark_form_invalid($validation_result), false);
|
|
}
|
|
|
|
$mapper = new Field_Mapper();
|
|
$tz = wp_timezone_string();
|
|
|
|
$errors = $mapper->validate($values, $tz);
|
|
if ($errors) {
|
|
$this->apply_field_errors($validation_result, $form, $feed, $errors);
|
|
return $this->remember($token, $validation_result, false);
|
|
}
|
|
|
|
// Abuse control: throttle the paid API + PII table per IP and per email.
|
|
$throttle = $this->rate_limit_message((string) ($values['customer_email'] ?? ''));
|
|
if ($throttle !== null) {
|
|
$this->pending_message = $throttle;
|
|
return $this->remember($token, $this->mark_form_invalid($validation_result), false);
|
|
}
|
|
|
|
$body = $mapper->to_payload($values, $tz);
|
|
|
|
// Write-ahead: persist BEFORE the HTTP call (redacted body carries no secrets).
|
|
$store_enabled = get_option('espinstanda_storage_enabled', '1') === '1';
|
|
$tx_id = 0;
|
|
if ($store_enabled) {
|
|
$tx = new Transaction();
|
|
$tx->form_id = (int) $form['id'];
|
|
$tx->environment = Config::environment()->value;
|
|
$tx->dedupe_key = $dedupe;
|
|
$tx->customer_email = (string) ($values['customer_email'] ?? '');
|
|
$tx->role_label = (string) ($values['role'] ?? '');
|
|
$tx->redacted_payload = (string) wp_json_encode($body);
|
|
$tx_id = $store->create_pending($tx);
|
|
|
|
// A failed write-ahead insert means we cannot audit the call - do not send it.
|
|
if ($tx_id === 0) {
|
|
Logger::error('Write-ahead insert failed; aborting StartQuote to avoid an unaudited call.');
|
|
$this->alert_failure($this->synth_tx($form, $values, $dedupe, 'Write-ahead insert failed - submission not sent.'), null);
|
|
Failure_Surface::bump();
|
|
$this->pending_message = $this->friendly_failure_message(true);
|
|
return $this->remember($token, $this->mark_form_invalid($validation_result), false);
|
|
}
|
|
}
|
|
|
|
$result = (new Api_Client())->start_quote($body, $dedupe);
|
|
$has_ref = $result->quote_ref !== null && $result->quote_ref !== '';
|
|
|
|
if ($result->ok && $has_ref) {
|
|
if ($tx_id) {
|
|
$store->mark_success($tx_id, (string) $result->quote_ref, $result->url_single_use, $result->http_status);
|
|
}
|
|
$this->stash_success($dedupe, (string) $result->quote_ref, $result->url_single_use);
|
|
return $this->remember($token, $validation_result, true);
|
|
}
|
|
|
|
// Failure / ambiguous - input preserved, no entry saved. A 2xx without a usable
|
|
// quoteRef is ambiguous too: INSTANDA may have created a quote we cannot see.
|
|
$ambiguous = $result->is_ambiguous || ($result->ok && !$has_ref);
|
|
if ($tx_id) {
|
|
if ($ambiguous) {
|
|
$store->mark_ambiguous($tx_id, $result->http_status, $result->error_string());
|
|
} else {
|
|
$store->mark_failed($tx_id, $result->http_status, $result->response_excerpt, $result->error_string());
|
|
}
|
|
$stored = $store->find($tx_id) ?? $this->synth_tx($form, $values, $dedupe, $result->error_string());
|
|
} else {
|
|
// Storage disabled - still surface the failure (no silent drop).
|
|
$stored = $this->synth_tx($form, $values, $dedupe, $result->error_string());
|
|
}
|
|
$this->alert_failure($stored, $result);
|
|
Failure_Surface::bump();
|
|
|
|
$this->pending_message = $this->friendly_failure_message($ambiguous);
|
|
return $this->remember($token, $this->mark_form_invalid($validation_result), false);
|
|
}
|
|
|
|
/** gform_validation_message filter - surface our form-level failure message. */
|
|
public function validation_message(string $message, array $form): string {
|
|
return $this->pending_message !== null ? esc_html($this->pending_message) : $message;
|
|
}
|
|
|
|
/* ----------------------------------------------------------- gform_confirmation */
|
|
|
|
public function confirmation(mixed $confirmation, array $form, array $entry, bool $ajax): mixed {
|
|
if ($this->active_feed($form) === null) {
|
|
return $confirmation;
|
|
}
|
|
$url = (string) gform_get_meta((int) $entry['id'], 'instanda_quote_url');
|
|
if ($url === '') {
|
|
$stash = $this->read_stash_for_entry($form);
|
|
$url = $stash['url'] ?? '';
|
|
}
|
|
if ($url !== '') {
|
|
return ['redirect' => $url];
|
|
}
|
|
return $confirmation; // defensive: fall through to the form's default confirmation
|
|
}
|
|
|
|
/* ----------------------------------------------------------- success stash */
|
|
|
|
/** Stash by dedupe so process_feed (possibly async) can record without re-calling the API. */
|
|
private function stash_success(string $dedupe, string $quote_ref, ?string $url): void {
|
|
self::$current_dedupe = $dedupe;
|
|
set_transient('espinstanda_stash_' . md5($dedupe), [
|
|
'quote_ref' => $quote_ref,
|
|
'url' => (string) $url,
|
|
], self::STASH_TTL);
|
|
}
|
|
|
|
public function read_stash(string $dedupe): ?array {
|
|
$v = get_transient('espinstanda_stash_' . md5($dedupe));
|
|
return is_array($v) ? $v : null;
|
|
}
|
|
|
|
private function read_stash_for_entry(array $form): array {
|
|
// Best-effort: confirmation runs in the same request as a fresh success, so the
|
|
// content dedupe key was recorded in the request-scoped static during validate().
|
|
$dedupe = self::$current_dedupe;
|
|
return $dedupe !== null ? ($this->read_stash($dedupe) ?? []) : [];
|
|
}
|
|
|
|
/* ----------------------------------------------------------- feed + values */
|
|
|
|
private function active_feed(array $form): ?array {
|
|
$addon = Instanda_AddOn::get_instance();
|
|
$feeds = $addon->get_feeds((int) $form['id']);
|
|
foreach ($feeds as $feed) {
|
|
if (!empty($feed['is_active'])) {
|
|
return $feed;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** @return array<string,string> intent key => submitted value */
|
|
private function extract_values(array $form, array $feed): array {
|
|
$meta = $feed['meta'] ?? [];
|
|
$values = [];
|
|
foreach (self::FIELD_KEYS as $key) {
|
|
$field_id = (string) ($meta[$key] ?? '');
|
|
$values[$key] = $field_id !== '' ? $this->submitted_value($form, $field_id) : '';
|
|
}
|
|
return $values;
|
|
}
|
|
|
|
/** Read a submitted field value via GF's API (never raw $_POST). @verify-on-site */
|
|
private function submitted_value(array $form, string $field_id): string {
|
|
$field = \GFFormsModel::get_field($form, $field_id);
|
|
if ($field === null) {
|
|
return '';
|
|
}
|
|
$value = \GFFormsModel::get_field_value($field);
|
|
return is_array($value) ? trim((string) reset($value)) : trim((string) $value);
|
|
}
|
|
|
|
/* ----------------------------------------------------------- duplicate guards */
|
|
|
|
private function is_last_page(array $form): bool {
|
|
// @verify-on-site: GFFormDisplay::get_max_page_number returns 0 for single-page forms.
|
|
if (!class_exists('\GFFormDisplay')) {
|
|
return true;
|
|
}
|
|
$target = (int) rgpost('gform_target_page_number_' . $form['id']);
|
|
$max = (int) \GFFormDisplay::get_max_page_number($form);
|
|
return $max === 0 || $target === 0;
|
|
}
|
|
|
|
private function submission_token(array $form): string {
|
|
$unique = '';
|
|
if (class_exists('\GFFormsModel') && method_exists('\GFFormsModel', 'get_form_unique_id')) {
|
|
$unique = (string) \GFFormsModel::get_form_unique_id((int) $form['id']); // @verify-on-site
|
|
}
|
|
if ($unique === '') {
|
|
// Fallback: stable across re-validation of one submit, unique per real submit.
|
|
$unique = md5(($form['id'] ?? '') . '|' . (string) rgpost('gform_submit') . '|' . (string) wp_date('YmdHi'));
|
|
}
|
|
return 'espinstanda_' . $unique;
|
|
}
|
|
|
|
/**
|
|
* Content-addressed dedupe key: two submissions of the same form with the same
|
|
* mapped field values produce the same key, so an identical re-POST or a
|
|
* retry-after-error collapses to the existing quote instead of creating a new one.
|
|
*/
|
|
private function content_dedupe_key(array $form, array $values): string {
|
|
$parts = [(string) ($form['id'] ?? '')];
|
|
foreach (self::FIELD_KEYS as $key) {
|
|
$v = trim((string) ($values[$key] ?? ''));
|
|
$parts[] = $key === 'customer_email' ? strtolower($v) : $v;
|
|
}
|
|
return 'dk_' . substr(hash('sha256', implode('|', $parts)), 0, 60);
|
|
}
|
|
|
|
/** Per-request lock: stops a single submission from double-firing within one request. */
|
|
private function acquire_lock(string $token): bool {
|
|
return $this->try_lock('lock_' . md5($token));
|
|
}
|
|
|
|
/** Brief atomic lock on the content key: serializes concurrent identical submits. */
|
|
private function acquire_content_lock(string $dedupe): bool {
|
|
return $this->try_lock('clock_' . md5($dedupe));
|
|
}
|
|
|
|
private function try_lock(string $key): bool {
|
|
if (function_exists('wp_cache_add') && wp_cache_add($key, 1, 'espinstanda', self::LOCK_TTL)) {
|
|
set_transient('espinstanda_' . $key, 1, self::LOCK_TTL);
|
|
return true;
|
|
}
|
|
if (get_transient('espinstanda_' . $key)) {
|
|
return false;
|
|
}
|
|
set_transient('espinstanda_' . $key, 1, self::LOCK_TTL);
|
|
return true;
|
|
}
|
|
|
|
private function remember(string $token, array $validation_result, bool $passed): array {
|
|
set_transient('espinstanda_res_' . md5($token), [
|
|
'passed' => $passed,
|
|
'message' => $this->pending_message,
|
|
], self::RESULT_TTL);
|
|
return $validation_result;
|
|
}
|
|
|
|
private function replay(string $token, array $validation_result): array {
|
|
$cached = get_transient('espinstanda_res_' . md5($token));
|
|
if (!is_array($cached)) {
|
|
return $validation_result; // unknown - let it pass through unchanged
|
|
}
|
|
if (empty($cached['passed'])) {
|
|
$this->pending_message = $cached['message'] ?? null;
|
|
return $this->mark_form_invalid($validation_result);
|
|
}
|
|
return $validation_result;
|
|
}
|
|
|
|
/* ----------------------------------------------------------- error rendering */
|
|
|
|
private function mark_form_invalid(array $validation_result): array {
|
|
$validation_result['is_valid'] = false;
|
|
return $validation_result;
|
|
}
|
|
|
|
/** Attach mapper error codes to their fields where possible; else a form-level message. */
|
|
private function apply_field_errors(array &$validation_result, array $form, array $feed, array $errors): void {
|
|
$meta = $feed['meta'] ?? [];
|
|
$map = [
|
|
'vendor_type_required' => ['vendor_type', 'Please choose a vendor type.'],
|
|
'performer_type_required' => ['performer_type', 'Please choose a performer type.'],
|
|
'activity_type_required' => ['activity_type', 'Please choose an activity type.'],
|
|
'location_required' => ['location_state', 'Please choose an event location.'],
|
|
'attendance_not_positive_integer' => ['max_attendance', 'Enter a positive number for daily attendance.'],
|
|
'start_required' => ['event_start', 'Please enter a valid start date.'],
|
|
'end_required' => ['event_end', 'Please enter a valid end date.'],
|
|
'end_before_start' => ['event_end', 'The end date must be on or after the start date.'],
|
|
'start_in_past' => ['event_start', 'The start date must be today or later.'],
|
|
'email_required' => ['customer_email', 'Please enter your email address.'],
|
|
'email_invalid' => ['customer_email', 'Please enter a valid email address.'],
|
|
'role_required' => ['role', 'Please choose an event role.'],
|
|
];
|
|
|
|
$validation_result['is_valid'] = false;
|
|
foreach ($validation_result['form']['fields'] as &$field) {
|
|
foreach ($errors as $code) {
|
|
if (!isset($map[$code])) {
|
|
continue;
|
|
}
|
|
[$intent, $message] = $map[$code];
|
|
$mapped_id = (string) ($meta[$intent] ?? '');
|
|
if ($mapped_id !== '' && (string) $field->id === $mapped_id) {
|
|
$field->failed_validation = true;
|
|
$field->validation_message = $message;
|
|
}
|
|
}
|
|
}
|
|
unset($field);
|
|
}
|
|
|
|
private function friendly_failure_message(bool $ambiguous): string {
|
|
if ($ambiguous) {
|
|
return 'We could not confirm your quote. Please try again in a moment - we have saved your details.';
|
|
}
|
|
return 'We could not start your quote just now. Please review your entries and try again.';
|
|
}
|
|
|
|
/* ----------------------------------------------------------- abuse control */
|
|
|
|
/** A friendly message when over the per-IP or per-email hourly limit, else null. */
|
|
private function rate_limit_message(string $email): ?string {
|
|
$ip = isset($_SERVER['REMOTE_ADDR']) ? (string) $_SERVER['REMOTE_ADDR'] : '';
|
|
if ($ip !== '' && $this->bump_counter('rl_ip_' . md5($ip)) > self::RL_IP_MAX) {
|
|
return 'Too many quote attempts from your connection. Please try again later.';
|
|
}
|
|
$email = strtolower(trim($email));
|
|
if ($email !== '' && $this->bump_counter('rl_email_' . md5($email)) > self::RL_EMAIL_MAX) {
|
|
return 'You have started several quotes recently. Please try again later or contact us for help.';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private function bump_counter(string $key): int {
|
|
$tkey = 'espinstanda_' . $key;
|
|
$n = (int) get_transient($tkey) + 1;
|
|
set_transient($tkey, $n, self::RL_WINDOW);
|
|
return $n;
|
|
}
|
|
|
|
/* ----------------------------------------------------------- failure surfacing */
|
|
|
|
private function alert_failure(Transaction $tx, ?Api_Result $result): void {
|
|
(new Notifier())->validation_failure_alert($tx, $result);
|
|
}
|
|
|
|
/** In-memory transaction used to alert when no row was stored (storage off / insert failed). */
|
|
private function synth_tx(array $form, array $values, string $dedupe, string $error): Transaction {
|
|
$tx = new Transaction();
|
|
$tx->form_id = (int) ($form['id'] ?? 0);
|
|
$tx->environment = Config::environment()->value;
|
|
$tx->dedupe_key = $dedupe;
|
|
$tx->customer_email = (string) ($values['customer_email'] ?? '');
|
|
$tx->role_label = (string) ($values['role'] ?? '');
|
|
$tx->error_detail = $error;
|
|
return $tx;
|
|
}
|
|
|
|
/** Best-effort admin alert when process() itself throws (F-11). Never throws. */
|
|
private function record_hard_failure(\Throwable $e): void {
|
|
try {
|
|
$tx = new Transaction();
|
|
$tx->environment = Config::environment()->value;
|
|
$tx->error_detail = 'Unexpected error during quote submission: ' . $e->getMessage();
|
|
(new Notifier())->validation_failure_alert($tx);
|
|
} catch (\Throwable $ignored) {
|
|
// never let the failure path itself throw
|
|
}
|
|
}
|
|
}
|