$form["name"], "{name}" => $contact["contact_name"], "{email}" => $contact["email"], "{site_name}" => get_bloginfo("name"), ]; foreach ($answers as $id => $value) { $map["{" . $id . "}"] = value_text($value); } return strtr($text, $map); } function hubspot_property_value($value): string { if (is_array($value)) { return implode( ";", array_values( array_filter( array_map( static fn($item) => sanitize_text_field(scalar($item)), $value, ), static fn($item) => $item !== "", ), ), ); } return sanitize_textarea_field(scalar($value)); } function hubspot_payload( array $form, array $answers, array $contact, ): ?array { $s = $form["settings"]; if (empty($s["hubspot_enabled"])) { return null; } $email = sanitize_email( scalar($answers[$s["hubspot_email_field"] ?? ""] ?? ""), ); if (!$email) { return null; } $properties = ["email" => $email]; $map = [ "firstname" => "hubspot_firstname_field", "lastname" => "hubspot_lastname_field", "phone" => "hubspot_phone_field", "company" => "hubspot_company_field", "website" => "hubspot_website_field", "jobtitle" => "hubspot_jobtitle_field", ]; foreach ($map as $property => $setting) { $field = $s[$setting] ?? ""; if ($field && array_key_exists($field, $answers)) { $value = hubspot_property_value($answers[$field]); if ($value !== "") { $properties[$property] = $value; } } } if ( empty($properties["firstname"]) && empty($properties["lastname"]) && !empty($contact["contact_name"]) ) { $parts = preg_split('/\s+/', trim($contact["contact_name"]), 2); $properties["firstname"] = sanitize_text_field($parts[0] ?? ""); if (!empty($parts[1])) { $properties["lastname"] = sanitize_text_field($parts[1]); } } if (empty($properties["company"]) && !empty($contact["company"])) { $properties["company"] = sanitize_text_field($contact["company"]); } return [ "inputs" => [ [ "id" => $email, "idProperty" => "email", "properties" => array_filter( $properties, static fn($value) => $value !== "", ), ], ], ]; } function deliver_hubspot(array $payload): array { $token = settings()["hubspot_token"] ?? ""; if (!$token) { return [ "success" => false, "retryable" => false, "retry_after" => 0, "error" => "HubSpot is not connected in Feather Forms settings.", ]; } $response = wp_remote_post( "https://api.hubapi.com/crm/v3/objects/contacts/batch/upsert", [ "timeout" => 10, "redirection" => 0, "headers" => [ "Authorization" => "Bearer " . $token, "Content-Type" => "application/json", ], "body" => json($payload), "limit_response_size" => 8192, ], ); if (is_wp_error($response)) { return [ "success" => false, "retryable" => true, "retry_after" => 0, "error" => "HubSpot could not be reached.", ]; } $code = wp_remote_retrieve_response_code($response); $body = json_decode(wp_remote_retrieve_body($response), true); $success = in_array($code, [200, 201, 207], true) && !empty($body["results"]) && empty($body["errors"]); $retry_after = (int) wp_remote_retrieve_header($response, "retry-after"); return [ "success" => $success, "retryable" => !$success && (in_array($code, [408, 425, 429], true) || $code >= 500), "retry_after" => max(0, min(HOUR_IN_SECONDS, $retry_after)), "error" => $success ? "" : "HubSpot returned HTTP " . $code . ".", ]; } function queue_delivery( int $id, array $form, array $answers, array $contact, ): bool { global $wpdb; $s = $form["settings"]; $jobs = []; if ($s["notify"]) { $to = $s["recipients"]; foreach ($s["routes"] as $route) { if ( in_array( $route["value"], (array) ($answers[$route["field"]] ?? []), true, ) ) { $to = $route["recipient"]; break; } } $jobs["notification"] = email_payload( $form, $answers, $contact, "notification", email_context($id), $to, ); } $visitor_email = !empty($s["confirmation_email_field"]) ? scalar($answers[$s["confirmation_email_field"]] ?? "") : $contact["email"]; if ($s["autoresponder"] && is_email($visitor_email)) { $jobs["confirmation"] = email_payload( $form, $answers, array_replace($contact, ["email" => $visitor_email]), "confirmation", email_context($id), $visitor_email, ); } if ($s["webhook_url"]) { $jobs["webhook"] = [ "url" => $s["webhook_url"], "secret" => $s["webhook_secret"], "body" => json([ "event" => "form.submitted", "entry_id" => $id, "form_id" => $form["id"], "form_name" => $form["name"], "submitted_at" => now(), "answers" => $answers, ]), ]; } $hubspot = hubspot_payload($form, $answers, $contact); if ($hubspot) { $jobs["hubspot"] = $hubspot; } foreach ($jobs as $kind => $payload) { if ( !$wpdb->insert(table("jobs"), [ "entry_id" => $id, "kind" => $kind, "payload_json" => json($payload), "due_at" => now(), "created_at" => now(), "updated_at" => now(), ]) ) { return false; } } if (!$s["notify"]) { $wpdb->update( table("entries"), ["notification_status" => "disabled"], ["id" => $id], ); } return true; } function run_jobs(int $entry_id = 0): void { global $wpdb; $where = "status='pending' AND due_at<=%s"; $args = [now()]; if ($entry_id) { $where .= " AND entry_id=%d"; $args[] = $entry_id; } $jobs = $entry_id ? $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE status='pending' AND due_at<=%s AND entry_id=%d ORDER BY id LIMIT 10", table("jobs"), now(), $entry_id, ), ARRAY_A, ) : $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE status='pending' AND due_at<=%s ORDER BY id LIMIT 10", table("jobs"), now(), ), ARRAY_A, ); foreach ($jobs as $job) { $locked = $wpdb->query( $wpdb->prepare( 'UPDATE %i SET status=\'processing\',updated_at=%s WHERE id=%d AND status=\'pending\'', table("jobs"), now(), $job["id"], ), ); if (!$locked) { continue; } $payload = json_decode($job["payload_json"], true); $success = false; $error = ""; $retryable = true; $retry_after = 0; if ($job["kind"] === "hubspot") { $result = deliver_hubspot((array) $payload); $success = $result["success"]; $retryable = $result["retryable"]; $retry_after = $result["retry_after"]; $error = $result["error"]; } elseif ($job["kind"] === "webhook") { $headers = [ "Content-Type" => "application/json", "X-Noesis-Event" => "form.submitted", "X-Noesis-Delivery" => (string) $job["id"], ]; if (!empty($payload["secret"])) { $headers["X-Noesis-Signature"] = "sha256=" . hash_hmac("sha256", $payload["body"], $payload["secret"]); } // Safe HTTP prevents local-network requests. Redirects cannot move data to another host. $response = wp_safe_remote_post($payload["url"], [ "timeout" => 8, "redirection" => 0, "headers" => $headers, "body" => $payload["body"], "limit_response_size" => 1024, ]); $success = !is_wp_error($response) && wp_remote_retrieve_response_code($response) >= 200 && wp_remote_retrieve_response_code($response) < 300; $error = $success ? "" : (is_wp_error($response) ? "The webhook could not be reached." : "Webhook returned HTTP " . wp_remote_retrieve_response_code($response) . "."); } else { $success = send_email_payload($payload); $error = $success ? "" : "WordPress could not hand this message to the mail server."; } $attempts = (int) $job["attempts"] + 1; $status = $success ? "sent" : (!$retryable || $attempts >= 4 ? "failed" : "pending"); $wpdb->update( table("jobs"), [ "status" => $status, "attempts" => $attempts, "last_error" => $error, "due_at" => gmdate( "Y-m-d H:i:s", time() + ($retry_after ?: min(HOUR_IN_SECONDS, 60 * 3 ** $attempts)), ), "updated_at" => now(), ], ["id" => $job["id"]], ); if ($job["kind"] === "notification") { $wpdb->update( table("entries"), [ "notification_status" => $success ? "sent" : ($status === "failed" ? "failed" : "pending"), "updated_at" => now(), ], ["id" => $job["entry_id"]], ); record_change((int) $job["entry_id"]); } } if ( $wpdb->get_var( $wpdb->prepare( 'SELECT id FROM %i WHERE status=\'pending\' LIMIT 1', table("jobs"), ), ) ) { wp_schedule_single_event( time() + 3 * MINUTE_IN_SECONDS, "noesis_forms_jobs", ); } } function entry_where(array $query): array { global $wpdb; $where = ["1=1"]; $args = []; if (!empty($query["form"])) { $where[] = "e.form_id=%d"; $args[] = absint($query["form"]); } $status = $query["status"] ?? ""; if ( in_array( $status, [ "new", "read", "in_progress", "closed", "spam", "trash", "anonymised", ], true, ) ) { $where[] = "e.status=%s"; $args[] = $status; } elseif ($status === "starred") { $where[] = "e.starred=1"; $where[] = "e.status NOT IN ('trash','spam','anonymised')"; } else { $where[] = "e.status NOT IN ('trash','spam','anonymised')"; } $search = substr(sanitize_text_field($query["search"] ?? ""), 0, 120); if ($search !== "") { $like = "%" . $wpdb->esc_like($search) . "%"; $where[] = "(e.contact_name LIKE %s OR e.email LIKE %s OR e.company LIKE %s OR e.subject LIKE %s OR EXISTS (SELECT 1 FROM %i v WHERE v.entry_id=e.id AND v.value_json LIKE %s))"; array_push($args, $like, $like, $like, $like, table("values"), $like); } foreach (["from" => ">=", "to" => "<="] as $key => $op) { if ( !empty($query[$key]) && preg_match('/^\d{4}-\d{2}-\d{2}$/D', $query[$key]) ) { $where[] = "e.created_at$op%s"; $args[] = $query[$key] . ($key === "from" ? " 00:00:00" : " 23:59:59"); } } return ["sql" => implode(" AND ", $where), "args" => $args]; } function export_csv(): void { if (!allowed("export_noesis_entries")) { wp_die("You cannot export enquiries.", "", ["response" => 403]); } check_admin_referer("noesis_forms_csv"); global $wpdb; $query = array_map("sanitize_text_field", wp_unslash($_GET)); $where = entry_where($query); $count_sql = "SELECT COUNT(*) FROM %i e WHERE " . $where["sql"]; // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- Closed query fragments only; identifiers use %i, and every filter, ID and pagination value is passed separately to prepare(). $count = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Closed query fragments only; identifiers use %i, and every filter, ID and pagination value is passed separately to prepare(). $wpdb->prepare($count_sql, table("entries"), ...$where["args"]), ); if ($count > 50000) { wp_die("Narrow the date range to export fewer than 50,000 enquiries."); } $safe = static function ($value): string { $value = (string) $value; return preg_match('/^[\s\x00-\x1f]*[=+@-]/u', $value) ? "'" . $value : $value; }; $column_sql = "SELECT e.form_id,v.field_id,MAX(v.id) AS last_id FROM %i v INNER JOIN %i e ON e.id=v.entry_id WHERE " . $where["sql"] . " GROUP BY e.form_id,v.field_id LIMIT 501"; // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- Closed query fragments only; identifiers use %i, and every filter, ID and pagination value is passed separately to prepare(). $columns = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Closed query fragments only; identifiers use %i, and every filter, ID and pagination value is passed separately to prepare(). $column_sql, table("values"), table("entries"), ...$where["args"], ), ARRAY_A, ); if (count($columns) > 500) { wp_die("Choose a single form to export fewer than 500 answer columns."); } nocache_headers(); header("Content-Type: text/csv; charset=UTF-8"); header( 'Content-Disposition: attachment; filename="enquiries-' . gmdate("Y-m-d") . '.csv"', ); header("X-Content-Type-Options: nosniff"); $output = fopen("php://output", "w"); echo "\xEF\xBB\xBF"; $forms = array_column(form_catalog(), "name", "id"); // Answer labels must be current. Batch them instead of issuing one extra // query for each of up to 500 export columns. $labels = []; if ($columns) { $labels = $wpdb->get_results( $wpdb->prepare( "SELECT id,label FROM %i WHERE id IN (" . implode(",", array_fill(0, count($columns), "%d")) . ")", ...array_merge( [table("values")], array_map("intval", array_column($columns, "last_id")), ), ), OBJECT_K, ); } $headers = [ "Reference", "Form", "Received (UTC)", "Status", "Name", "Email", "Company", "Subject", "Source", ]; foreach ($columns as $column) { $headers[] = ($forms[$column["form_id"]] ?? "Form") . " ยท " . ($labels[$column["last_id"]]->label ?? ""); } fputcsv($output, array_map($safe, $headers), ",", '"', ""); for ($offset = 0; $offset < $count; $offset += 200) { $sql = "SELECT e.*, f.name AS form_name FROM %i e LEFT JOIN %i f ON f.id=e.form_id WHERE " . $where["sql"] . " ORDER BY e.id DESC LIMIT 200 OFFSET %d"; // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- Closed query fragments only; identifiers use %i, and every filter, ID and pagination value is passed separately to prepare(). $rows = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Closed query fragments only; identifiers use %i, and every filter, ID and pagination value is passed separately to prepare(). $sql, ...array_merge( [table("entries"), table("forms")], $where["args"], [$offset], ), ), ARRAY_A, ); foreach ($rows as $entry) { $answers = []; foreach (entry_values((int) $entry["id"]) as $value) { $answers[$value["field_id"]] = value_text($value["value"]); } $cells = [ $entry["reference"], $entry["form_name"], $entry["created_at"], $entry["status"], $entry["contact_name"], $entry["email"], $entry["company"], $entry["subject"], $entry["source_path"], ]; foreach ($columns as $column) { $cells[] = (int) $column["form_id"] === (int) $entry["form_id"] ? $answers[$column["field_id"]] ?? "" : ""; } fputcsv($output, array_map($safe, $cells), ",", '"', ""); } } // php://output is closed automatically when this streaming response ends. exit(); } function download_file(): void { if (!allowed("view_noesis_entries")) { wp_die("You cannot open this file.", "", ["response" => 403]); } $id = absint($_GET["file"] ?? 0); check_admin_referer("noesis_forms_file_" . $id); global $wpdb; $file = $wpdb->get_row( $wpdb->prepare("SELECT * FROM %i WHERE id=%d", table("files"), $id), ARRAY_A, ); if (!$file) { wp_die("File not found.", "", ["response" => 404]); } nocache_headers(); header("Content-Type: application/octet-stream"); header( 'Content-Disposition: attachment; filename="' . sanitize_file_name($file["original_name"]) . '"', ); header("Content-Length: " . (int) $file["bytes"]); header("X-Content-Type-Options: nosniff"); header("Content-Security-Policy: sandbox"); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Authenticated attachment download with nosniff and sandbox headers; escaping would corrupt the binary file. echo $file["content"]; exit(); } function privacy_exporters(array $exporters): array { $exporters["noesis-forms"] = [ "exporter_friendly_name" => "Feather Forms enquiries", "callback" => static function ($email, $page = 1) { global $wpdb; $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE email=%s ORDER BY id LIMIT 50 OFFSET %d", table("entries"), $email, max(0, $page - 1) * 50, ), ARRAY_A, ); $data = []; foreach ($rows as $entry) { $fields = [ [ "name" => "Received (UTC)", "value" => $entry["created_at"], ], ["name" => "Reference", "value" => $entry["reference"]], ["name" => "Status", "value" => $entry["status"]], ]; foreach (entry_values((int) $entry["id"]) as $value) { $fields[] = [ "name" => $value["label"], "value" => value_text($value["value"]), ]; } $data[] = [ "group_id" => "noesis-forms", "group_label" => "Form enquiries", "item_id" => "noesis-form-entry-" . $entry["id"], "data" => $fields, ]; } return ["data" => $data, "done" => count($rows) < 50]; }, ]; return $exporters; } function privacy_erasers(array $erasers): array { $erasers["noesis-forms"] = [ "eraser_friendly_name" => "Feather Forms enquiries", "callback" => static function ($email, $page = 1) { global $wpdb; $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id,legacy_post_id FROM %i WHERE email=%s ORDER BY id LIMIT 50", table("entries"), $email, ), ARRAY_A, ); foreach ($rows as $entry) { remove_entry_data((int) $entry["id"], true); if ($entry["legacy_post_id"]) { wp_update_post([ "ID" => (int) $entry["legacy_post_id"], "post_title" => "Personal data removed", "post_content" => "", "post_status" => "private", ]); } } return [ "items_removed" => (bool) $rows, "items_retained" => false, "messages" => [], "done" => count($rows) < 50, ]; }, ]; return $erasers; }