Files
2026-07-02 15:54:39 -06:00

843 lines
22 KiB
PHP

<?php
/**
* REST endpoints for form integrations.
*
* Exposes registered provider metadata, connection management, and provider
* option lists to the form editor. Connection details stay server-side.
*
* API key storage strategy:
*
* Keys are stored as plain text in the options table, protected by the
* forms-manage capability on save/delete endpoints. We intentionally do not
* encrypt these keys because any encryption key must live on the same server.
* The real security boundary is capability access to these endpoints.
*
* @package GenerateBlocksPro
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Form integration REST class.
*/
class GenerateBlocks_Pro_Form_Integration_Rest extends GenerateBlocks_Pro_Singleton {
/**
* Per-request cache for resolved connection settings.
*
* @var array<string, string>
*/
private static $connection_cache = [];
/**
* Initialize routes.
*/
public function init() {
add_action( 'rest_api_init', [ $this, 'register_routes' ] );
}
/**
* Register REST routes.
*/
public function register_routes() {
$namespace = 'generateblocks-pro/v1';
register_rest_route(
$namespace,
'/integrations/(?P<service>[a-z0-9_-]+)',
[
'methods' => WP_REST_Server::EDITABLE,
'callback' => [ $this, 'save_settings' ],
'permission_callback' => [ $this, 'admin_permission' ],
'args' => [
'service' => [
'required' => true,
'validate_callback' => [ $this, 'validate_service' ],
],
'settings' => [
'required' => false,
'type' => 'object',
],
],
]
);
register_rest_route(
$namespace,
'/integrations/(?P<service>[a-z0-9_-]+)',
[
'methods' => WP_REST_Server::DELETABLE,
'callback' => [ $this, 'delete_settings' ],
'permission_callback' => [ $this, 'admin_permission' ],
'args' => [
'service' => [
'required' => true,
'validate_callback' => [ $this, 'validate_service' ],
],
],
]
);
register_rest_route(
$namespace,
'/integrations/(?P<service>[a-z0-9_-]+)/test',
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'test_connection' ],
'permission_callback' => [ $this, 'admin_permission' ],
'args' => [
'service' => [
'required' => true,
'validate_callback' => [ $this, 'validate_service' ],
],
'settings' => [
'required' => false,
'type' => 'object',
],
],
]
);
register_rest_route(
$namespace,
'/integrations/(?P<service>[a-z0-9_-]+)/destinations',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'fetch_destinations' ],
'permission_callback' => [ $this, 'admin_permission' ],
'args' => [
'service' => [
'required' => true,
'validate_callback' => [ $this, 'validate_service' ],
],
'nocache' => [
'required' => false,
'type' => 'boolean',
],
],
]
);
register_rest_route(
$namespace,
'/integrations/(?P<service>[a-z0-9_-]+)/secondary-destinations',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'fetch_secondary_destinations' ],
'permission_callback' => [ $this, 'admin_permission' ],
'args' => [
'service' => [
'required' => true,
'validate_callback' => [ $this, 'validate_service' ],
],
'nocache' => [
'required' => false,
'type' => 'boolean',
],
],
]
);
register_rest_route(
$namespace,
'/integrations/(?P<service>[a-z0-9_-]+)/fields',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'fetch_fields' ],
'permission_callback' => [ $this, 'admin_permission' ],
'args' => [
'service' => [
'required' => true,
'validate_callback' => [ $this, 'validate_service' ],
],
'nocache' => [
'required' => false,
'type' => 'boolean',
],
],
]
);
register_rest_route(
$namespace,
'/integrations',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_integrations' ],
'permission_callback' => [ $this, 'admin_permission' ],
]
);
}
/**
* Save provider connection settings.
*
* @param WP_REST_Request $request The request.
* @return WP_REST_Response
*/
public function save_settings( WP_REST_Request $request ) {
$service = $request->get_param( 'service' );
$settings = $this->sanitize_connection_settings(
$service,
$request->get_param( 'settings' )
);
$effective_settings = $this->get_effective_connection_settings( $service, $settings );
$validation = $this->validate_connection_settings( $service, $effective_settings );
if ( is_wp_error( $validation ) ) {
return new WP_REST_Response(
[
'success' => false,
'message' => $validation->get_error_message(),
],
200
);
}
$integrations = get_option( 'generateblocks_pro_form_integrations', [] );
$integrations = is_array( $integrations ) ? $integrations : [];
$current = isset( $integrations[ $service ] ) && is_array( $integrations[ $service ] )
? $integrations[ $service ]
: [];
$provider = GenerateBlocks_Pro_Form_Integration_Registry::get_provider( $service );
$connection = $provider['connection'] ?? [];
foreach ( $connection['fields'] ?? [] as $field ) {
$key = $field['key'];
if (
array_key_exists( $key, $settings )
&& ! in_array( $this->get_connection_field_source( $service, $field ), [ 'define', 'code' ], true )
) {
$current[ $key ] = $settings[ $key ];
}
}
$integrations[ $service ] = $current;
update_option( 'generateblocks_pro_form_integrations', $integrations, false );
self::clear_service_cache( $service );
return new WP_REST_Response( [ 'success' => true ], 200 );
}
/**
* Delete provider connection settings.
*
* @param WP_REST_Request $request The request.
* @return WP_REST_Response
*/
public function delete_settings( WP_REST_Request $request ) {
$service = $request->get_param( 'service' );
$integrations = get_option( 'generateblocks_pro_form_integrations', [] );
$integrations = is_array( $integrations ) ? $integrations : [];
unset( $integrations[ $service ] );
update_option( 'generateblocks_pro_form_integrations', $integrations, false );
self::clear_service_cache( $service );
return new WP_REST_Response( [ 'success' => true ], 200 );
}
/**
* Test a service connection with provided settings.
*
* @param WP_REST_Request $request The request.
* @return WP_REST_Response
*/
public function test_connection( WP_REST_Request $request ) {
$service = $request->get_param( 'service' );
$settings = $this->sanitize_connection_settings(
$service,
$request->get_param( 'settings' )
);
$effective_settings = $this->get_effective_connection_settings( $service, $settings );
$validation = $this->validate_connection_settings( $service, $effective_settings, true );
if ( is_wp_error( $validation ) ) {
return new WP_REST_Response(
[
'success' => false,
'message' => $validation->get_error_message(),
],
200
);
}
$result = $this->test_service( $service, $effective_settings );
if ( is_wp_error( $result ) ) {
return new WP_REST_Response(
[
'success' => false,
'message' => $result->get_error_message(),
],
200
);
}
return new WP_REST_Response(
[
'success' => true,
'message' => __( 'Connected successfully.', 'generateblocks-pro' ),
],
200
);
}
/**
* Fetch primary destination options for a provider.
*
* @param WP_REST_Request $request The request.
* @return WP_REST_Response
*/
public function fetch_destinations( WP_REST_Request $request ) {
return $this->fetch_provider_options( $request, 'destination' );
}
/**
* Fetch secondary destination options for a provider.
*
* @param WP_REST_Request $request The request.
* @return WP_REST_Response
*/
public function fetch_secondary_destinations( WP_REST_Request $request ) {
return $this->fetch_provider_options( $request, 'secondary_destination' );
}
/**
* Fetch field-map options for a provider.
*
* @param WP_REST_Request $request The request.
* @return WP_REST_Response
*/
public function fetch_fields( WP_REST_Request $request ) {
return $this->fetch_provider_options( $request, 'field_map' );
}
/**
* Fetch options from a provider callback.
*
* @param WP_REST_Request $request The request.
* @param string $key Provider option key.
* @return WP_REST_Response
*/
private function fetch_provider_options( WP_REST_Request $request, $key ) {
$service = $request->get_param( 'service' );
$provider = GenerateBlocks_Pro_Form_Integration_Registry::get_provider( $service );
$config = $provider[ $key ] ?? [];
$callback = $config['options_callback'] ?? null;
if ( ! is_callable( $callback ) ) {
return new WP_REST_Response(
[
'success' => true,
'options' => [],
],
200
);
}
$connection_ready = $this->connection_ready_for_options( $service );
if ( is_wp_error( $connection_ready ) ) {
return new WP_REST_Response(
[
'success' => false,
'message' => $connection_ready->get_error_message(),
],
200
);
}
if ( ! $connection_ready ) {
return new WP_REST_Response(
[
'success' => false,
'message' => __( 'Integration is not connected.', 'generateblocks-pro' ),
],
200
);
}
$settings = $this->get_option_request_settings( $request, $service );
$cache_key = 'gb_form_options_' . $service . '_' . $key . '_' . md5(
wp_json_encode( [ self::connection_cache_hash( $service ), $settings ] )
);
$nocache = (bool) $request->get_param( 'nocache' );
$cached = $nocache ? false : get_transient( $cache_key );
if ( false !== $cached ) {
return new WP_REST_Response(
[
'success' => true,
'options' => $cached,
],
200
);
}
$options = call_user_func( $callback, $settings, $request, $service );
if ( is_wp_error( $options ) ) {
return new WP_REST_Response(
[
'success' => false,
'message' => $options->get_error_message(),
],
200
);
}
$options = GenerateBlocks_Pro_Form_Integration_Registry::normalize_options( $options );
set_transient( $cache_key, $options, 5 * MINUTE_IN_SECONDS );
return new WP_REST_Response(
[
'success' => true,
'options' => $options,
],
200
);
}
/**
* Get public integration definitions and connection state.
*
* @return WP_REST_Response
*/
public function get_integrations() {
$connected = [];
foreach ( GenerateBlocks_Pro_Form_Integration_Registry::get_all() as $service => $provider ) {
$connected[ $service ] = $this->get_connection_state( $service, $provider );
}
return new WP_REST_Response(
[
'success' => true,
'integrations' => GenerateBlocks_Pro_Form_Integration_Registry::get_public_definitions(),
'connected' => $connected,
],
200
);
}
/**
* Get the API key for a service.
*
* @param string $service The service name.
* @return string The API key, or empty string if not configured.
*/
public static function get_api_key_for( $service ) {
return self::get_connection_setting_for( $service, 'api_key' );
}
/**
* Get the site key for a service.
*
* @param string $service The service name.
* @return string The site key, or empty string if not configured.
*/
public static function get_site_key_for( $service ) {
return self::get_connection_setting_for( $service, 'site_key' );
}
/**
* Get a connection setting for a service.
*
* Priority chain:
* 1. wp-config.php define registered on the field.
* 2. In-memory registered key for api_key.
* 3. Database option saved via admin UI.
*
* @param string $service Service ID.
* @param string $key Setting key.
* @return string
*/
public static function get_connection_setting_for( $service, $key ) {
$service = sanitize_key( $service );
$key = preg_replace( '/[^A-Za-z0-9_]/', '', (string) $key );
$cache_key = $service . ':' . $key;
if ( array_key_exists( $cache_key, self::$connection_cache ) ) {
return self::$connection_cache[ $cache_key ];
}
$value = self::resolve_connection_setting( $service, $key );
self::$connection_cache[ $cache_key ] = $value;
return $value;
}
/**
* Resolve a connection setting without cache.
*
* @param string $service Service ID.
* @param string $key Setting key.
* @return string
*/
private static function resolve_connection_setting( $service, $key ) {
$provider = GenerateBlocks_Pro_Form_Integration_Registry::get_provider( $service );
$connection = $provider['connection'] ?? [];
$define = '';
foreach ( $connection['fields'] ?? [] as $field ) {
if ( $field['key'] === $key ) {
$define = $field['define'];
break;
}
}
if ( $define && defined( $define ) ) {
$value = (string) constant( $define );
if ( '' !== trim( $value ) ) {
return $value;
}
}
if ( 'api_key' === $key ) {
$registered = GenerateBlocks_Pro_Form_Integration_Registry::get( $service );
if ( ! empty( $registered ) ) {
return (string) $registered;
}
}
$integrations = get_option( 'generateblocks_pro_form_integrations', [] );
$integrations = is_array( $integrations ) ? $integrations : [];
return isset( $integrations[ $service ][ $key ] )
? (string) $integrations[ $service ][ $key ]
: '';
}
/**
* Sanitize connection settings against registered connection fields.
*
* @param string $service Service ID.
* @param array $settings Raw settings.
* @return array
*/
private function sanitize_connection_settings( $service, $settings ) {
if ( ! is_array( $settings ) ) {
return [];
}
$provider = GenerateBlocks_Pro_Form_Integration_Registry::get_provider( $service );
$fields = $provider['connection']['fields'] ?? [];
$out = [];
foreach ( $fields as $field ) {
$key = $field['key'];
if ( isset( $settings[ $key ] ) && ! is_array( $settings[ $key ] ) && ! is_object( $settings[ $key ] ) ) {
$value = sanitize_text_field( (string) $settings[ $key ] );
if ( '' !== trim( $value ) ) {
$out[ $key ] = $value;
}
}
}
return $out;
}
/**
* Merge submitted settings with currently resolved settings.
*
* Blank modal fields mean "leave the existing code/DB value alone".
*
* @param string $service Service ID.
* @param array $settings Sanitized submitted settings.
* @return array
*/
private function get_effective_connection_settings( $service, $settings ) {
$provider = GenerateBlocks_Pro_Form_Integration_Registry::get_provider( $service );
$fields = $provider['connection']['fields'] ?? [];
$out = [];
foreach ( $fields as $field ) {
$key = $field['key'];
$out[ $key ] = array_key_exists( $key, $settings )
? $settings[ $key ]
: self::get_connection_setting_for( $service, $key );
}
return $out;
}
/**
* Validate sanitized connection settings against required fields.
*
* @param string $service Service ID.
* @param array $settings Sanitized settings.
* @param bool $for_test Whether this validation is for a connection test.
* @return true|WP_Error
*/
private function validate_connection_settings( $service, $settings, $for_test = false ) {
$provider = GenerateBlocks_Pro_Form_Integration_Registry::get_provider( $service );
$fields = $provider['connection']['fields'] ?? [];
foreach ( $fields as $field ) {
$required_key = $for_test ? 'test_required' : 'required';
if ( empty( $field[ $required_key ] ) ) {
continue;
}
$key = $field['key'];
$value = isset( $settings[ $key ] ) ? trim( (string) $settings[ $key ] ) : '';
if ( '' === $value ) {
return new WP_Error(
'missing_connection_setting',
sprintf(
/* translators: %s: connection setting label */
__( '%s is required.', 'generateblocks-pro' ),
$field['label']
)
);
}
}
return true;
}
/**
* Build sanitized settings from option route query params.
*
* @param WP_REST_Request $request The request.
* @param string $service Service ID.
* @return array
*/
private function get_option_request_settings( WP_REST_Request $request, $service ) {
$provider = GenerateBlocks_Pro_Form_Integration_Registry::get_provider( $service );
$params = $request->get_params();
$allowed = [
'destinationId' => true,
'secondaryDestinationId' => true,
];
$out = [];
foreach ( $provider['connection']['fields'] ?? [] as $field ) {
$allowed[ $field['key'] ] = true;
}
foreach ( $params as $key => $value ) {
$key = (string) $key;
if ( ! isset( $allowed[ $key ] ) || is_array( $value ) || is_object( $value ) ) {
continue;
}
$out[ $key ] = sanitize_text_field( (string) $value );
}
ksort( $out );
return $out;
}
/**
* Test a specific provider connection.
*
* @param string $service Service ID.
* @param array $settings Connection settings.
* @return true|WP_Error
*/
private function test_service( $service, $settings ) {
$provider = GenerateBlocks_Pro_Form_Integration_Registry::get_provider( $service );
$callback = $provider['connection']['test_callback'] ?? null;
if ( ! is_callable( $callback ) ) {
return true;
}
return call_user_func( $callback, $settings, $service );
}
/**
* Determine whether a provider is connected enough to fetch options.
*
* @param string $service Service ID.
* @return bool|WP_Error
*/
private function connection_ready_for_options( $service ) {
$provider = GenerateBlocks_Pro_Form_Integration_Registry::get_provider( $service );
$connection = $provider['connection'] ?? [];
if ( empty( $connection ) || 'none' === $connection['type'] ) {
return true;
}
if ( is_callable( $connection['connected_callback'] ) ) {
return call_user_func( $connection['connected_callback'], $service );
}
foreach ( $connection['fields'] ?? [] as $field ) {
if ( ! empty( $field['required'] ) && '' === self::get_connection_setting_for( $service, $field['key'] ) ) {
return false;
}
}
return true;
}
/**
* Get connection state for the editor.
*
* @param string $service Service ID.
* @param array $provider Provider definition.
* @return array
*/
private function get_connection_state( $service, $provider ) {
$connection = $provider['connection'] ?? [];
$callback_result = null;
$source = '';
$fields = [];
$public_fields = [];
$connected = true;
if ( empty( $connection ) || 'none' === $connection['type'] ) {
return [
'connected' => true,
'source' => '',
'fields' => [],
];
}
if ( is_callable( $connection['connected_callback'] ) ) {
$callback_result = call_user_func( $connection['connected_callback'], $service );
$connected = ! is_wp_error( $callback_result ) && (bool) $callback_result;
}
foreach ( $connection['fields'] ?? [] as $field ) {
$value = self::get_connection_setting_for( $service, $field['key'] );
$field_key = $field['key'];
$field_state = [
'configured' => '' !== $value,
'source' => $this->get_connection_field_source( $service, $field ),
];
$fields[ $field_key ] = $field_state;
if ( ! empty( $field['public'] ) && '' !== $value ) {
$public_fields[ $field_key ] = $value;
}
if ( '' === $source && '' !== $field_state['source'] ) {
$source = $field_state['source'];
}
if ( ! empty( $field['required'] ) && '' === $value ) {
$connected = false;
}
}
$state = [
'connected' => $connected,
'source' => $source,
'fields' => $fields,
];
if ( is_wp_error( $callback_result ) ) {
$state['message'] = $callback_result->get_error_message();
}
if ( ! empty( $public_fields ) ) {
$state['publicFields'] = $public_fields;
}
return $state;
}
/**
* Get the source for a connection field.
*
* @param string $service Service ID.
* @param array $field Connection field.
* @return string
*/
private function get_connection_field_source( $service, $field ) {
$define = $field['define'];
if ( $define && defined( $define ) && '' !== trim( (string) constant( $define ) ) ) {
return 'define';
}
if ( 'api_key' === $field['key'] && ! empty( GenerateBlocks_Pro_Form_Integration_Registry::get( $service ) ) ) {
return 'code';
}
$integrations = get_option( 'generateblocks_pro_form_integrations', [] );
$integrations = is_array( $integrations ) ? $integrations : [];
if ( ! empty( $integrations[ $service ][ $field['key'] ] ) ) {
return 'db';
}
return '';
}
/**
* Build a hash of current connection settings for option-cache keys.
*
* @param string $service Service ID.
* @return string
*/
private static function connection_cache_hash( $service ) {
$provider = GenerateBlocks_Pro_Form_Integration_Registry::get_provider( $service );
$values = [];
foreach ( $provider['connection']['fields'] ?? [] as $field ) {
$values[ $field['key'] ] = self::get_connection_setting_for( $service, $field['key'] );
}
return md5( wp_json_encode( $values ) );
}
/**
* Clear the per-request connection cache for a service.
*
* @param string $service Service ID.
*/
public static function clear_service_cache( $service ) {
$service = sanitize_key( $service );
foreach ( array_keys( self::$connection_cache ) as $key ) {
if ( 0 === strpos( $key, $service . ':' ) ) {
unset( self::$connection_cache[ $key ] );
}
}
}
/**
* Validate that the service parameter is registered.
*
* @param string $value The service name.
* @return bool
*/
public function validate_service( $value ) {
return (bool) GenerateBlocks_Pro_Form_Integration_Registry::get_provider( $value );
}
/**
* Permission check for all integration routes.
*
* @return bool
*/
public function admin_permission() {
return current_user_can( 'manage_options' );
}
}