Files
hub-insurance/wp-content/plugins/generateblocks-pro/includes/form/class-form-rest.php
T
2026-07-02 15:54:39 -06:00

1277 lines
37 KiB
PHP

<?php
/**
* Form submission REST endpoint.
*
* Public endpoint for form submissions. No auth required.
* Security: honeypot, timestamp, server-side field validation.
* All errors return generic messages. Honeypot failures return fake success.
*
* @package GenerateBlocksPro
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
require_once GENERATEBLOCKS_PRO_DIR . 'includes/utils/trait-usage-search.php';
/**
* Form REST endpoint class.
*/
class GenerateBlocks_Pro_Form_Rest extends GenerateBlocks_Pro_Singleton {
use GenerateBlocks_Pro_Usage_Search;
/**
* Initialize the REST routes.
*/
public function init() {
add_action( 'rest_api_init', [ $this, 'register_routes' ] );
}
/**
* Register REST routes.
*/
public function register_routes() {
// Security token issuer.
//
// Issues a fresh signed timestamp + honeypot name for a given form.
// Called by the frontend JS on form mount, *not* baked into the
// server-rendered HTML, so page caches (WP Rocket, Cloudflare APO,
// etc.) cannot freeze a single token into cached HTML.
register_rest_route(
'generateblocks-pro/v1',
'/forms/security',
[
'methods' => 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<id>\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<id>\d+)/submissions/(?P<submissionId>\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<id>\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<id>\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<id>\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<id>\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
);
}
}