$key, "secret" => $secret, "required" => (bool) $required, ]; } function verify_captcha(string $token): bool { $c = captcha_config(); if (!$c["required"]) { return true; } if (!$c["site_key"] || !$c["secret"] || !$token || strlen($token) > 8192) { return false; } $response = wp_remote_post( "https://www.google.com/recaptcha/api/siteverify", [ "timeout" => 10, "body" => ["secret" => $c["secret"], "response" => $token], ], ); if ( is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200 ) { return false; } $r = json_decode(wp_remote_retrieve_body($response), true); $host = strtolower(wp_parse_url(home_url("/"), PHP_URL_HOST) ?: ""); return is_array($r) && ($r["success"] ?? false) === true && in_array( strtolower($r["hostname"] ?? ""), array_unique([$host, preg_replace("/^www\./", "", $host)]), true, ) && ($r["action"] ?? "") === "enquiry" && is_numeric($r["score"] ?? null) && $r["score"] >= 0.5; } function form_open(array $form): bool { global $wpdb; $s = $form["settings"]; if ( $form["status"] !== "active" || (!empty($s["login_required"]) && !is_user_logged_in()) ) { return false; } $local = current_time("Y-m-d\TH:i"); if ( (!empty($s["open_at"]) && $local < $s["open_at"]) || (!empty($s["close_at"]) && $local >= $s["close_at"]) ) { return false; } if ( !empty($s["submit_limit"]) && (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE form_id=%d AND status NOT IN (\'trash\',\'spam\',\'anonymised\')', table("entries"), $form["id"], ), ) >= $s["submit_limit"] ) { return false; } return true; } function started_token(int $id): string { $time = time(); return $time . "." . hash_hmac("sha256", $id . ":" . $time, wp_salt("nonce")); } function started_valid(int $id, string $token): bool { $parts = explode(".", $token, 2); if (count($parts) !== 2 || !ctype_digit($parts[0])) { return false; } return hash_equals( hash_hmac("sha256", $id . ":" . $parts[0], wp_salt("nonce")), $parts[1], ) && time() - (int) $parts[0] >= 1 && time() - (int) $parts[0] <= DAY_IN_SECONDS; } function scalar($value): string { return is_scalar($value) ? (string) $value : ""; } function validate_answers(array $form, array $raw, array $uploads = []) { $answers = []; $errors = []; $accepted_files = []; $total_bytes = 0; foreach ($form["schema"]["pages"] as $page) { if (!matches($page["conditions"] ?? [], $answers)) { continue; } foreach ($page["fields"] as $field) { $id = $field["id"]; $type = $field["type"]; if ( in_array($type, ["heading", "paragraph", "divider"], true) || !matches($field["conditions"], $answers) ) { continue; } $value = $raw[$id] ?? ""; if ($type === "hidden") { $value = $field["default"]; } if ($type === "calculation") { $expression = preg_replace_callback( "/\{([a-z0-9_-]+)\}/", static fn($m) => "(" . (is_numeric($answers[$m[1]] ?? null) ? $answers[$m[1]] : "0") . ")", $field["formula"], ); $result = calculate($expression); if ($result === null) { $errors[$id] = "This calculation could not be completed. Check the values above."; continue; } $value = number_format($result, $field["decimals"], ".", ""); } elseif ($type === "file") { $files = $uploads[$id] ?? []; if (count($files) > $field["max_files"]) { $errors[$id] = "Choose no more than " . $field["max_files"] . " files."; continue; } $names = []; foreach ($files as $file) { if ( ($file["error"] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE ) { continue; } $checked = check_upload($file, $field); if (is_wp_error($checked)) { $errors[$id] = $checked->get_error_message(); break; } $total_bytes += $checked["bytes"]; $accepted_files[] = $checked + ["field_id" => $id]; $names[] = $checked["original_name"]; } $value = $names; } elseif ($type === "repeater") { if (!is_array($value)) { $value = []; } if (count($value) > $field["max_rows"]) { $errors[$id] = "Use no more than " . $field["max_rows"] . " rows."; continue; } $rows = []; foreach ($value as $row) { if (!is_array($row)) { continue; } $clean = []; foreach ($field["columns"] as $column) { $cell = sanitize_text_field( substr(scalar($row[$column["id"]] ?? ""), 0, 500), ); if ( $cell !== "" && (($column["type"] === "number" && !is_numeric($cell)) || ($column["type"] === "email" && !is_email($cell))) ) { $errors[$id] = "Check the values in this table."; } $clean[$column["id"]] = $cell; } if (implode("", $clean) !== "") { $rows[] = $clean; } } $value = $rows; } elseif (in_array($type, ["multiselect", "checkboxes"], true)) { $value = is_array($value) ? array_values( array_unique( array_map( static fn($v) => sanitize_text_field( scalar($v), ), array_slice($value, 0, 100), ), ), ) : []; if ( array_diff($value, array_column($field["options"], "value")) ) { $errors[$id] = "Choose from the available options."; } } else { if (is_array($value)) { $errors[$id] = "Check this answer."; continue; } $value = $type === "textarea" ? sanitize_textarea_field(scalar($value)) : sanitize_text_field(scalar($value)); if ( strlen($value) > 40000 || mb_strlen($value) > $field["max_length"] ) { $errors[$id] = "Use no more than " . $field["max_length"] . " characters."; } if ($value !== "" && mb_strlen($value) < $field["min_length"]) { $errors[$id] = "Use at least " . $field["min_length"] . " characters."; } if ($value !== "") { if ($type === "email" && !is_email($value)) { $errors[$id] = "Enter a valid email address."; } if ( $type === "url" && (!filter_var($value, FILTER_VALIDATE_URL) || !in_array( wp_parse_url($value, PHP_URL_SCHEME), ["https", "http"], true, )) ) { $errors[$id] = "Enter a full website address starting with https://."; } if ( $type === "tel" && !preg_match('/^[+()0-9\s.\-]{5,40}$/', $value) ) { $errors[$id] = "Enter a valid phone number."; } if (in_array($type, ["number", "range", "rating"], true)) { if ( !is_numeric($value) || !is_finite((float) $value) || abs((float) $value) > 1e12 ) { $errors[$id] = "Enter a valid number."; } elseif ( ($field["min"] !== null && $value < $field["min"]) || ($field["max"] !== null && $value > $field["max"]) || ($type === "rating" && ($value < 1 || $value > 5 || floor((float) $value) != $value)) ) { $errors[$id] = "Choose a value within the allowed range."; } elseif ( $type !== "rating" && abs( ($value - ($field["min"] ?? 0)) / $field["step"] - round( ($value - ($field["min"] ?? 0)) / $field["step"], ), ) > 1e-6 ) { $errors[$id] = "Choose a value in steps of " . $field["step"] . "."; } } if ($type === "select" || $type === "radio") { if ( !in_array( $value, array_column($field["options"], "value"), true, ) ) { $errors[$id] = "Choose from the available options."; } } if ( in_array($type, ["checkbox", "consent"], true) && $value !== "yes" ) { $errors[$id] = "Check this option or leave it unchecked."; } if ( in_array( $type, ["date", "time", "datetime-local"], true, ) ) { $format = [ "date" => "Y-m-d", "time" => "H:i", "datetime-local" => "Y-m-d\TH:i", ][$type]; $date = \DateTimeImmutable::createFromFormat( "!" . $format, $value, ); if (!$date || $date->format($format) !== $value) { $errors[$id] = "Enter a valid date or time."; } } } } if ($field["required"] && ($value === "" || $value === [])) { $errors[$id] = in_array($type, ["checkbox", "consent"], true) ? "Please confirm this before continuing." : "Please complete this field."; } $answers[$id] = $value; } } if ($total_bytes > 10 * 1024 * 1024) { return problem("The files together must be smaller than 10 MB.", 422); } if ($errors) { return problem("Please check the highlighted answers.", 422, [ "fields" => $errors, ]); } return ["answers" => $answers, "files" => $accepted_files]; } function check_upload(array $file, array $field) { if ( in_array( $file["error"] ?? -1, [UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE], true, ) ) { return problem( "This file exceeds the server upload limit. Please choose a smaller file.", ); } if ( ($file["error"] ?? -1) !== UPLOAD_ERR_OK || empty($file["tmp_name"]) || !is_uploaded_file($file["tmp_name"]) ) { return problem( "The file could not be uploaded. Please choose it again.", ); } $size = filesize($file["tmp_name"]); if (!$size || $size > $field["max_mb"] * 1024 * 1024) { return problem( "Each file must be smaller than " . $field["max_mb"] . " MB.", ); } $name = sanitize_file_name($file["name"]); $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); if (!in_array($ext, $field["extensions"], true)) { return problem( "Use one of these file types: " . implode(", ", $field["extensions"]) . ".", ); } $actual = (new \finfo(FILEINFO_MIME_TYPE))->file($file["tmp_name"]); $allowed = [ "pdf" => ["application/pdf"], "jpg" => ["image/jpeg"], "jpeg" => ["image/jpeg"], "png" => ["image/png"], "webp" => ["image/webp"], "txt" => ["text/plain"], "csv" => ["text/plain", "text/csv", "application/csv"], "docx" => [ "application/zip", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ], "xlsx" => [ "application/zip", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ], ]; if (!in_array($actual, $allowed[$ext] ?? [], true)) { return problem("The file contents do not match its type."); } if ( in_array($ext, ["jpg", "jpeg", "png", "webp"], true) && !@getimagesize($file["tmp_name"]) ) { return problem("Choose a valid image file."); } if (in_array($ext, ["docx", "xlsx"], true)) { if (!class_exists("ZipArchive")) { return problem("Please upload this document as a PDF."); } $zip = new \ZipArchive(); if ($zip->open($file["tmp_name"]) !== true) { return problem("Choose a valid document."); } $valid = $zip->numFiles < 1000 && $zip->locateName("[Content_Types].xml") !== false && $zip->locateName( $ext === "docx" ? "word/document.xml" : "xl/workbook.xml", ) !== false; $total = 0; for ($i = 0; $i < $zip->numFiles && $valid; $i++) { $s = $zip->statIndex($i); $total += $s["size"]; if ( str_contains(strtolower($s["name"]), "vbaproject") || $total > 30 * 1024 * 1024 ) { $valid = false; } } $zip->close(); if (!$valid) { return problem("Use a document without macros, or upload a PDF."); } $actual = $allowed[$ext][1]; } return [ "original_name" => $name, "mime_type" => $actual, "bytes" => $size, "content" => file_get_contents($file["tmp_name"]), ]; } function normalise_uploads(array $files): array { $result = []; $data = $files["uploads"] ?? []; foreach ((array) ($data["name"] ?? []) as $field => $names) { foreach ((array) $names as $i => $name) { $row = []; foreach (["name", "type", "tmp_name", "error", "size"] as $key) { $row[$key] = is_array($data[$key][$field] ?? null) ? $data[$key][$field][$i] ?? "" : $data[$key][$field] ?? ""; } $result[$field][] = $row; } } return $result; } function submit(int $id, array $body, array $files = []) { global $wpdb; // Status, validation and delivery settings are authoritative at submission. $form = get_form($id, true); if (!$form || !form_open($form)) { return problem( "This form is not accepting responses at the moment.", 403, ); } $nonce = sanitize_text_field(scalar($body["nf_nonce"] ?? "")); if (!wp_verify_nonce($nonce, "nf_submit_" . $id)) { return problem( "This form has expired. Refresh the page and try again.", 403, ["refresh" => true], ); } if (sanitize_text_field(scalar($body["website_url"] ?? "")) !== "") { return problem( "Your message could not be accepted. Please try again.", 422, ); } $started = sanitize_text_field(scalar($body["nf_started"] ?? "")); if (!started_valid($id, $started)) { return problem("Please refresh the form, then try again.", 422, [ "refresh" => true, ]); } $request_id = sanitize_text_field(scalar($body["nf_request"] ?? "")); if (!preg_match('/^[a-zA-Z0-9-]{20,80}$/D', $request_id)) { return problem("Refresh the form and try again.", 422); } $identity = hash_hmac( "sha256", $id . ":" . sanitize_text_field(wp_unslash($_SERVER["REMOTE_ADDR"] ?? "")), wp_salt("auth"), ); $rate_key = "feather_forms_rate_" . $identity; $rate = (array) get_transient($rate_key); $attempts = (int) ($rate["attempts"] ?? 0); $successes = (int) ($rate["successes"] ?? 0); if ( $attempts >= 20 || $successes >= max(1, min(30, (int) settings()["rate_limit"])) ) { return problem( "We have received several messages. Please wait ten minutes before trying again.", 429, ); } set_transient( $rate_key, ["attempts" => $attempts + 1, "successes" => $successes], 10 * MINUTE_IN_SECONDS, ); $raw_answers = $body["fields"] ?? []; if (isset($body["nf_answers"])) { if ( !is_string($body["nf_answers"]) || strlen($body["nf_answers"]) > 200000 ) { return problem( "This form contains too much text. Shorten your answers and try again.", 422, ); } $raw_answers = json_decode($body["nf_answers"], true); } if (!is_array($raw_answers)) { return problem( "The answers could not be read. Please refresh the form.", 422, ); } $validated = validate_answers( $form, $raw_answers, normalise_uploads($files), ); if (is_wp_error($validated)) { return $validated; } $answers = $validated["answers"]; $key = hash_hmac("sha256", $id . ":" . $request_id, wp_salt("auth")); $digest = hash( "sha256", json($answers) . implode( "", array_map( static fn($f) => hash("sha256", $f["content"]), $validated["files"], ), ), ); $existing = $wpdb->get_row( $wpdb->prepare( "SELECT reference,request_hash FROM %i WHERE submission_key=%s", table("entries"), $key, ), ARRAY_A, ); // A successful retry returns only the reference for that same unguessable request. if ($existing) { return hash_equals($existing["request_hash"], $digest) ? success_response($form, $existing["reference"]) : problem( "This message has already been sent. Refresh to start another.", 409, ); } $captcha_token = sanitize_text_field( scalar($body["g-recaptcha-response"] ?? ""), ); if (!verify_captcha($captcha_token)) { return problem( "The spam check could not be completed. Please try again.", 422, ["captcha" => true], ); } $contact = [ "contact_name" => "", "email" => "", "company" => "", "subject" => "", ]; $snapshot = []; foreach (flatten($form["schema"]) as $field) { if (!array_key_exists($field["id"], $answers)) { continue; } if ($field["role"]) { $contact[ $field["role"] === "name" ? "contact_name" : $field["role"] ] = substr( value_text($answers[$field["id"]]), 0, $field["role"] === "subject" ? 255 : 190, ); } $snapshot[] = [ "id" => $field["id"], "label" => $field["label"], "type" => $field["type"], "columns" => $field["columns"] ?? [], ]; } if ($contact["email"] && !is_email($contact["email"])) { $contact["email"] = ""; } $source = wp_parse_url(scalar($body["nf_source"] ?? ""), PHP_URL_PATH) ?: ""; $source = str_starts_with($source, "/") ? substr(sanitize_text_field($source), 0, 500) : ""; $reference = wp_generate_uuid4(); $wpdb->query("START TRANSACTION"); // Serialize entries for limited forms, then recheck inside the transaction. if (!empty($form["settings"]["submit_limit"])) { $wpdb->get_var( $wpdb->prepare( "SELECT id FROM %i WHERE id=%d FOR UPDATE", table("forms"), $id, ), ); if (!form_open($form)) { $wpdb->query("ROLLBACK"); return problem( "This form is not accepting responses at the moment.", 403, ); } } $ok = $wpdb->insert( table("entries"), $contact + [ "reference" => $reference, "form_id" => $id, "schema_json" => json($snapshot), "source_path" => $source, "submission_key" => $key, "request_hash" => $digest, "created_at" => now(), "updated_at" => now(), ], ); $entry_id = (int) $wpdb->insert_id; if (!$ok) { $wpdb->query("ROLLBACK"); $existing = $wpdb->get_row( $wpdb->prepare( "SELECT reference,request_hash FROM %i WHERE submission_key=%s", table("entries"), $key, ), ARRAY_A, ); return $existing && hash_equals($existing["request_hash"], $digest) ? success_response($form, $existing["reference"]) : problem( "Your message could not be saved. Please try again.", 500, ); } foreach ($snapshot as $i => $field) { $ok = $ok && (bool) $wpdb->insert(table("values"), [ "entry_id" => $entry_id, "field_id" => $field["id"], "field_type" => $field["type"], "label" => $field["label"], "value_json" => json($answers[$field["id"]]), "position" => $i, ]); } foreach ($validated["files"] as $file) { $ok = $ok && (bool) $wpdb->insert( table("files"), $file + ["entry_id" => $entry_id, "created_at" => now()], ); } if ($ok) { $ok = queue_delivery($entry_id, $form, $answers, $contact); } if ($ok) { $ok = record_change($entry_id, "upsert", true); } if (!$ok) { $wpdb->query("ROLLBACK"); return problem( "Your message could not be saved. Please try again.", 500, ); } $wpdb->query("COMMIT"); set_transient( $rate_key, ["attempts" => $attempts + 1, "successes" => $successes + 1], 10 * MINUTE_IN_SECONDS, ); run_jobs($entry_id); if (!wp_next_scheduled("noesis_forms_jobs")) { wp_schedule_single_event(time() + 60, "noesis_forms_jobs"); } do_action("noesis_forms_entry_created", $entry_id, $id); return success_response($form, $reference); } function success_response(array $form, string $reference): array { return [ "success" => true, "reference" => $reference, "title" => $form["settings"]["success_title"], "message" => $form["settings"]["success_message"], "redirect" => $form["settings"]["redirect_url"], ]; } function post_submit(): void { $id = isset($_POST["nf_form_id"]) && is_scalar($_POST["nf_form_id"]) ? absint(wp_unslash($_POST["nf_form_id"])) : 0; $nonce = isset($_POST["nf_nonce"]) && is_string($_POST["nf_nonce"]) ? sanitize_text_field(wp_unslash($_POST["nf_nonce"])) : ""; if ( !wp_verify_nonce( $nonce, "nf_submit_" . $id, ) ) { wp_die("Refresh the form and try again.", "", ["response" => 403]); } $body = [ "nf_nonce" => $nonce, "nf_started" => isset($_POST["nf_started"]) && is_string($_POST["nf_started"]) ? sanitize_text_field(wp_unslash($_POST["nf_started"])) : "", "nf_request" => isset($_POST["nf_request"]) && is_string($_POST["nf_request"]) ? sanitize_text_field(wp_unslash($_POST["nf_request"])) : "", "nf_source" => isset($_POST["nf_source"]) && is_string($_POST["nf_source"]) ? sanitize_text_field(wp_unslash($_POST["nf_source"])) : "", "website_url" => isset($_POST["website_url"]) && is_string($_POST["website_url"]) ? sanitize_text_field(wp_unslash($_POST["website_url"])) : "", "g-recaptcha-response" => isset( $_POST["g-recaptcha-response"], ) && is_string($_POST["g-recaptcha-response"]) ? sanitize_text_field( wp_unslash($_POST["g-recaptcha-response"]), ) : "", ]; if (isset($_POST["nf_answers"]) && is_string($_POST["nf_answers"])) { $body["nf_answers"] = sanitize_textarea_field( wp_unslash($_POST["nf_answers"]), ); } elseif (isset($_POST["fields"]) && is_array($_POST["fields"])) { $body["fields"] = map_deep( wp_unslash($_POST["fields"]), "sanitize_textarea_field", ); } $result = submit($id, $body, $_FILES); if (is_wp_error($result)) { wp_die( esc_html($result->get_error_message()) . '
', "Please check your message", [ "response" => (int) ($result->get_error_data()["status"] ?? 400), "back_link" => false, ], ); } if ($result["redirect"]) { wp_safe_redirect(home_url($result["redirect"]), 303); exit(); } wp_die( "" . esc_html($result["message"]) . '
', "Message received", ["response" => 200], ); } /** Accept a previously opened legacy form without ever creating another post. */ function legacy_submit(): void { $legacy_nonce = isset($_POST["noesis_nonce"]) && is_string($_POST["noesis_nonce"]) ? sanitize_text_field(wp_unslash($_POST["noesis_nonce"])) : ""; if ( !wp_verify_nonce( $legacy_nonce, "noesis_enquiry", ) ) { wp_die("Refresh the page and try again.", "", ["response" => 403]); } $id = (int) get_option("noesis_forms_contact_id"); $time = time() - 2; $body = [ "nf_nonce" => wp_create_nonce("nf_submit_" . $id), "nf_started" => $time . "." . hash_hmac("sha256", $id . ":" . $time, wp_salt("nonce")), "nf_request" => wp_generate_uuid4(), "nf_source" => "/contact/", "website_url" => isset($_POST["website_url"]) && is_string($_POST["website_url"]) ? sanitize_text_field(wp_unslash($_POST["website_url"])) : "", "g-recaptcha-response" => isset( $_POST["g-recaptcha-response"], ) && is_string($_POST["g-recaptcha-response"]) ? sanitize_text_field( wp_unslash($_POST["g-recaptcha-response"]), ) : "", "fields" => [ "name" => isset($_POST["name"]) && is_string($_POST["name"]) ? sanitize_text_field(wp_unslash($_POST["name"])) : "", "email" => isset($_POST["email"]) && is_string($_POST["email"]) ? sanitize_email(wp_unslash($_POST["email"])) : "", "company" => isset($_POST["company"]) && is_string($_POST["company"]) ? sanitize_text_field(wp_unslash($_POST["company"])) : "", "service" => isset($_POST["service"]) && is_string($_POST["service"]) ? sanitize_text_field(wp_unslash($_POST["service"])) : "", "message" => isset($_POST["message"]) && is_string($_POST["message"]) ? sanitize_textarea_field(wp_unslash($_POST["message"])) : "", "consent" => isset($_POST["consent"]) && is_string($_POST["consent"]) ? sanitize_text_field(wp_unslash($_POST["consent"])) : "", ], ]; $result = submit($id, $body); if (is_wp_error($result)) { wp_die( esc_html($result->get_error_message()), "Please check your message", [ "response" => (int) ($result->get_error_data()["status"] ?? 422), "back_link" => true, ], ); } if ($result["redirect"]) { wp_safe_redirect(home_url($result["redirect"]), 303); exit(); } wp_die( "" . esc_html($result["message"]) . '
', "Message received", ["response" => 200], ); }