] (checkbox groups / multiple uploads: fields[][]), * zx_hp (honeypot), g-recaptcha-response (when reCAPTCHA is on) * * Response (always JSON of this shape, HTTP 200 / 400 / 404 / 429 / 500): * { ok: bool, message: string, errors: { fieldId: message }, redirect?: { url, delay }, popup?: { id, action } } * * Everything that matters is read from the SAVED document (post id + element id): field definitions, * validation rules, messages, email recipients, actions. The browser only sends values. * * Why there is no nonce: this is a public endpoint for anonymous visitors. A nonce would add no CSRF * protection (anyone can fetch a fresh one, and the request does not act with the visitor's privileges: * without a REST nonce WordPress treats cookie requests as logged out), while it WOULD break every form on * cached pages once the cached nonce expires (12-24 h). Abuse is handled by the honeypot, the per-IP rate * limit, optional reCAPTCHA, strict server-side validation and "published posts only". */ final class SubmitController { public const NS = 'zadex/v1'; public const RATE_LIMIT = 5; public const RATE_WINDOW = 60; public const HONEYPOT = 'zx_hp'; private ?WidgetRegistry $widgets; private ?Actions $actions; private ?Uploads $uploads; public function __construct(?WidgetRegistry $widgets = null, ?Actions $actions = null, ?Uploads $uploads = null) { $this->widgets = $widgets; $this->actions = $actions; $this->uploads = $uploads; } public function routes(): void { register_rest_route(self::NS, '/forms/submit', [ 'methods' => 'POST', 'callback' => [$this, 'submit'], // Public on purpose: visitors submit forms. The form is looked up in the saved document of a live page // (find_form(): published, or private and readable by the current user, never password protected); // its saved field list validates and sanitizes every value; honeypot, optional reCAPTCHA and a per-IP // rate limit guard against abuse. Nothing else can be changed through this route. 'permission_callback' => '__return_true', ]); } public function actions(): Actions { if ($this->actions === null) { $this->actions = new Actions(); } return $this->actions; } private function respond(bool $ok, string $message, int $status = 200, array $errors = [], array $extra = []): WP_REST_Response { return new WP_REST_Response(array_merge(['ok' => $ok, 'message' => $message, 'errors' => (object) $errors], $extra), $status); } public function submit(WP_REST_Request $request): WP_REST_Response { $post_id = is_numeric($request['postId'] ?? null) ? (int) $request['postId'] : 0; $element_id = is_string($request['elementId'] ?? null) ? (string) $request['elementId'] : ''; $form = $this->find_form($post_id, $element_id); if ($form === null) { return $this->respond(false, __('This form is not available.', 'zadex'), 404); } $settings = $this->settings($form); $msg = static fn(string $key, string $fallback): string => isset($settings[$key]) && is_string($settings[$key]) && trim($settings[$key]) !== '' ? $settings[$key] : $fallback; $error_message = $msg('error_message', __('Something went wrong. Please try again.', 'zadex')); $success_message = $msg('success_message', __('Thanks! Your submission has been received.', 'zadex')); $ip = $this->client_ip(); if ($this->is_rate_limited($ip)) { return $this->respond(false, __('Too many submissions. Please try again in a minute.', 'zadex'), 429); } // Honeypot: bots fill every field. Pretend it worked (so they do not adapt) and store nothing. $hp = $request[self::HONEYPOT] ?? ''; if (!array_key_exists('honeypot', $settings) || !empty($settings['honeypot'])) { if (is_scalar($hp) && trim((string) $hp) !== '') { return $this->respond(true, $success_message); } } $recaptcha = in_array($settings['recaptcha'] ?? 'off', ['v2', 'v3'], true) ? (string) $settings['recaptcha'] : 'off'; if ($recaptcha !== 'off') { $secret = $this->recaptcha_secret(); $token = $request['g-recaptcha-response'] ?? ''; if ($secret !== '' && !Recaptcha::verify(is_string($token) ? $token : '', $secret, $recaptcha, $ip)) { return $this->respond(false, __('The spam check failed. Please try again.', 'zadex'), 400); } } $fields = Fields::normalize($settings['fields'] ?? []); $values = $request['fields'] ?? []; $files = Uploads::normalize(method_exists($request, 'get_file_params') ? ($request->get_file_params()['fields'] ?? null) : null); $validator = new Validator([ 'required' => $msg('required_message', ''), 'invalid' => $msg('invalid_message', ''), ]); $result = $validator->validate($fields, is_array($values) ? $values : [], $files); $result = apply_filters('zadex/forms/validate', $result, $fields, $settings, $request); if (!empty($result['errors'])) { return $this->respond(false, __('Please check the highlighted fields.', 'zadex'), 400, $result['errors']); } $this->bump_rate($ip); $clean = $result['values']; if (!empty($result['files'])) { $stored = ($this->uploads ?? new Uploads())->store($result['files']); if ($stored instanceof \WP_Error) { $data = method_exists($stored, 'get_error_data') ? $stored->get_error_data() : ($stored->data ?? []); $field = is_array($data) && isset($data['field']) ? (string) $data['field'] : ''; return $this->respond(false, $error_message, 400, $field !== '' ? [$field => $stored->get_error_message()] : []); } foreach ($stored as $id => $urls) { $clean[$id] = $urls; } } $submission = new Submission( $element_id, isset($settings['form_name']) && is_string($settings['form_name']) ? sanitize_text_field($settings['form_name']) : '', $post_id, $fields, $clean, $settings, $this->meta($post_id, $ip) ); $ids = is_array($settings['actions'] ?? null) ? array_values(array_filter($settings['actions'], 'is_string')) : ['collect', 'email']; $run = $this->actions()->run($ids, $submission); $failed_all = $run['ran'] && count($run['errors']) === count($run['ran']) && $submission->id === 0; // Actions marked required (`_required`, e.g. an email-marketing or webhook action) turn their // failure into the form's generic error message; the provider's message is never shown. $required_failed = array_filter(array_keys($run['errors']), static fn($id): bool => !empty($settings[$id . '_required'])); do_action('zadex/forms/submitted', $submission, $run); if ($failed_all || $required_failed) { return $this->respond(false, $error_message, 500); } $extra = array_intersect_key($run['response'], array_flip(['redirect', 'popup'])); foreach ($run['response'] as $k => $v) { if (!isset($extra[$k]) && !in_array($k, ['ok', 'message', 'errors'], true)) { $extra[$k] = $v; } } return $this->respond(true, $success_message, 200, [], $extra); } /** Finds the form element in the saved document of a published builder post. */ public function find_form(int $post_id, string $element_id): ?array { if ($post_id <= 0 || !preg_match('/^[a-z][a-z0-9]{2,15}$/', $element_id) || !Document::is_builder($post_id)) { return apply_filters('zadex/forms/find_form', null, $post_id, $element_id); } $post = get_post($post_id); if (!$post || !in_array((string) ($post->post_status ?? ''), ['publish', 'private'], true) || (string) ($post->post_password ?? '') !== '') { return null; } if ((string) $post->post_status === 'private' && !current_user_can('read_post', $post_id)) { return null; } $data = Document::get_data($post_id); $found = self::find_element($data['elements'] ?? [], $element_id); return apply_filters('zadex/forms/find_form', $found, $post_id, $element_id); } /** @param array $elements */ public static function find_element(array $elements, string $id, int $depth = 0): ?array { if ($depth > 60) { return null; } foreach ($elements as $element) { if (!is_array($element) || !empty($element['hidden'])) { continue; } if (($element['id'] ?? '') === $id) { return ($element['type'] ?? '') === 'form' ? $element : null; } if (!empty($element['children']) && is_array($element['children'])) { $found = self::find_element($element['children'], $id, $depth + 1); if ($found !== null) { return $found; } } } return null; } /** Element settings merged with the manifest defaults. */ public function settings(array $form): array { $settings = is_array($form['settings'] ?? null) ? $form['settings'] : []; $registry = $this->widgets; if ($registry === null && class_exists(Plugin::class)) { try { $registry = Plugin::instance()->widgets; } catch (\Throwable $e) { $registry = null; } } $defaults = $registry ? $registry->defaults('form') : []; return array_merge($defaults, $settings); } private function recaptcha_secret(): string { $all = get_option(\Zadex\Settings::OPTION, []); return is_array($all) && isset($all['recaptcha_secret']) && is_string($all['recaptcha_secret']) ? trim($all['recaptcha_secret']) : ''; } /** @return array */ private function meta(int $post_id, string $ip): array { $ua = isset($_SERVER['HTTP_USER_AGENT']) && is_string($_SERVER['HTTP_USER_AGENT']) ? substr(sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])), 0, 255) : ''; $ref = isset($_SERVER['HTTP_REFERER']) && is_string($_SERVER['HTTP_REFERER']) ? substr(esc_url_raw(wp_unslash($_SERVER['HTTP_REFERER'])), 0, 500) : ''; return [ 'ip' => $ip, 'user_agent' => $ua, 'referrer' => $ref, 'user_id' => function_exists('get_current_user_id') ? (int) get_current_user_id() : 0, 'page' => function_exists('get_permalink') ? (string) get_permalink($post_id) : '', ]; } public function is_rate_limited(string $ip): bool { [$limit] = $this->rate(); return (int) get_transient($this->rate_key($ip)) >= $limit; } private function bump_rate(string $ip): void { [, $window] = $this->rate(); $key = $this->rate_key($ip); set_transient($key, (int) get_transient($key) + 1, $window); } /** @return array{0: int, 1: int} [max submissions, window seconds] (filter: zadex/forms/rate_limit) */ private function rate(): array { $rate = apply_filters('zadex/forms/rate_limit', [self::RATE_LIMIT, self::RATE_WINDOW]); return is_array($rate) && count($rate) === 2 ? [max(1, (int) $rate[0]), max(1, (int) $rate[1])] : [self::RATE_LIMIT, self::RATE_WINDOW]; } private function rate_key(string $ip): string { return 'zadex_fs_' . md5($ip); } private function client_ip(): string { $ip = isset($_SERVER['REMOTE_ADDR']) && is_string($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '0.0.0.0'; $ip = (string) apply_filters('zadex/forms/client_ip', $ip); return filter_var($ip, FILTER_VALIDATE_IP) ? $ip : '0.0.0.0'; } }