WP_REST_Server::READABLE, 'callback' => [ $this, 'issue_security_token' ], 'permission_callback' => '__return_true', 'args' => [ 'formId' => [ 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint', ], 'postId' => [ 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint', ], ], ] ); // Admin: per-form stored submissions — list + clear-all. register_rest_route( 'generateblocks-pro/v1', '/admin/forms/(?P\d+)/submissions', [ [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $this, 'list_form_submissions' ], 'permission_callback' => [ $this, 'admin_permission' ], 'args' => [ 'id' => [ 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint', ], 'page' => [ 'required' => false, 'type' => 'integer', 'default' => 1, 'minimum' => 1, 'sanitize_callback' => 'absint', ], 'per_page' => [ 'required' => false, 'type' => 'integer', 'default' => GenerateBlocks_Pro_Form_Submissions::DEFAULT_PAGE_SIZE, 'minimum' => 1, 'maximum' => GenerateBlocks_Pro_Form_Submissions::MAX_PAGE_SIZE, 'sanitize_callback' => 'absint', ], ], ], [ 'methods' => WP_REST_Server::DELETABLE, 'callback' => [ $this, 'clear_form_submissions' ], 'permission_callback' => [ $this, 'admin_permission' ], 'args' => [ 'id' => [ 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint', ], ], ], ] ); // Admin: delete one stored submission, scoped to its form. register_rest_route( 'generateblocks-pro/v1', '/admin/forms/(?P\d+)/submissions/(?P\d+)', [ 'methods' => WP_REST_Server::DELETABLE, 'callback' => [ $this, 'delete_form_submission' ], 'permission_callback' => [ $this, 'admin_permission' ], 'args' => [ 'id' => [ 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint', ], 'submissionId' => [ 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint', ], ], ] ); // Admin: duplicate a form. Post + meta copy is atomic server-side so // the dashboard doesn't need to reason about block content structure. register_rest_route( 'generateblocks-pro/v1', '/admin/forms/(?P\d+)/duplicate', [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [ $this, 'duplicate_form' ], 'permission_callback' => [ $this, 'admin_permission' ], 'args' => [ 'id' => [ 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint', ], ], ] ); // Admin: toggle submission storage for a single form. Lets the // dashboard's storage-off failure view enable storage in place, // without round-tripping through the block editor. The meta write // triggers maybe_install_submissions_table_after_meta_save() on the // post type, so the table is created when storage is switched on. register_rest_route( 'generateblocks-pro/v1', '/admin/forms/(?P\d+)/storage', [ 'methods' => WP_REST_Server::EDITABLE, 'callback' => [ $this, 'update_form_storage' ], 'permission_callback' => [ $this, 'admin_permission' ], 'args' => [ 'id' => [ 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint', ], 'enabled' => [ 'required' => true, 'type' => 'boolean', ], ], ] ); // Admin: where is this form embedded? Used by the dashboard's // "Check Usage" menu item and pre-populated by the delete modal so // admins see content references before they confirm. register_rest_route( 'generateblocks-pro/v1', '/admin/forms/(?P\d+)/usage', [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $this, 'get_form_usage' ], 'permission_callback' => [ $this, 'admin_permission' ], 'args' => [ 'id' => [ 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint', ], 'limit' => [ 'required' => false, 'type' => 'integer', 'default' => 50, 'sanitize_callback' => 'absint', ], 'refresh' => [ 'required' => false, 'type' => 'boolean', 'default' => false, ], ], ] ); // Admin: usage-aware form deletion. Mirrors the conditions delete // flow — a form referenced by published content is blocked unless // the caller passes force=true. The core REST DELETE on // /wp/v2/gblocks-forms/{id} would skip this guard, so the dashboard // uses this endpoint exclusively. force=true is always required; // trash is intentionally unsupported for forms. register_rest_route( 'generateblocks-pro/v1', '/admin/forms/(?P\d+)', [ 'methods' => WP_REST_Server::DELETABLE, 'callback' => [ $this, 'delete_form' ], 'permission_callback' => [ $this, 'admin_permission' ], 'args' => [ 'id' => [ 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint', ], 'force' => [ 'required' => false, 'type' => 'boolean', 'default' => false, ], ], ] ); register_rest_route( 'generateblocks-pro/v1', '/forms/submit', [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [ $this, 'handle_submission' ], 'permission_callback' => '__return_true', 'args' => [ 'formId' => [ 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint', ], 'postId' => [ 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint', ], 'fields' => [ 'required' => true, 'type' => 'object', 'validate_callback' => function ( $value ) { return is_array( $value ); }, 'sanitize_callback' => function ( $value ) { if ( ! is_array( $value ) ) { return []; } $flat = []; foreach ( $value as $key => $val ) { // sanitize_key (not sanitize_text_field) so the honeypot // lookup at handle_submission() can't be bypassed by // submitting the field name with non-lowercase casing. // Schema field names are all sanitize_key-normalized. $clean_key = sanitize_key( $key ); if ( '' === $clean_key ) { continue; } if ( is_array( $val ) ) { $clean_array = []; foreach ( $val as $item ) { if ( is_string( $item ) || is_numeric( $item ) ) { $clean_array[] = (string) $item; } } $flat[ $clean_key ] = $clean_array; } elseif ( is_string( $val ) || is_numeric( $val ) ) { $flat[ $clean_key ] = (string) $val; } } return $flat; }, ], 'gb_form_ts' => [ 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], 'gb_hp' => [ 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], 'cf_turnstile_response' => [ 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], ], ] ); } /** * Handle a form submission. * * Order of operations matters — cheapest checks first. * * @param WP_REST_Request $request The REST request. * @return WP_REST_Response The response. */ public function handle_submission( WP_REST_Request $request ) { $form_id = absint( $request->get_param( 'formId' ) ); $post_id = absint( $request->get_param( 'postId' ) ); $raw_fields = $request->get_param( 'fields' ); $gb_form_ts = $request->get_param( 'gb_form_ts' ); $gb_hp = $request->get_param( 'gb_hp' ); if ( ! $form_id ) { return $this->generic_error( 'missing_form_id' ); } if ( ! is_array( $raw_fields ) ) { return $this->generic_error( 'fields_not_array' ); } // Reject oversized submissions (max 500 KB aggregate, max 100 fields). if ( count( $raw_fields ) > 100 ) { return $this->generic_error( 'too_many_fields' ); } $total_size = 0; foreach ( $raw_fields as $value ) { if ( is_string( $value ) ) { $total_size += strlen( $value ); } elseif ( is_array( $value ) ) { foreach ( $value as $item ) { $total_size += is_string( $item ) ? strlen( $item ) : 0; } } if ( $total_size > 500000 ) { return $this->generic_error( 'payload_too_large' ); } } // 1. Origin check — reject cross-site submissions. if ( ! $this->origin_is_allowed() ) { return $this->generic_error( 'origin_not_allowed' ); } // 2. Honeypot — return fake success so bots don't adapt. if ( ! empty( $gb_hp ) ) { return $this->fake_success(); } // 3. Timestamp check — bound to (form_id, post_id) so a harvested token // can only replay against the same form on the same page. // // `retry: true` on these branches lets the client request a fresh // token and replay once. Other failure branches (origin, post-process, // action errors) intentionally don't set it — replaying after a // partial action-success would re-execute already-delivered actions // (e.g. duplicate emails / webhooks). $verified_ts = GenerateBlocks_Pro_Form_Spam::verified_timestamp( $gb_form_ts, $form_id, $post_id ); if ( false === $verified_ts ) { return $this->generic_error( 'timestamp_signature_invalid', true ); } if ( ! GenerateBlocks_Pro_Form_Spam::is_fresh( $verified_ts ) ) { return $this->generic_error( 'timestamp_stale', true ); } // 3b. Per-render honeypot check. $expected_hp = GenerateBlocks_Pro_Form_Spam::honeypot_field_name( $verified_ts, $form_id, $post_id ); if ( isset( $raw_fields[ $expected_hp ] ) ) { $hp_value = $raw_fields[ $expected_hp ]; if ( is_array( $hp_value ) ? ! empty( $hp_value ) : '' !== trim( (string) $hp_value ) ) { return $this->fake_success(); } } // 4. Validate the form post exists + is published. $form = get_post( $form_id ); if ( ! $form ) { return $this->generic_error( 'form_not_found:' . $form_id ); } if ( GenerateBlocks_Pro_Form_Post_Type::POST_TYPE !== $form->post_type ) { return $this->generic_error( 'form_wrong_post_type:' . $form->post_type ); } if ( 'publish' !== $form->post_status ) { return $this->generic_error( 'form_not_published:' . $form->post_status ); } // 5. Validate the host post exists and is accessible. if ( $post_id ) { $host = get_post( $post_id ); $allowed_statuses = [ 'publish', 'private' ]; if ( ! $host || ! in_array( $host->post_status, $allowed_statuses, true ) ) { return $this->generic_error( 'host_not_accessible' ); } if ( 'private' === $host->post_status && ! current_user_can( 'read_post', $post_id ) ) { return $this->generic_error( 'host_private_no_cap' ); } if ( post_password_required( $host ) ) { return $this->generic_error( 'host_password_protected' ); } } // 6. Process the submission. $context = [ 'form_id' => $form_id, 'post_id' => $post_id, 'page_url' => $post_id ? esc_url( get_permalink( $post_id ) ) : '', ]; $turnstile_token = $request->get_param( 'cf_turnstile_response' ); if ( null !== $turnstile_token ) { $context['turnstile_token'] = $turnstile_token; } /** * Filter to verify a form submission before processing. * * Receives ALL submitted fields, including fields injected by third-party * plugins (e.g. Cloudflare Turnstile, hCaptcha). These extra fields are * available here but will be stripped during schema-based sanitization, * so this is the correct hook for verifying them. * * Return a WP_Error to reject the submission. * * @since 2.6.0 * @param null|WP_Error $pre_result Default null. Return WP_Error to reject. * @param int $form_id The form post ID. * @param array $raw_fields All submitted fields. * @param array $context Request context (post_id, page_url). */ $pre_result = apply_filters( 'generateblocks_form_verify_submission', null, $form_id, $raw_fields, $context ); if ( is_wp_error( $pre_result ) ) { return $this->generic_error( 'pre_verify:' . $pre_result->get_error_code() ); } $result = GenerateBlocks_Pro_Form_Processor::process( $form_id, $post_id, $raw_fields, $context ); if ( is_wp_error( $result ) ) { $error_data = $result->get_error_data(); $public_message = ( is_array( $error_data ) && ! empty( $error_data['public_message'] ) && is_string( $error_data['public_message'] ) ) ? $error_data['public_message'] : ''; return $this->generic_error( 'processor:' . $result->get_error_code() . ':' . $result->get_error_message(), false, $public_message ); } /** * Fires after a form submission has been successfully processed. * * @since 2.6.0 * @param int $form_id The form post ID. * @param array $context Request context (post_id, page_url). */ do_action( 'generateblocks_form_after_process', $form_id, $context ); return new WP_REST_Response( [ 'success' => true ], 200 ); } /** * Permission callback for admin routes. * * @return bool */ public function admin_permission() { return GenerateBlocks_Pro_Form_Post_Type::current_user_can( 'manage' ); } /** * Validate a form_id resolves to a published/draft `gblocks_form` post. * * @param int $form_id Form post ID. * @return bool */ private static function is_form_post( $form_id ) { $post = get_post( $form_id ); return $post instanceof WP_Post && GenerateBlocks_Pro_Form_Post_Type::POST_TYPE === $post->post_type; } /** * Check whether a form has opted into submission storage. * * @param int $form_id Form post ID. * @return bool */ private static function form_submission_storage_enabled( $form_id ) { $meta = get_post_meta( absint( $form_id ), GenerateBlocks_Pro_Form_Post_Type::META_KEY, true ); $meta = is_array( $meta ) ? $meta : []; return ! empty( $meta['config']['storeSubmissions'] ); } /** * List stored submissions for a single form. * * @param WP_REST_Request $request The REST request. * @return WP_REST_Response */ public function list_form_submissions( WP_REST_Request $request ) { $form_id = absint( $request->get_param( 'id' ) ); $page = max( 1, absint( $request->get_param( 'page' ) ) ); $per_page = absint( $request->get_param( 'per_page' ) ); $per_page = $per_page ? $per_page : GenerateBlocks_Pro_Form_Submissions::DEFAULT_PAGE_SIZE; $per_page = max( 1, min( GenerateBlocks_Pro_Form_Submissions::MAX_PAGE_SIZE, $per_page ) ); if ( ! self::is_form_post( $form_id ) ) { return new WP_REST_Response( [ 'success' => false, 'message' => 'Form not found.', ], 404 ); } $storage_enabled = self::form_submission_storage_enabled( $form_id ); if ( $storage_enabled && ! GenerateBlocks_Pro_Form_Submissions::table_exists() ) { GenerateBlocks_Pro_Form_Submissions::maybe_install( $form_id ); } $total = GenerateBlocks_Pro_Form_Submissions::total_for_form( $form_id ); $total_pages = $total > 0 ? (int) ceil( $total / $per_page ) : 1; $page = min( $page, $total_pages ); $items = GenerateBlocks_Pro_Form_Submissions::get_for_form( $form_id, $page, $per_page ); $limits = GenerateBlocks_Pro_Form_Submissions::storage_limits_for_form( $form_id ); return new WP_REST_Response( [ 'items' => $items, 'total' => $total, 'totalPages' => $total_pages, 'page' => $page, 'perPage' => $per_page, 'storageEnabled' => $storage_enabled, 'storageError' => GenerateBlocks_Pro_Form_Submissions::get_storage_error( $form_id ), 'maxRecords' => $limits['max_records'], 'retentionDays' => $limits['retention_days'], ], 200 ); } /** * Delete one stored submission (scoped to its form). * * @param WP_REST_Request $request The REST request. * @return WP_REST_Response */ public function delete_form_submission( WP_REST_Request $request ) { $form_id = absint( $request->get_param( 'id' ) ); $submission_id = absint( $request->get_param( 'submissionId' ) ); if ( ! self::is_form_post( $form_id ) ) { return new WP_REST_Response( [ 'success' => false, 'message' => 'Form not found.', ], 404 ); } $result = GenerateBlocks_Pro_Form_Submissions::delete_record( $form_id, $submission_id ); if ( true === $result ) { return new WP_REST_Response( [ 'success' => true, 'submissionId' => $submission_id, ], 200 ); } if ( null === $result ) { return new WP_REST_Response( [ 'success' => false, 'submissionId' => $submission_id, 'message' => 'Not found.', ], 404 ); } return new WP_REST_Response( [ 'success' => false, 'submissionId' => $submission_id, 'message' => 'Could not persist deletion; please retry.', ], 500 ); } /** * Delete every stored submission for a form. * * @param WP_REST_Request $request The REST request. * @return WP_REST_Response */ public function clear_form_submissions( WP_REST_Request $request ) { $form_id = absint( $request->get_param( 'id' ) ); if ( ! self::is_form_post( $form_id ) ) { return new WP_REST_Response( [ 'success' => false, 'message' => 'Form not found.', ], 404 ); } $deleted = GenerateBlocks_Pro_Form_Submissions::clear_for_form( $form_id ); if ( false === $deleted ) { return new WP_REST_Response( [ 'success' => false, 'message' => 'Could not persist clear; please retry.', ], 500 ); } return new WP_REST_Response( [ 'success' => true, 'deleted' => $deleted, ], 200 ); } /** * Duplicate a form post (content + meta). * * Status is forced to `draft` on the copy — seed_new_form() intentionally * skips `$update=true` inserts, but a fresh insert with `draft` gives the * user a chance to review before publishing regardless. * * @param WP_REST_Request $request The REST request. * @return WP_REST_Response */ public function duplicate_form( WP_REST_Request $request ) { $id = (int) $request->get_param( 'id' ); $post = get_post( $id ); if ( ! $post || GenerateBlocks_Pro_Form_Post_Type::POST_TYPE !== $post->post_type ) { return new WP_REST_Response( [ 'success' => false, 'message' => 'Not found.', ], 404 ); } $base_title = $post->post_title ? $post->post_title : __( '(untitled)', 'generateblocks-pro' ); // Mint fresh uniqueIds on every block before inserting. uniqueId drives // `gb-field-{id}` field IDs and the generated CSS class names; copying // post_content verbatim would let the original and the duplicate // collide if both end up on the same page. $new_content = self::regenerate_block_unique_ids( $post->post_content ); $new_id = wp_insert_post( wp_slash( [ 'post_type' => GenerateBlocks_Pro_Form_Post_Type::POST_TYPE, 'post_status' => 'draft', /* translators: %s: original form title */ 'post_title' => sprintf( __( '%s (Copy)', 'generateblocks-pro' ), $base_title ), 'post_content' => $new_content, ] ), true ); if ( is_wp_error( $new_id ) || ! $new_id ) { return new WP_REST_Response( [ 'success' => false, 'message' => 'Duplicate failed.', ], 500 ); } $meta = get_post_meta( $id, GenerateBlocks_Pro_Form_Post_Type::META_KEY, true ); if ( is_array( $meta ) ) { update_post_meta( $new_id, GenerateBlocks_Pro_Form_Post_Type::META_KEY, $meta ); } return new WP_REST_Response( [ 'success' => true, 'id' => (int) $new_id, ], 200 ); } /** * Toggle a single form's submission storage. * * Read-modify-write of the nested `_gb_form.config.storeSubmissions` flag * so the dashboard can enable storage from the storage-off failure view * without opening the block editor. Enabling installs the submissions * table via the meta-save hook on GenerateBlocks_Pro_Form_Post_Type, so * future submissions start being recorded immediately. * * @param WP_REST_Request $request The REST request. * @return WP_REST_Response */ public function update_form_storage( WP_REST_Request $request ) { $form_id = absint( $request->get_param( 'id' ) ); $enabled = (bool) $request->get_param( 'enabled' ); if ( ! self::is_form_post( $form_id ) ) { return new WP_REST_Response( [ 'success' => false, 'message' => 'Form not found.', ], 404 ); } $meta = get_post_meta( $form_id, GenerateBlocks_Pro_Form_Post_Type::META_KEY, true ); $meta = is_array( $meta ) ? $meta : []; // Preserve every other config key — only the storage flag changes. // Seed from the defaults when a form has never saved config so we // don't write a config blob that's missing expected keys. $config = isset( $meta['config'] ) && is_array( $meta['config'] ) ? $meta['config'] : GenerateBlocks_Pro_Form_Post_Type::default_form_config(); $config['storeSubmissions'] = $enabled; $meta['config'] = $config; // update_post_meta() unslashes values before saving. Slash the complete // blob so unrelated text config containing backslashes survives this // read-modify-write unchanged. update_post_meta( $form_id, GenerateBlocks_Pro_Form_Post_Type::META_KEY, wp_slash( $meta ) ); return new WP_REST_Response( [ 'success' => true, 'storageEnabled' => $enabled, ], 200 ); } /** * Walk parsed blocks and replace every `uniqueId` attribute with a fresh * 8-char hex value, matching the format the editor mints from * `clientId.substr(2, 9).replace('-', '')`. * * Returns serialized post_content. If parse fails or content is empty, * the original is returned untouched. * * @param string $post_content Post content to walk. * @return string Post content with regenerated uniqueIds. */ public static function regenerate_block_unique_ids( $post_content ) { if ( ! is_string( $post_content ) || '' === trim( $post_content ) ) { return $post_content; } $blocks = parse_blocks( $post_content ); if ( empty( $blocks ) ) { return $post_content; } $walk = static function ( &$blocks ) use ( &$walk ) { foreach ( $blocks as &$block ) { $old_id = $block['attrs']['uniqueId'] ?? ''; if ( '' !== $old_id ) { $new_id = self::mint_unique_id(); $block['attrs']['uniqueId'] = $new_id; // Three places carry the old id forward when only the attr is // rewritten: (1) `getBlockClasses` bakes `{slug}-{uniqueId}` // into the saved wrapper class in the block's innerContent; // (2) innerHTML mirrors innerContent and is read by some // block APIs; (3) attrs.css holds the local stylesheet and // is emitted verbatim by GenerateBlocks_Block::get_css() as // selectors like `.gb-form-field-{uniqueId}`. Without all // three rewrites a duplicate renders with the new wrapper // class but CSS still targets the old id, so the duplicate // looks unstyled. The 8-char hex id shape makes string // replace effectively collision-free. $needle = '-' . $old_id; $replacement = '-' . $new_id; if ( ! empty( $block['innerContent'] ) && is_array( $block['innerContent'] ) ) { foreach ( $block['innerContent'] as $i => $chunk ) { if ( is_string( $chunk ) ) { $block['innerContent'][ $i ] = str_replace( $needle, $replacement, $chunk ); } } } if ( isset( $block['innerHTML'] ) && is_string( $block['innerHTML'] ) ) { $block['innerHTML'] = str_replace( $needle, $replacement, $block['innerHTML'] ); } if ( isset( $block['attrs']['css'] ) && is_string( $block['attrs']['css'] ) ) { $block['attrs']['css'] = str_replace( $needle, $replacement, $block['attrs']['css'] ); } } if ( ! empty( $block['innerBlocks'] ) ) { $walk( $block['innerBlocks'] ); } } }; $walk( $blocks ); return serialize_blocks( $blocks ); } /** * Mint an 8-char hex uniqueId in the same shape the editor uses. * * @return string */ private static function mint_unique_id() { // Editor mints via `clientId.substr(2, 9).replace('-', '')` — slicing // 9 chars from a UUID always crosses one dash, leaving 8 hex chars // after the strip. Match that 8-char shape so duplicates are // indistinguishable from editor-minted ids. $uuid = str_replace( '-', '', wp_generate_uuid4() ); return substr( $uuid, 0, 8 ); } /** * Issue a security token for a form on a given host page. * * @param WP_REST_Request $request The REST request. * @return WP_REST_Response */ public function issue_security_token( WP_REST_Request $request ) { $form_id = absint( $request->get_param( 'formId' ) ); $post_id = absint( $request->get_param( 'postId' ) ); if ( ! $form_id ) { return $this->security_token_error(); } // Softer origin check on the GET token issuer (see comments below). if ( ! $this->referer_or_origin_is_same_site() ) { return $this->security_token_error(); } // Confirm the form exists + is published. Without this, the issuer // could mint tokens for nonexistent or draft forms — submission // handler would reject them, but we want consistent failure modes. $form = get_post( $form_id ); if ( ! $form || GenerateBlocks_Pro_Form_Post_Type::POST_TYPE !== $form->post_type || 'publish' !== $form->post_status ) { return $this->security_token_error(); } // Host page (optional — forms might be rendered standalone). if ( $post_id ) { $host = get_post( $post_id ); if ( ! $host || ! in_array( $host->post_status, [ 'publish', 'private' ], true ) ) { return $this->security_token_error(); } if ( 'private' === $host->post_status && ! current_user_can( 'read_post', $post_id ) ) { return $this->security_token_error(); } if ( post_password_required( $host ) ) { return $this->security_token_error(); } } $token = GenerateBlocks_Pro_Form_Spam::generate_timestamp( $form_id, $post_id ); $ts_only = (int) explode( '-', $token, 2 )[0]; $hp_name = GenerateBlocks_Pro_Form_Spam::honeypot_field_name( $ts_only, $form_id, $post_id ); $response = new WP_REST_Response( [ 'gb_form_ts' => $token, 'hp_name' => $hp_name, ], 200 ); $response->header( 'Cache-Control', 'private, no-store, max-age=0' ); return $response; } /** * Generic error response for the token issuer. * * @return WP_REST_Response */ private function security_token_error() { $response = new WP_REST_Response( [ 'success' => false, 'message' => __( 'Unable to issue token.', 'generateblocks-pro' ), ], 200 ); $response->header( 'Cache-Control', 'private, no-store, max-age=0' ); return $response; } /** * Strict origin check for the POST submit endpoint. * * @return bool */ private function origin_is_allowed() { $origin = isset( $_SERVER['HTTP_ORIGIN'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_ORIGIN'] ) ) : ''; if ( '' !== $origin ) { return $this->matches_site( $origin ); } // Some legitimate requests arrive without an Origin header (older browsers, // strict Referrer-Policy documents). Fall back to Referer before the filter // so admins enabling `generateblocks_form_allow_missing_origin` keep // at least one same-site signal. $referer = isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : ''; if ( '' !== $referer ) { return $this->matches_site( $referer ); } return (bool) apply_filters( 'generateblocks_form_allow_missing_origin', false ); } /** * Softer origin check for the GET token issuer. * * @return bool */ private function referer_or_origin_is_same_site() { $origin = isset( $_SERVER['HTTP_ORIGIN'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_ORIGIN'] ) ) : ''; if ( '' !== $origin ) { return $this->matches_site( $origin ); } $referer = isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : ''; if ( '' !== $referer ) { return $this->matches_site( $referer ); } return (bool) apply_filters( 'generateblocks_form_allow_missing_origin', false ); } /** * Compare a URL's scheme+host+port to home_url(). * * @param string $url The URL to compare. * @return bool */ private function matches_site( $url ) { $parsed = wp_parse_url( $url ); $home = wp_parse_url( home_url() ); if ( empty( $parsed['scheme'] ) || empty( $parsed['host'] ) || empty( $home['scheme'] ) || empty( $home['host'] ) ) { return false; } $scheme = strtolower( $parsed['scheme'] ); if ( strtolower( $home['scheme'] ) !== $scheme ) { return false; } if ( strtolower( $parsed['host'] ) !== strtolower( $home['host'] ) ) { return false; } // Browsers omit default ports from Origin/Referer (so `https://x.com`, // not `https://x.com:443`), but home_url() may be configured with // explicit ports (or vice versa behind a reverse proxy). Resolve both // sides to the explicit port before comparing so a representational // difference doesn't cause a false rejection. $default_port = 'https' === $scheme ? 443 : ( 'http' === $scheme ? 80 : null ); $req_port = $parsed['port'] ?? $default_port; $home_port = $home['port'] ?? $default_port; return $req_port === $home_port; } /** * Return a generic error response. * * @param string $reason Internal reason for the rejection (logged only when WP_DEBUG). * @param bool $retryable Set when a fresh client retry could legitimately succeed * (currently only timestamp-token failures). Surfaced as * `retry: true` so the runtime knows it's safe to re-mint * a token and resubmit. Any failure path that may have * already executed side effects (action-success then * action-fail) MUST leave this false to avoid duplicates. * @param string $public_message Optional public-safe message for known configuration failures. * @return WP_REST_Response */ private function generic_error( $reason = '', $retryable = false, $public_message = '' ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG && '' !== $reason ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log( 'GB Form reject: ' . sanitize_text_field( $reason ) ); } $public_message = sanitize_text_field( (string) $public_message ); $payload = [ 'success' => false, 'message' => '' !== $public_message ? $public_message : __( 'Something went wrong. Please try again.', 'generateblocks-pro' ), ]; if ( '' !== $public_message ) { $payload['publicMessage'] = true; } if ( $retryable ) { $payload['retry'] = true; } return new WP_REST_Response( $payload, 200 ); } /** * Return a fake success response for honeypot-caught bots. * * @return WP_REST_Response */ private function fake_success() { return new WP_REST_Response( [ 'success' => true ], 200 ); } /** * Get the list of posts that embed a given form. * * Cached for 15 minutes per (form_id, limit) pair. Pass `refresh=true` * to bypass the cache — used by the delete modal so the warning reflects * current content at delete time. * * Cache invalidation is intentionally manual (refresh button in the UI), * matching the conditions usage cache: invalidating on every host-page * save would force us to clear caches across every form when any post * changes, which is too broad to be useful. * * @param WP_REST_Request $request The REST request. * @return WP_REST_Response */ public function get_form_usage( WP_REST_Request $request ) { $form_id = absint( $request->get_param( 'id' ) ); $limit = absint( $request->get_param( 'limit' ) ); $refresh = (bool) $request->get_param( 'refresh' ); if ( ! $form_id ) { return new WP_REST_Response( [ 'success' => false, 'message' => __( 'Form ID is required.', 'generateblocks-pro' ), ], 400 ); } if ( ! self::is_form_post( $form_id ) ) { return new WP_REST_Response( [ 'success' => false, 'message' => __( 'Form not found.', 'generateblocks-pro' ), ], 404 ); } $cache_key = sprintf( 'gb_form_usage_%d_limit%d', $form_id, $limit ); if ( ! $refresh ) { $cached = $this->get_usage_cache( $cache_key ); if ( false !== $cached ) { $cached['cached'] = true; $cached['cached_at'] = $cached['cached_at'] ?? time(); return new WP_REST_Response( [ 'success' => true, 'response' => $cached, ], 200 ); } } $usage_data = GenerateBlocks_Pro_Form_Post_Type::search_form_usage( $form_id, $limit ); $usage_data['cached_at'] = time(); $this->set_usage_cache( $cache_key, $usage_data, 900 ); $usage_data['cached'] = false; return new WP_REST_Response( [ 'success' => true, 'response' => $usage_data, ], 200 ); } /** * Delete a form, with a usage guard. * * Mirrors the conditions delete flow: without `force=true`, run a fresh * usage scan (with the per-page cap raised so we're less likely to miss * older content) and 409 if anything references the form. With * `force=true`, skip the guard and call `wp_delete_post()` directly. * * Trash is intentionally not supported here — forms are configuration, * not content, and a trashed form still leaves embedded blocks rendering * nothing on the frontend. * * @param WP_REST_Request $request The REST request. * @return WP_REST_Response|WP_Error */ public function delete_form( WP_REST_Request $request ) { $form_id = absint( $request->get_param( 'id' ) ); $force = (bool) $request->get_param( 'force' ); if ( ! $form_id ) { return new WP_REST_Response( [ 'success' => false, 'message' => __( 'Form ID is required.', 'generateblocks-pro' ), ], 400 ); } if ( ! self::is_form_post( $form_id ) ) { return new WP_REST_Response( [ 'success' => false, 'message' => __( 'Form not found.', 'generateblocks-pro' ), ], 404 ); } if ( ! $force ) { // Raise the scan cap at delete time so we're less likely to // miss a reference in older content. Sites can override // further via the standard filter. $raise_scan_cap = static function ( $max ) { $current = is_numeric( $max ) ? (int) $max : 5000; return max( $current, 50000 ); }; add_filter( 'generateblocks_usage_search_max_posts', $raise_scan_cap, 99 ); try { $usage = GenerateBlocks_Pro_Form_Post_Type::search_form_usage( $form_id, 50 ); } finally { remove_filter( 'generateblocks_usage_search_max_posts', $raise_scan_cap, 99 ); } $usage_found = ! empty( $usage['total'] ); $usage_scan_incomplete = ! empty( $usage['limited'] ) || ! empty( $usage['has_more'] ); if ( $usage_found || $usage_scan_incomplete ) { $this->clear_usage_caches( 'gb_form_usage_' . $form_id . '_' ); return new WP_Error( 'form_in_use', __( 'This form is still embedded in published content, or usage could not be fully verified. Resend the request with force=true to confirm deletion.', 'generateblocks-pro' ), [ 'status' => 409, 'usage' => $usage, ] ); } } $result = wp_delete_post( $form_id, true ); if ( ! $result ) { return new WP_REST_Response( [ 'success' => false, 'message' => __( 'Failed to delete form.', 'generateblocks-pro' ), ], 500 ); } $this->clear_usage_caches( 'gb_form_usage_' . $form_id . '_' ); return new WP_REST_Response( [ 'success' => true, 'deleted' => true, ], 200 ); } }