anti-spam -> store -> notify. * * Over REST rather than admin-ajax: the URL is semantically clear, standard nonce verification applies, and a * failure returns structured JSON so the front end can attach errors to the specific field rather than * dropping "submission failed" at the top. * * @package Aumframe */ defined('ABSPATH') || exit; class Submit { /** Completing the form in fewer seconds than this means a bot. */ const MIN_SECONDS = 3; /** How many submissions one IP may make in ten minutes. */ const MAX_PER_IP = 5; public static function register_routes(): void { register_rest_route('aumframe/v1', '/form', [ 'methods' => 'POST', 'callback' => [self::class, 'handle'], 'permission_callback' => '__return_true', // A public form; security rests on the nonce plus anti-spam ]); } public static function handle(\WP_REST_Request $req) { $form_id = (int) $req->get_param('aumframe_form_id'); $form = get_post($form_id); if (!$form || $form->post_type !== Store::POST_TYPE || $form->post_status !== 'publish') { return new \WP_REST_Response(['ok' => false, 'message' => __('This form no longer exists.', 'aumframe')], 404); } if (!wp_verify_nonce(sanitize_text_field((string) $req->get_param('aumframe_form_nonce')), 'aumframe_form_' . $form_id)) { // The most common reason a nonce expires is a page left open for a long time (caching plugins do // it too). Say so in plain language and ask them to refresh, rather than "security check failed". return new \WP_REST_Response([ 'ok' => false, 'message' => __('This page has been open a while — please refresh and try again.', 'aumframe'), ], 403); } $ip = self::client_ip(); /* Anti-spam. All three are invisible to real people. */ // ① The honeypot: a CSS-hidden field was filled in if (trim((string) $req->get_param(Render::HONEYPOT)) !== '') { // Return "success" to a bot so it does not try another approach. A real person never reaches this // branch. return new \WP_REST_Response(['ok' => true, 'message' => self::success_message($form_id)], 200); } // ② The time trap: a human cannot complete a form in three seconds $ts = (int) $req->get_param('aumframe_ts'); if ($ts > 0 && (time() - $ts) < self::MIN_SECONDS) { return new \WP_REST_Response(['ok' => true, 'message' => self::success_message($form_id)], 200); } // ③ Rate limiting if (Inbox::recent_count($ip) >= self::MAX_PER_IP) { return new \WP_REST_Response([ 'ok' => false, 'message' => __('Too many submissions. Please try again later.', 'aumframe'), ], 429); } /* Field-by-field validation */ $fields = Store::fields($form_id); $data = []; $errors = []; foreach ($fields as $f) { if (($f['type'] ?? '') === 'hidden') { // Multi-line stays multi-line: the enquiry sheet writes one item per line into its hidden field $data[$f['id']] = sanitize_textarea_field((string) $req->get_param($f['id'])); continue; } $res = Fields::accept($f, $req->get_param($f['id'])); if (!$res['ok']) { $errors[$f['id']] = $res['error']; continue; } $data[$f['id']] = $res['value']; } if ($errors) { return new \WP_REST_Response([ 'ok' => false, 'message' => __('A few fields need checking.', 'aumframe'), 'errors' => $errors, ], 422); } /* Store first, so a mail failure never loses the data */ $ctx = [ 'source_id' => (int) $req->get_param('aumframe_source_id'), 'source_url' => esc_url_raw((string) $req->get_param('aumframe_source_url')), 'ip' => $ip, 'user_agent' => substr(sanitize_text_field(wp_unslash((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''))), 0, 255), ]; $entry_id = Inbox::insert($form_id, $data, $ctx); /* Notify (a failure is logged only and does not affect the user seeing success) */ self::notify($form_id, $fields, $data, $ctx, $entry_id); $settings = Store::settings($form_id); return new \WP_REST_Response([ 'ok' => true, 'message' => self::success_message($form_id), 'redirect' => $settings['success_redirect'] ?: '', 'entry' => $entry_id, ], 200); } private static function success_message(int $form_id): string { $s = Store::settings($form_id); return $s['success_message'] ?: __('Thanks — we’ll be in touch shortly.', 'aumframe'); } /** * Email notification. * * From is always this site's domain, and the submitter's address goes in Reply-To. * Setting From to the submitter's address (which Contact Form 7 encourages by default) makes the sending * domain disagree with SPF and DMARC, and the mail goes straight to spam or is rejected outright — the * number one reason contact forms never arrive. Reply-To keeps "press reply" going back to the client while * the sending identity stays legitimate. */ private static function notify(int $form_id, array $fields, array $data, array $ctx, int $entry_id): void { $settings = Store::settings($form_id); $to = Store::recipient($form_id); $site = wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES); $title = get_the_title($form_id); /* translators: 1: Site name. 2: Form title, or "form submission" when the form has no title. */ $subject = $settings['subject'] ?: sprintf(__('[%1$s] New %2$s', 'aumframe'), $site, $title ?: __('form submission', 'aumframe')); $body = $settings['body_template'] !== '' ? self::apply_template($settings['body_template'], $fields, $data) : self::auto_body($fields, $data, $ctx, $entry_id); $domain = wp_parse_url(home_url(), PHP_URL_HOST) ?: 'localhost'; $from = 'wordpress@' . preg_replace('/^www\./', '', $domain); $headers = [ 'Content-Type: text/plain; charset=UTF-8', sprintf('From: %s <%s>', $site, $from), ]; // Find the submitter's address and attach it as Reply-To $reply = self::submitter_email($fields, $data); if ($reply) { $headers[] = 'Reply-To: ' . $reply; } if (!wp_mail($to, $subject, $body, $headers)) { if (defined('WP_DEBUG') && WP_DEBUG) { error_log("[aumframe] notification email for form {$form_id} failed to send (entry #{$entry_id} is stored)"); } } // The auto-reply if (!empty($settings['autoreply']) && $reply) { /* translators: %s: Site name. */ $ar_subject = $settings['autoreply_subject'] ?: sprintf(__('Thanks for getting in touch · %s', 'aumframe'), $site); $ar_body = $settings['autoreply_body'] !== '' ? self::apply_template($settings['autoreply_body'], $fields, $data) : __('We’ve received your message and will reply soon.', 'aumframe'); wp_mail($reply, $ar_subject, $ar_body, $headers); } } /** * The auto-generated mail body. * * Generated from the field list, so a new field appears in the mail automatically — while a hand-written * template that misses a new field reports nothing and that information is simply never received. * Which is exactly why a template is not the default. */ private static function auto_body(array $fields, array $data, array $ctx, int $entry_id): string { $lines = []; foreach ($fields as $f) { if (Fields::is_silent($f)) { continue; } $val = Fields::display_value($f, $data[$f['id']] ?? ''); // A multi-line value (the enquiry list) starts on its own line so the items read as a list $lines[] = ($f['label'] ?: $f['id']) . ':' . ($val === '' ? '—' : (strpos($val, "\n") !== false ? "\n" . $val : $val)); } $lines[] = ''; $lines[] = str_repeat('—', 20); if (!empty($ctx['source_id'])) { $lines[] = __('From page: ', 'aumframe') . get_the_title((int) $ctx['source_id']); } if (!empty($ctx['source_url'])) { $lines[] = __('Page URL: ', 'aumframe') . $ctx['source_url']; } $lines[] = __('Submitted: ', 'aumframe') . current_time('Y-m-d H:i'); $lines[] = __('Record: ', 'aumframe') . '#' . $entry_id; return implode("\n", $lines); } /** Replace {field id} in a template with its value. */ private static function apply_template(string $tpl, array $fields, array $data): string { foreach ($fields as $f) { $tpl = str_replace( '{' . $f['id'] . '}', Fields::display_value($f, $data[$f['id']] ?? ''), $tpl ); } return $tpl; } private static function submitter_email(array $fields, array $data): string { foreach ($fields as $f) { if (($f['type'] ?? '') === 'email' && !empty($data[$f['id']])) { return (string) $data[$f['id']]; } } return ''; } private static function client_ip(): string { // Trust REMOTE_ADDR only. X-Forwarded-For can be forged by the client, and rate-limiting on it lets an // attacker decide whether they are limited. return isset($_SERVER['REMOTE_ADDR']) ? (string) filter_var(wp_unslash($_SERVER['REMOTE_ADDR']), FILTER_VALIDATE_IP) : ''; } }