query("START TRANSACTION"); } // Serialise event allocation until COMMIT. Auto-increment alone does not // guarantee commit order: a slower transaction could otherwise be skipped. $lock = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM %i ORDER BY id LIMIT 1 FOR UPDATE", table("forms"), ), ); $form_id = $lock ? $wpdb->get_var( $wpdb->prepare( "SELECT form_id FROM %i WHERE id=%d", table("entries"), $entry_id, ), ) : null; $ok = $form_id && (bool) $wpdb->insert(table("changes"), [ "entry_id" => $entry_id, "form_id" => $form_id, "operation" => $operation, "created_at" => now(), ]); if (!$inside_transaction) { if ($ok) { $wpdb->query("COMMIT"); } else { $wpdb->query("ROLLBACK"); } } return $ok; } function api_scope(): array { if (allowed()) { return array_map("intval", array_column(form_catalog(), "id")); } return array_values( array_filter( array_map( "absint", (array) get_user_meta( get_current_user_id(), "noesis_forms_api_scope", true, ), ), ), ); } function api_permission() { if (!is_ssl()) { return problem("Use HTTPS for the leads API.", 403); } if (!is_user_logged_in()) { return problem( "Authenticate with a WordPress Application Password over HTTPS.", 401, ); } if (!allowed("read_noesis_leads")) { return problem("This account cannot read leads.", 403); } if (!api_scope()) { return problem( "An administrator must assign forms to this integration user.", 403, ); } return true; } function api_lead(array $entry): array { $deleted = in_array( $entry["status"], ["trash", "spam", "anonymised"], true, ); $result = [ "id" => (int) $entry["id"], "reference" => $entry["reference"], "form_id" => (int) $entry["form_id"], "status" => $entry["status"], "deleted" => $deleted, "created_at" => gmdate("c", strtotime($entry["created_at"] . " UTC")), "updated_at" => gmdate("c", strtotime($entry["updated_at"] . " UTC")), ]; if ($deleted) { return $result; } $result += [ "name" => $entry["contact_name"], "email" => $entry["email"], "company" => $entry["company"], "subject" => $entry["subject"], "source_path" => $entry["source_path"], "starred" => (bool) $entry["starred"], "notification_status" => $entry["notification_status"], "answers" => entry_values((int) $entry["id"]), ]; global $wpdb; $result["files"] = $wpdb->get_results( $wpdb->prepare( "SELECT id,field_id,original_name,mime_type,bytes FROM %i WHERE entry_id=%d", table("files"), $entry["id"], ), ARRAY_A, ); foreach ($result["files"] as &$file) { $file["download_url"] = rest_url( "noesis-forms/v1/leads/" . $entry["id"] . "/files/" . $file["id"], ); } return $result; } function register_leads_api(): void { $register = static function ($path, $callback) { register_rest_route("noesis-forms/v1", $path, [ "methods" => "GET", "permission_callback" => __NAMESPACE__ . "\\api_permission", "callback" => $callback, ]); }; $register("/leads", static function ($request) { global $wpdb; $scope = api_scope(); $form_id = absint($request->get_param("form_id")); if ($form_id && !in_array($form_id, $scope, true)) { return problem("This form is outside your access scope.", 403); } $ids = $form_id ? [$form_id] : $scope; $after = absint($request->get_param("after_id")); $limit = max(1, min(100, (int) ($request->get_param("limit") ?: 50))); $placeholders = implode(",", array_fill(0, count($ids), "%d")); $sql = "SELECT * FROM %i WHERE form_id IN (" . $placeholders . ") AND id>%d ORDER BY id LIMIT %d"; // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- The IN fragment contains only generated %d placeholders; table and numeric scope values are passed separately to prepare(). $rows = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The IN fragment contains only generated %d placeholders; table and numeric scope values are passed separately to prepare(). $sql, ...array_merge([table("entries")], $ids, [$after, $limit + 1]), ), ARRAY_A, ); $has_more = count($rows) > $limit; $rows = array_slice($rows, 0, $limit); return [ "leads" => array_map(__NAMESPACE__ . "\\api_lead", $rows), "next_after_id" => $rows ? (int) end($rows)["id"] : $after, "has_more" => $has_more, ]; }); $register("/leads/sync", static function ($request) { global $wpdb; $scope = api_scope(); $cursor = absint($request->get_param("cursor")); $limit = max(1, min(100, (int) ($request->get_param("limit") ?: 50))); $high = (int) $wpdb->get_var( $wpdb->prepare("SELECT MAX(id) FROM %i", table("changes")), ); if ($cursor > $high) { return problem( "The cursor is beyond this site’s change feed. Start a new sync from cursor 0.", 400, ); } $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE id>%d AND id<=%d AND form_id IN (" . implode(",", array_fill(0, count($scope), "%d")) . ") ORDER BY id LIMIT %d", ...array_merge([table("changes"), $cursor, $high], $scope, [ $limit + 1, ]), ), ARRAY_A, ); $has_more = count($rows) > $limit; $rows = array_slice($rows, 0, $limit); $changes = []; foreach ($rows as $change) { $entry = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM %i WHERE id=%d", table("entries"), $change["entry_id"], ), ARRAY_A, ); $deleted = $change["operation"] === "delete" || !$entry || in_array( $entry["status"], ["trash", "spam", "anonymised"], true, ); $changes[] = [ "cursor" => (int) $change["id"], "entry_id" => (int) $change["entry_id"], "form_id" => (int) $change["form_id"], "operation" => $deleted ? "delete" : "upsert", "changed_at" => gmdate( "c", strtotime($change["created_at"] . " UTC"), ), "lead" => $deleted ? null : api_lead($entry), ]; } return [ "changes" => $changes, "next_cursor" => $has_more ? (int) end($rows)["id"] : $high, "has_more" => $has_more, "as_of_cursor" => $high, ]; }); $register("/leads/(?P\d+)", static function ($request) { global $wpdb; $entry = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM %i WHERE id=%d", table("entries"), $request["id"], ), ARRAY_A, ); if (!$entry || !in_array((int) $entry["form_id"], api_scope(), true)) { return problem("Lead not found.", 404); } return api_lead($entry); }); $register("/leads/(?P\d+)/files/(?P\d+)", static function ( $request, ) { global $wpdb; $entry = $wpdb->get_row( $wpdb->prepare( "SELECT form_id,status FROM %i WHERE id=%d", table("entries"), $request["id"], ), ARRAY_A, ); if ( !$entry || !in_array((int) $entry["form_id"], api_scope(), true) || in_array($entry["status"], ["trash", "spam", "anonymised"], true) ) { return problem("File not found.", 404); } $file = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM %i WHERE id=%d AND entry_id=%d", table("files"), $request["file"], $request["id"], ), ARRAY_A, ); if (!$file) { return problem("File not found.", 404); } nocache_headers(); header("Content-Type: application/octet-stream"); header( 'Content-Disposition: attachment; filename="' . sanitize_file_name($file["original_name"]) . '"', ); header("X-Content-Type-Options: nosniff"); header("Content-Length: " . (int) $file["bytes"]); 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(); }); add_filter( "rest_post_dispatch", static function ($response, $server, $request) { if (str_starts_with($request->get_route(), "/noesis-forms/v1/")) { $response->header( "Cache-Control", "private, no-store, max-age=0", ); $response->header("X-Content-Type-Options", "nosniff"); } return $response; }, 10, 3, ); } function scope_profile($user): void { if (!allowed()) { return; } $forms = form_catalog(); $selected = (array) get_user_meta( $user->ID, "noesis_forms_api_scope", true, ); echo "

Feather Forms lead API access

The Feather Form Leads Reader role can read only the forms selected here. Administrators with form management access can read all forms. Use a separate Application Password for each integration.

"; wp_nonce_field("nf_scope_" . $user->ID, "nf_scope_nonce"); foreach ($forms as $form) { echo '

"; } } function save_scope_profile(int $user_id): void { $nonce = isset($_POST["nf_scope_nonce"]) && is_string($_POST["nf_scope_nonce"]) ? sanitize_text_field(wp_unslash($_POST["nf_scope_nonce"])) : ""; if ( !allowed() || !current_user_can("edit_user", $user_id) || !wp_verify_nonce( $nonce, "nf_scope_" . $user_id, ) ) { return; } $ids = array_slice( array_filter( array_map( "absint", wp_unslash((array) ($_POST["nf_api_scope"] ?? [])), ), ), 0, 1000, ); update_user_meta($user_id, "noesis_forms_api_scope", $ids); } function export_json(): void { if (!allowed("export_noesis_entries")) { wp_die("You cannot export enquiries.", "", ["response" => 403]); } check_admin_referer("noesis_forms_json"); global $wpdb; $where = entry_where(array_map("sanitize_text_field", wp_unslash($_GET))); $base = " FROM %i e WHERE " . $where["sql"]; $count_sql = "SELECT COUNT(*)" . $base; // 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 fewer than 50,000 enquiries."); } nocache_headers(); header("Content-Type: application/json; charset=UTF-8"); header( 'Content-Disposition: attachment; filename="enquiries-' . gmdate("Y-m-d") . '.json"', ); header("X-Content-Type-Options: nosniff"); echo '{"exported_at":' . wp_json_encode(gmdate("c")) . ',"enquiries":['; $first = true; for ($offset = 0; $offset < $count; $offset += 200) { $sql = "SELECT *" . $base . " ORDER BY e.id 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")], $where["args"], [$offset]), ), ARRAY_A, ); foreach ($rows as $entry) { if (!$first) { echo ","; } $first = false; $lead = api_lead($entry); if (isset($lead["files"])) { foreach ($lead["files"] as &$file) { unset($file["download_url"]); } unset($file); } echo wp_json_encode( $lead, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES, ); } } echo "]}"; exit(); }