, values:array}|null the outcome of a failed submission on this request */ private static ?array $result = null; private static bool $styled = false; public function __construct(private Plugin $plugin) { } public function register(): void { add_shortcode(self::SHORTCODE, [$this, 'render']); add_action('template_redirect', [$this, 'handle'], 1); } // ------------------------------------------------------------------ submission public function handle(): void { if (sanitize_text_field(wp_unslash((string) ($_SERVER['REQUEST_METHOD'] ?? ''))) !== 'POST' || !$this->plugin->modules->isEnabled(AiFormsModule::ID)) { return; } // The nonce is read and verified first, before any other submitted value is used. Visitors are anonymous and WordPress gives all of them // the same nonce, so it is a limited freshness/intent check, not visitor authentication or a standalone CSRF defence; abuse is limited // by the other protections listed in the class comment. $nonce = isset($_POST[self::NONCE_FIELD]) && is_string($_POST[self::NONCE_FIELD]) ? sanitize_text_field(wp_unslash($_POST[self::NONCE_FIELD])) : ''; $nonceValid = $nonce !== '' && wp_verify_nonce($nonce, self::NONCE_ACTION) !== false; $formId = isset($_POST[self::ACTION_FIELD]) && is_string($_POST[self::ACTION_FIELD]) ? absint(wp_unslash($_POST[self::ACTION_FIELD])) : 0; if ($formId < 1) { return; // not a submission of one of our forms } if (!defined('DONOTCACHEPAGE')) { define('DONOTCACHEPAGE', true); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- the de-facto constant that page-cache plugins read } nocache_headers(); if (!$this->sameOrigin()) { status_header(403); wp_die(esc_html__('This request was not accepted.', 'aiwend'), '', ['response' => 403]); } // Field names carry a prefix on purpose: WordPress reads POSTed `name`, `s`, `p`, `page` and similar as query variables, which would turn a re-rendered page into a 404. // Each value is taken as a string only (an array or other shape becomes ''), unslashed, and validated by SubmissionValidator below. $text = static fn(string $key): string => Input::post($key); $input = [SubmissionValidator::HONEYPOT => $text(SubmissionValidator::HONEYPOT)]; foreach (['name', 'email', 'topic', 'message', 'ack'] as $k) { $input[$k] = $text('aiwend_' . $k); } if (!$nonceValid) { // A cached page can carry an old nonce: nothing is stored, the visitor's text is kept and the form comes back with a fresh one. $keep = []; foreach (['name', 'email', 'topic', 'message'] as $k) { $keep[$k] = mb_substr($input[$k], 0, SubmissionValidator::MESSAGE_MAX); } $keep['ack'] = $input['ack'] !== ''; self::$result = ['form' => $formId, 'status' => 'expired', 'errors' => [], 'values' => $keep]; status_header(403); return; } $form = AiFormsConfig::activeForm($formId); if ($form === null) { self::$result = ['form' => $formId, 'status' => 'unavailable', 'errors' => [], 'values' => []]; status_header(404); return; } $check = SubmissionValidator::validate($form, $input); if ($check['spam']) { $this->done($formId); // a bot is told it worked; nothing is stored } if ($check['values'] === null) { $keep = []; foreach (['name', 'email', 'topic', 'message'] as $k) { $keep[$k] = is_string($input[$k] ?? null) ? mb_substr((string) $input[$k], 0, SubmissionValidator::MESSAGE_MAX) : ''; } $keep['ack'] = !empty($input['ack']); self::$result = ['form' => $formId, 'status' => 'invalid', 'errors' => $check['errors'], 'values' => $keep]; status_header(422); return; } if (!$this->allowed($formId)) { self::$result = ['form' => $formId, 'status' => 'rate', 'errors' => [], 'values' => $check['values']]; status_header(429); header('Retry-After: 600'); return; } $site = get_current_blog_id(); $store = AiFormsConfig::store($this->plugin); $now = $this->plugin->clock->now(); if ($store->full($site, $now)) { self::$result = ['form' => $formId, 'status' => 'full', 'errors' => [], 'values' => $check['values']]; status_header(503); return; } try { $store->add($site, $formId, $check['values'], $now, AiFormsConfig::retentionDays()); // 'stored' and 'duplicate' look the same to the visitor } catch (\Throwable) { self::$result = ['form' => $formId, 'status' => 'error', 'errors' => [], 'values' => $check['values']]; status_header(500); return; } $this->done($formId); } /** The address of the privacy policy page chosen under Settings > Privacy, or '' when none is set, it is not published, or it is not a page. */ public static function privacyPolicyUrl(): string { if (!function_exists('get_privacy_policy_url')) { return ''; } $url = (string) get_privacy_policy_url(); return wp_http_validate_url($url) !== false ? $url : ''; } private function done(int $formId): never { $uri = sanitize_text_field(wp_unslash((string) ($_SERVER['REQUEST_URI'] ?? '/'))); wp_safe_redirect(self::successUrl(home_url(), $uri, $formId), 303); exit; } /** * Where the visitor goes after a stored submission: the very page the form was posted from, with a success marker and the form's anchor. * The scheme, host and port always come from this site's own address; only the request path and query are taken from the request, so the * target can never leave the site. The query string is kept because plain permalinks (?page_id=10, ?p=5) and previews identify the page by * it; dropping it sent visitors to the blog home. Our own marker is replaced, an absurdly long query is dropped, and a path that does not * start with a single slash falls back to the site root. */ public static function successUrl(string $homeUrl, string $requestUri, int $formId): string { // phpcs:disable WordPress.WP.AlternativeFunctions.parse_url_parse_url -- a pure function with no WordPress dependency (unit-testable); only the path and query of this request and this site's own address are read, never a host from the request $home = (array) parse_url($homeUrl); $origin = ($home['scheme'] ?? 'http') . '://' . ($home['host'] ?? '') . (isset($home['port']) ? ':' . $home['port'] : ''); $parts = (array) parse_url($requestUri); // phpcs:enable WordPress.WP.AlternativeFunctions.parse_url_parse_url $path = (string) ($parts['path'] ?? '/'); if ($path === '' || $path[0] !== '/' || str_starts_with($path, '//')) { $path = '/'; } $query = []; $raw = (string) ($parts['query'] ?? ''); if ($raw !== '' && strlen($raw) <= 1000) { parse_str($raw, $query); } unset($query['aiwend_form_ok']); $query['aiwend_form_ok'] = $formId; return $origin . $path . '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986) . '#aiwend-form-' . $formId; } /** When the browser sends Origin (or, failing that, Referer) it must name this site's host; when neither is sent the request is allowed. */ private function sameOrigin(): bool { $home = strtolower((string) wp_parse_url(home_url(), PHP_URL_HOST)); foreach (['HTTP_ORIGIN', 'HTTP_REFERER'] as $h) { $v = sanitize_text_field(wp_unslash((string) ($_SERVER[$h] ?? ''))); if ($v !== '' && $v !== 'null') { return strtolower((string) wp_parse_url($v, PHP_URL_HOST)) === $home; } if ($v === 'null') { return false; } } return true; } private function allowed(int $formId): bool { $client = ClientAddress::network(ClientAddress::resolve($_SERVER, array_values(array_filter((array) apply_filters('aiwend_trusted_proxies', []), 'is_string')))); $site = get_current_blog_id(); $l = $this->limiter(); $limit = static fn(string $filter, int $default): int => max(1, (int) apply_filters($filter, $default)); return $l->allow('aiwend_fc_' . substr(hash_hmac('sha256', $site . '|' . $client, wp_salt('nonce')), 0, 32), $limit('aiwend_forms_client_limit', 5), 600) && $l->allow('aiwend_ff_' . $site . '_' . $formId, $limit('aiwend_forms_form_limit', 100), 3600) && $l->allow('aiwend_fs_' . $site, $limit('aiwend_forms_site_limit', 300), 3600); } private function limiter(): FixedWindowLimiter { return new FixedWindowLimiter('get_transient', 'set_transient', null, static fn(string $k): bool => self::lock($k), static function (string $k): void { self::unlock($k); }); } private static function lock(string $key): bool { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- a named database lock cannot use the WordPress API or a cache; the statement is prepared $held = $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 2)', 'aiwend_' . substr(md5($key), 0, 32))); return $held === null || (int) $held === 1; } private static function unlock(string $key): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- releases the named lock taken in lock(); prepared, no cache applies $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', 'aiwend_' . substr(md5($key), 0, 32))); } // ------------------------------------------------------------------ rendering /** @param array|string $atts */ public function render($atts = []): string { $id = absint(is_array($atts) ? ($atts['id'] ?? 0) : 0); $form = AiFormsConfig::activeForm($id); if ($form === null) { return ''; } $r = self::$result !== null && self::$result['form'] === $id ? self::$result : null; $v = $r['values'] ?? []; $err = $r['errors'] ?? []; $domId = 'aiwend-form-' . $id; $days = AiFormsConfig::retentionDays(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display of a confirmation after a redirect; changes nothing $ok = absint($_GET['aiwend_form_ok'] ?? 0) === $id; $o = ''; if (!self::$styled) { self::$styled = true; wp_enqueue_style('aiwend-forms', plugins_url('assets/forms.css', AIWEND_FILE), [], AIWEND_VERSION); } $o .= '
'; if ($ok) { return $o . '
' . esc_html__('Thank you. Your message was received.', 'aiwend') . ' ' . esc_html__('It will be read by the site team. You will not receive an automatic reply from this form.', 'aiwend') . '
'; } $messages = [ 'invalid' => __('Please correct the fields marked below. Your message has not been sent.', 'aiwend'), 'rate' => __('Too many messages were sent from your connection. Please wait a few minutes and try again.', 'aiwend'), 'full' => __('This form cannot take new messages right now. Please try again later.', 'aiwend'), 'error' => __('Your message could not be saved. Please try again later.', 'aiwend'), 'unavailable' => __('This form is not available.', 'aiwend'), 'expired' => __('This page was open for too long, so your message was not sent. Your text is still here: please press Send message again.', 'aiwend'), ]; if ($r !== null && isset($messages[$r['status']])) { $o .= ''; } $fieldErr = static function (string $key, string $text) use ($err, $domId): string { return in_array($key, $err, true) ? '' . esc_html($text) . '' : ''; }; $describe = static fn(string $key): string => in_array($key, $err, true) ? ' aria-invalid="true" aria-describedby="' . esc_attr($domId . '-' . $key . '-err') . '"' : ''; $o .= '

' . esc_html($form['title']) . '

'; if ($form['intro'] !== '') { $o .= '

' . esc_html($form['intro']) . '

'; } $o .= '
'; $o .= '

' . esc_html__('Fields marked * are required.', 'aiwend') . '

'; if ($form['show_name']) { $o .= '

'; } $o .= '

' . $fieldErr('email', __('Enter a valid email address.', 'aiwend')) . '

'; if ($form['topics'] !== []) { $o .= '

' . $fieldErr('topic', __('Choose one of the topics.', 'aiwend')) . '

'; } /* translators: 1: minimum characters, 2: maximum characters */ $help = sprintf(__('Between %1$d and %2$d characters.', 'aiwend'), SubmissionValidator::MESSAGE_MIN, SubmissionValidator::MESSAGE_MAX); $o .= '

' . esc_html($help) . '' . $fieldErr('message', $help) . '

'; $o .= ''; /* translators: %d: number of days */ $notice = sprintf(__('How your message is used: this site stores it for up to %d days and authorised site administrators can read it. An administrator may choose to have an AI service analyse it. If they do, the topic and the text of your message (not your name or email address) may be sent to the AI provider this site has configured. No reply is sent automatically.', 'aiwend'), $days); $policy = self::privacyPolicyUrl(); $o .= '

' . esc_html($notice) . ($policy !== '' ? ' ' . esc_html__('Read the site privacy policy.', 'aiwend') . '' : '') . '

'; if ($form['ack']) { $o .= '

' . $fieldErr('ack', __('Please tick the box to send your message.', 'aiwend')) . '

'; } $o .= '

'; return $o; } }