403]); } echo '

Loading Feather Forms…

'; } function admin_assets(string $hook): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only view selection; changes and private data use separate capability and nonce checks. $page = sanitize_key(wp_unslash($_GET["page"] ?? "")); if ( !in_array( $page, ["noesis-forms", "noesis-forms-entries", "noesis-forms-settings"], true, ) ) { return; } wp_enqueue_style( "noesis-forms-admin", plugins_url("assets/admin.min.css", FILE), [], asset_stamp("admin.min.css"), ); $deps = ["wp-element"]; if ($page === "noesis-forms" && allowed()) { wp_enqueue_editor(); wp_enqueue_media(); add_action( "admin_print_footer_scripts", ["_WP_Editors", "print_tinymce_scripts"], 5, ); $deps = array_merge($deps, [ "editor", "quicktags", "wplink", "media-editor", ]); } wp_enqueue_script( "noesis-forms-admin", plugins_url("assets/admin.min.js", FILE), $deps, asset_stamp("admin.min.js"), true, ); $view = $page === "noesis-forms-entries" ? "entries" : ($page === "noesis-forms-settings" ? "settings" : "forms"); wp_add_inline_script( "noesis-forms-admin", "window.NF_ADMIN=" . wp_json_encode( [ "logoURL" => wp_make_link_relative( plugins_url("assets/feather-forms.svg", FILE), ), "richEditing" => user_can_richedit(), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only view selection; changes and private data use separate capability and nonce checks. "emailMode" => !empty($_GET["emails"]), "emailDefaults" => email_templates([]), "testEmail" => wp_get_current_user()->user_email, "root" => wp_make_link_relative( rest_url("noesis-forms/v1/"), ), "nonce" => wp_create_nonce("wp_rest"), "view" => $view, // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only view selection; changes and private data use separate capability and nonce checks. "formId" => absint($_GET["form"] ?? 0), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only view selection; changes and private data use separate capability and nonce checks. "entryId" => absint($_GET["entry"] ?? 0), "adminURL" => wp_make_link_relative(admin_url()), "csvURL" => nonce_link( wp_make_link_relative( admin_url("admin-post.php?action=noesis_forms_csv"), ), "noesis_forms_csv", ), "canManage" => allowed(), "canExport" => allowed("export_noesis_entries"), "canDelete" => allowed("delete_noesis_entries"), "types" => field_types(), "frameworks" => frameworks(), "previewBase" => wp_make_link_relative(home_url("/")), "jsonURL" => nonce_link( wp_make_link_relative( admin_url( "admin-post.php?action=noesis_forms_json", ), ), "noesis_forms_json", ), "defaults" => form_defaults(), "contactId" => (int) get_option("noesis_forms_contact_id"), "timezone" => wp_timezone_string(), "hubspotConnected" => !empty(settings()["hubspot_token"]), ], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT, ) . ";", "before", ); } function register_routes(): void { $manage = static fn() => allowed(); $view = static fn() => allowed("view_noesis_entries"); $route = static function ($path, $methods, $permission, $callback) { register_rest_route("noesis-forms/v1", $path, [ "methods" => $methods, "permission_callback" => $permission, "callback" => $callback, ]); }; $route("/forms", "GET", $manage, static function () { global $wpdb; $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT f.*, (SELECT COUNT(*) FROM %i e WHERE e.form_id=f.id AND e.status NOT IN (\'trash\',\'spam\',\'anonymised\')) AS entry_count FROM %i f ORDER BY f.updated_at DESC', table("entries"), table("forms"), ), ARRAY_A, ); return array_map( static fn($row) => public_form(decode_form($row)), $rows, ); }); $route("/forms", "POST", $manage, static function ($request) { $data = $request->get_json_params(); if (!is_array($data)) { return problem("Choose a valid form."); } $form = create_form($data); return is_wp_error($form) ? $form : public_form($form); }); $route("/forms/(?P\d+)", "GET", $manage, static function ($request) { $form = get_form((int) $request["id"]); return $form ? public_form($form) : problem("Form not found.", 404); }); $route("/forms/(?P\d+)", "PUT", $manage, static function ($request) { $body = $request->get_json_params(); if (!is_array($body)) { return problem("Invalid form."); } $form = save_form((int) $request["id"], $body); return is_wp_error($form) ? $form : public_form($form); }); $route("/forms/(?P\d+)/duplicate", "POST", $manage, static function ( $request, ) { $form = get_form((int) $request["id"]); if (!$form) { return problem("Form not found.", 404); } $form["name"] .= " (copy)"; $form["status"] = "draft"; $form["settings"]["webhook_secret"] = ""; $new = create_form($form); return is_wp_error($new) ? $new : public_form($new); }); $route("/templates", "GET", $manage, static fn() => templates()); $route("/pages", "GET", $manage, static function () { return array_map( static fn($p) => [ "id" => $p->ID, "title" => $p->post_title, "path" => wp_make_link_relative(get_permalink($p)), ], get_posts([ "post_type" => "page", "post_status" => "publish", "numberposts" => 500, "orderby" => "title", "order" => "ASC", ]), ); }); $route("/inbox-forms", "GET", $view, static function () { return form_catalog(); }); $route( "/embed-forms", "GET", static fn() => current_user_can("edit_posts"), static function () { return array_values( array_map( static fn($form) => [ "id" => $form["id"], "name" => $form["name"], ], array_filter( form_catalog(), static fn($form) => $form["status"] === "active", ), ), ); }, ); $route("/entries", "GET", $view, __NAMESPACE__ . "\\list_entries"); $route("/entries/(?P\d+)", "GET", $view, __NAMESPACE__ . "\\get_entry"); $route("/entries/(?P\d+)", "PUT", $view, static function ($request) { global $wpdb; $data = $request->get_json_params(); $id = (int) $request["id"]; $changes = []; if (isset($data["status"])) { if ( !in_array( $data["status"], ["new", "read", "in_progress", "closed", "spam", "trash"], true, ) ) { return problem("Unknown status."); } if ( $data["status"] === "trash" && !allowed("delete_noesis_entries") ) { return problem("You cannot move enquiries to trash.", 403); } $changes["status"] = $data["status"]; } if (isset($data["starred"])) { $changes["starred"] = (int) (bool) $data["starred"]; } if (!$changes) { return problem("Choose a change."); } $changes["updated_at"] = now(); $result = entry_transaction([$id], static function () use ( $wpdb, $changes, $id, ) { return $wpdb->update(table("entries"), $changes, ["id" => $id]) !== false && record_change($id, "upsert", true); }); return is_wp_error($result) ? $result : get_entry($request); }); $route( "/entries/(?P\d+)", "DELETE", static fn() => allowed("delete_noesis_entries"), static function ($request) { global $wpdb; $id = (int) $request["id"]; $entry = $wpdb->get_row( $wpdb->prepare( "SELECT status,legacy_post_id FROM %i WHERE id=%d", table("entries"), $id, ), ARRAY_A, ); if (!$entry) { return problem("Enquiry not found.", 404); } if ($entry["legacy_post_id"]) { return problem( "Use WordPress Erase Personal Data to remove a migrated enquiry and its preserved original.", ); } if ($entry["status"] !== "trash") { return problem( "Move this enquiry to trash before deleting it permanently.", ); } remove_entry_data($id); return ["deleted" => true]; }, ); $route("/entries/(?P\d+)/notes", "POST", $view, static function ( $request, ) { global $wpdb; $id = (int) $request["id"]; $body = sanitize_textarea_field( substr(scalar($request->get_param("body")), 0, 5000), ); if (!$body) { return problem("Write a note first."); } if ( !$wpdb->get_var( $wpdb->prepare( "SELECT id FROM %i WHERE id=%d", table("entries"), $id, ), ) ) { return problem("Enquiry not found.", 404); } $result = entry_transaction([$id], static function () use ( $wpdb, $id, $body, ) { return (bool) $wpdb->insert(table("notes"), [ "entry_id" => $id, "user_id" => get_current_user_id(), "body" => $body, "created_at" => now(), ]) && $wpdb->update( table("entries"), ["updated_at" => now()], ["id" => $id], ) !== false && record_change($id, "upsert", true); }); return is_wp_error($result) ? $result : get_entry($request); }); $route("/entries/(?P\d+)/retry", "POST", $manage, static function ( $request, ) { global $wpdb; $id = (int) $request["id"]; $job = $wpdb->get_row( $wpdb->prepare( 'SELECT id FROM %i WHERE entry_id=%d AND kind=\'notification\'', table("jobs"), $id, ), ARRAY_A, ); if (!$job) { return problem( "There is no notification to retry for this enquiry.", ); } $wpdb->update( table("jobs"), [ "status" => "pending", "attempts" => 0, "due_at" => now(), "updated_at" => now(), ], ["id" => $job["id"]], ); run_jobs($id); return get_entry($request); }); $route("/entries/bulk", "POST", $view, static function ($request) { global $wpdb; $ids = array_slice( array_filter( array_map("absint", (array) $request->get_param("ids")), ), 0, 100, ); $action = $request->get_param("action"); if ( !$ids || !in_array( $action, [ "read", "new", "in_progress", "closed", "spam", "trash", "star", "unstar", ], true, ) ) { return problem("Select enquiries and an action."); } if ($action === "trash" && !allowed("delete_noesis_entries")) { return problem("You cannot move enquiries to trash.", 403); } $ids = array_values(array_unique($ids)); sort($ids); $result = entry_transaction($ids, static function () use ( $wpdb, $ids, $action, ) { foreach ($ids as $id) { if ( $wpdb->update( table("entries"), in_array($action, ["star", "unstar"], true) ? [ "starred" => $action === "star" ? 1 : 0, "updated_at" => now(), ] : ["status" => $action, "updated_at" => now()], ["id" => $id], ) === false || !record_change($id, "upsert", true) ) { return false; } } return true; }); return is_wp_error($result) ? $result : ["updated" => count($ids)]; }); $route("/settings", "GET", $manage, __NAMESPACE__ . "\\safe_settings"); $route("/settings", "PUT", $manage, static function ($request) { $old = settings(); $data = $request->get_json_params(); if (!is_email($data["recipient"] ?? "")) { return problem("Enter a valid default notification address."); } $old["recipient"] = sanitize_email($data["recipient"]); $old["sender_name"] = sanitize_text_field( substr(scalar($data["sender_name"] ?? ""), 0, 100), ); $old["rate_limit"] = max(1, min(30, (int) ($data["rate_limit"] ?? 6))); if (!defined("NOESIS_RECAPTCHA_SITE_KEY")) { $old["site_key"] = sanitize_text_field( substr(scalar($data["site_key"] ?? ""), 0, 190), ); } if ( !defined("NOESIS_RECAPTCHA_SECRET_KEY") && !empty($data["secret_key"]) ) { $old["secret_key"] = sanitize_text_field( substr(scalar($data["secret_key"]), 0, 190), ); } if (!empty($data["hubspot_clear_token"])) { $old["hubspot_token"] = ""; } elseif (!empty($data["hubspot_token"])) { $old["hubspot_token"] = sanitize_text_field( substr(scalar($data["hubspot_token"]), 0, 500), ); } update_option("noesis_forms_settings", $old, false); if ( isset($data["contact_id"]) && ($f = get_form(absint($data["contact_id"]))) && $f["status"] === "active" ) { update_option("noesis_forms_contact_id", $f["id"], false); } purge(); return safe_settings(); }); $route("/submit/(?P\d+)", "POST", "__return_true", static function ( $request, ) { $body = $request->get_body_params(); if (!$body) { $body = (array) $request->get_json_params(); } return submit((int) $request["id"], $body, $request->get_file_params()); }); $route("/token/(?P\d+)", "GET", "__return_true", static function ( $request, ) { $form = get_form((int) $request["id"]); if (!$form || !form_open($form)) { return problem("Form not available.", 404); } $response = new \WP_REST_Response([ "nonce" => wp_create_nonce("nf_submit_" . $form["id"]), "started" => started_token($form["id"]), ]); $response->header("Cache-Control", "no-store"); return $response; }); } function safe_settings(): array { $s = settings(); $c = captcha_config(); $s["hubspot_token_set"] = !empty($s["hubspot_token"]); unset($s["secret_key"], $s["hubspot_token"]); $s["hubspot_token"] = ""; $s["hubspot_clear_token"] = false; $s["site_key"] = $c["site_key"]; $s["secret_set"] = (bool) $c["secret"]; $s["captcha_locked"] = defined("NOESIS_RECAPTCHA_SECRET_KEY") || defined("NOESIS_RECAPTCHA_SITE_KEY"); $s["captcha_required"] = $c["required"]; $s["contact_id"] = (int) get_option("noesis_forms_contact_id"); $s["server_upload_mb"] = round(wp_max_upload_size() / MB_IN_BYTES, 1); $s["server_post_mb"] = round( wp_convert_hr_to_bytes(ini_get("post_max_size")) / MB_IN_BYTES, 1, ); return $s; } function list_entries($request): array { global $wpdb; $where = entry_where($request->get_params()); $base = " FROM %i e LEFT JOIN %i f ON f.id=e.form_id 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( $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(). $count_sql, table("entries"), table("forms"), ...$where["args"], ), ); $page = max(1, (int) $request->get_param("page")); $offset = ($page - 1) * 25; $order = $request->get_param("sort") === "oldest" ? "ASC" : "DESC"; $sql = "SELECT e.id,e.reference,e.form_id,e.status,e.starred,e.contact_name,e.email,e.company,e.subject,e.source_path,e.created_at,e.notification_status,f.name AS form_name" . $base . " ORDER BY e.id " . $order . " LIMIT 25 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, ); $counts = $wpdb->get_results( $wpdb->prepare( "SELECT status,COUNT(*) AS total FROM %i GROUP BY status", table("entries"), ), ARRAY_A, ); return [ "entries" => $rows, "total" => $count, "page" => $page, "pages" => max(1, (int) ceil($count / 25)), "counts" => $counts, ]; } function get_entry($request) { global $wpdb; $id = (int) $request["id"]; $entry = $wpdb->get_row( $wpdb->prepare( "SELECT e.*,f.name AS form_name FROM %i e LEFT JOIN %i f ON f.id=e.form_id WHERE e.id=%d", table("entries"), table("forms"), $id, ), ARRAY_A, ); if (!$entry) { return problem("Enquiry not found.", 404); } unset($entry["submission_key"], $entry["request_hash"]); $entry["schema"] = json_decode($entry["schema_json"], true); unset($entry["schema_json"]); $entry["values"] = entry_values($id); $entry["notes"] = $wpdb->get_results( $wpdb->prepare( "SELECT n.id,n.body,n.created_at,u.display_name AS author FROM %i n LEFT JOIN %i u ON u.ID=n.user_id WHERE n.entry_id=%d ORDER BY n.id DESC", table("notes"), $wpdb->users, $id, ), ARRAY_A, ); $entry["files"] = $wpdb->get_results( $wpdb->prepare( "SELECT id,field_id,original_name,mime_type,bytes FROM %i WHERE entry_id=%d", table("files"), $id, ), ARRAY_A, ); foreach ($entry["files"] as &$file) { $file["url"] = nonce_link( wp_make_link_relative( admin_url( "admin-post.php?action=noesis_forms_file&file=" . $file["id"], ), ), "noesis_forms_file_" . $file["id"], ); } $entry["deliveries"] = $wpdb->get_results( $wpdb->prepare( "SELECT id,kind,status,attempts,last_error,updated_at FROM %i WHERE entry_id=%d", table("jobs"), $id, ), ARRAY_A, ); return $entry; } function templates(): array { $contact = contact_template(); $contact["name"] = "Contact form"; $contact["status"] = "draft"; $brief = contact_template(); $brief["name"] = "Project brief"; $brief["status"] = "draft"; $fields = $brief["schema"]["pages"][0]["fields"]; $brief["schema"]["pages"] = [ [ "id" => "you", "title" => "A little about you", "description" => "Where should we send our reply?", "fields" => array_slice($fields, 0, 3), ], [ "id" => "project", "title" => "Your project", "description" => "Tell us what you would like to make better.", "fields" => array_slice($fields, 3, 2), ], [ "id" => "finish", "title" => "One last thing", "fields" => [ [ "id" => "attachment", "type" => "file", "label" => "Share a brief or a reference", "help" => "PDF or image, up to 5 MB.", "extensions" => ["pdf", "jpg", "png", "webp"], ], ] + [], ], ]; $brief["schema"]["pages"][2]["fields"][] = $fields[5]; $survey = [ "name" => "Customer feedback", "status" => "draft", "schema" => [ "pages" => [ [ "id" => "feedback", "title" => "How did we do?", "fields" => [ [ "id" => "rating", "type" => "rating", "label" => "How was your experience?", "required" => true, ], [ "id" => "feedback", "type" => "textarea", "label" => "What could we do better?", ], [ "id" => "email", "type" => "email", "label" => "Email for a reply (optional)", "role" => "email", ], ], ], ], ], "settings" => form_defaults(), ]; return [ [ "key" => "contact", "description" => "A clear, friendly contact form.", "form" => $contact, ], [ "key" => "brief", "description" => "Three guided pages with a private file upload.", "form" => $brief, ], [ "key" => "feedback", "description" => "A short rating and feedback form.", "form" => $survey, ], ]; } /** Keep state changes and their sync markers in the same committed transaction. */ function entry_transaction(array $ids, callable $change) { global $wpdb; $wpdb->query("START TRANSACTION"); foreach ($ids as $id) { if ( !$wpdb->get_var( $wpdb->prepare( "SELECT id FROM %i WHERE id=%d FOR UPDATE", table("entries"), $id, ), ) ) { $wpdb->query("ROLLBACK"); return problem("Enquiry not found.", 404); } } $ok = $change(); if ($ok) { $wpdb->query("COMMIT"); } else { $wpdb->query("ROLLBACK"); } return $ok ? true : problem("The change could not be saved. Please try again.", 500); }