widgets = $widgets; } public function register(): void { add_action('rest_api_init', [$this, 'routes']); } public function routes(): void { register_rest_route(self::NS, '/forms/basic', [ 'methods' => 'POST', 'callback' => [$this, 'submit_basic'], // Public on purpose: visitors submit the basic contact form. The form must exist in the saved document // of a live page (find_form(): published, or private and readable by the current user, never password // protected); the recipient comes from its saved settings; the name / email / message are validated and // sanitized; honeypot and a per-IP rate limit guard against abuse. It only sends one email. 'permission_callback' => '__return_true', ]); } /** @return WP_REST_Response|WP_Error */ public function submit_basic(WP_REST_Request $request) { $body = $request->get_json_params(); if (!is_array($body)) { $body = []; } $ip = $this->client_ip(); if ($this->is_rate_limited($ip)) { return new WP_Error('zadex_rate_limited', __('Too many messages. Please try again in a minute.', 'zadex'), ['status' => 429]); } // Honeypot: bots fill every field. Pretend it worked so they do not adapt. if (isset($body['website']) && is_scalar($body['website']) && trim((string) $body['website']) !== '') { return new WP_REST_Response(['sent' => true]); } $fields = $this->validate($body); if ($fields instanceof WP_Error) { return $fields; } $post_id = isset($body['postId']) && is_numeric($body['postId']) ? (int) $body['postId'] : 0; $element_id = isset($body['elementId']) && is_string($body['elementId']) ? $body['elementId'] : ''; $form = $this->find_form($post_id, $element_id); if ($form === null) { return new WP_Error('zadex_form_not_found', __('This form is not available.', 'zadex'), ['status' => 404]); } $this->bump_rate($ip); $settings = $this->form_settings($form); if (!$this->send($this->recipient($settings), $settings, $fields, $post_id)) { return new WP_Error('zadex_mail_failed', __('The message could not be sent. Please try again later.', 'zadex'), ['status' => 500]); } return new WP_REST_Response(['sent' => true, 'message' => (string) ($settings['success_message'] ?? '')]); } /** * @param array $body * @return array{name: string, email: string, message: string}|WP_Error */ public function validate(array $body) { $str = static fn($v): string => is_scalar($v) ? trim((string) $v) : ''; $name = sanitize_text_field($str($body['name'] ?? '')); $email = sanitize_email($str($body['email'] ?? '')); $message = function_exists('sanitize_textarea_field') ? sanitize_textarea_field($str($body['message'] ?? '')) : wp_strip_all_tags($str($body['message'] ?? '')); $errors = []; if ($name === '' || mb_strlen($name) > 200) { $errors['name'] = __('Please enter your name.', 'zadex'); } if ($email === '' || !is_email($email)) { $errors['email'] = __('Please enter a valid email address.', 'zadex'); } if ($message === '' || mb_strlen($message) > 5000) { $errors['message'] = __('Please enter a message.', 'zadex'); } if ($errors) { return new WP_Error('zadex_invalid_form', __('Please check the highlighted fields.', 'zadex'), ['status' => 400, 'fields' => $errors]); } return ['name' => $name, 'email' => $email, 'message' => $message]; } /** Finds the form-basic element in the saved document of a 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 null; } // Only forms on live content accept submissions: drafts, pending/trashed and password-protected // posts are not reachable by visitors, so they must not be usable as a mail relay either. $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); return $this->find_element($data['elements'] ?? [], $element_id); } /** Recipient from the saved element settings, falling back to the admin email. */ public function recipient(array $settings): string { $recipient = isset($settings['recipient']) && is_string($settings['recipient']) ? sanitize_email(trim($settings['recipient'])) : ''; if ($recipient !== '' && is_email($recipient)) { return $recipient; } return (string) get_option('admin_email', ''); } public function is_rate_limited(string $ip): bool { return (int) get_transient($this->rate_key($ip)) >= self::RATE_LIMIT; } private function bump_rate(string $ip): void { $key = $this->rate_key($ip); set_transient($key, (int) get_transient($key) + 1, self::RATE_WINDOW); } private function rate_key(string $ip): string { return 'zadex_form_' . 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'; return (string) apply_filters('zadex/forms/client_ip', $ip); } private function find_element(array $elements, string $id, int $depth = 0): ?array { if ($depth > 60) { return null; } foreach ($elements as $element) { if (!is_array($element)) { continue; } if (($element['id'] ?? '') === $id) { return ($element['type'] ?? '') === 'form-basic' ? $element : null; } if (!empty($element['children']) && is_array($element['children'])) { $found = $this->find_element($element['children'], $id, $depth + 1); if ($found !== null) { return $found; } } } return null; } /** Element settings merged with the manifest defaults. */ private function form_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-basic') : []; return array_merge($defaults, $settings); } /** Strips CR/LF/NUL (mail header injection) and collapses whitespace; used for every mail header part. */ public static function header_safe(string $value): string { $value = preg_replace('/[\r\n\x00]+/', ' ', $value) ?? ''; return trim(preg_replace('/\s{2,}/', ' ', $value) ?? ''); } /** @param array{name: string, email: string, message: string} $fields */ private function send(string $to, array $settings, array $fields, int $post_id): bool { if ($to === '') { return false; } $site = self::header_safe(function_exists('get_bloginfo') ? (string) get_bloginfo('name') : ''); $subject = self::header_safe((string) ($settings['subject'] ?? '')); if ($subject === '') { $subject = __('New message from the website', 'zadex'); } $subject = ($site !== '' ? '[' . $site . '] ' : '') . $subject; // Reply-To is built from visitor input: no line breaks (header injection) and no angle brackets in the name. $reply_name = self::header_safe(str_replace(['<', '>', '"'], '', $fields['name'])); $reply_email = self::header_safe($fields['email']); $lines = [ /* translators: %s: the visitor's name. */ sprintf(__('Name: %s', 'zadex'), $fields['name']), /* translators: %s: the visitor's email address. */ sprintf(__('Email: %s', 'zadex'), $fields['email']), '', $fields['message'], '', '--', /* translators: %s: URL of the page the form was sent from. */ sprintf(__('Sent from %s', 'zadex'), function_exists('get_permalink') ? (string) get_permalink($post_id) : '#' . $post_id), ]; $headers = [ 'Content-Type: text/plain; charset=UTF-8', 'Reply-To: ' . ($reply_name !== '' ? $reply_name . ' ' : '') . '<' . $reply_email . '>', ]; $mail = apply_filters('zadex/forms/basic/mail', ['to' => $to, 'subject' => $subject, 'message' => implode("\n", $lines), 'headers' => $headers], $fields, $settings, $post_id); return (bool) wp_mail($mail['to'], $mail['subject'], $mail['message'], $mail['headers']); } }