Files
zbatteandClaude Fable 5 0732dd662d fix(instanda): rate limiting, UTC timestamps, and close silent-failure paths
- 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>
2026-07-02 16:13:06 -06:00

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
}
}
}