[Experiment] Additional field extensible sanitisation and validation handling (#44463)

* Fix field saving in account area

* Check for error notices in core form handler

* Tidy up additional field saving in api

* sanitize_additional_fields does not expect request

* Validate fields with extra context

* Revert "Tidy up additional field saving in api"

This reverts commit 872c8f4afb.

* Tidy update_customer_from_request

* validate_field docs

* Validation and sanitization hooks

* Address schema validates fields and address location

* Validate locations

* Frontend validation

* Remove empty error response

* Document account details hook

* field_key

* Improve validation routine

* Changelog

* Swap key and value in woocommerce_blocks_validate_additional_field hook

* woocommerce_blocks_validate_location_X_fields

* Validation and sanitization callbacks

* Update try catch blocks
This commit is contained in:
Mike Jolley 2024-02-13 18:40:55 +00:00 committed by GitHub
parent cea1c10122
commit 51a9da9f2c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 358 additions and 141 deletions

View File

@ -0,0 +1,4 @@
Significance: patch
Type: update
This is behind a feature flag.

View File

@ -353,12 +353,20 @@ class WC_Form_Handler {
$customer->save();
}
wc_add_notice( __( 'Account details changed successfully.', 'woocommerce' ) );
/**
* Hook: woocommerce_save_account_details.
*
* @since 3.6.0
* @param int $user_id User ID being saved.
*/
do_action( 'woocommerce_save_account_details', $user->ID );
wp_safe_redirect( wc_get_endpoint_url( 'edit-account', '', wc_get_page_permalink( 'myaccount' ) ) );
exit;
// Notices are checked here so that if something created a notice during the save hooks above, the redirect will not happen.
if ( 0 === wc_notice_count( 'error' ) ) {
wc_add_notice( __( 'Account details changed successfully.', 'woocommerce' ) );
wp_safe_redirect( wc_get_endpoint_url( 'edit-account', '', wc_get_page_permalink( 'myaccount' ) ) );
exit;
}
}
}
@ -901,7 +909,7 @@ class WC_Form_Handler {
$quantity = empty( $_REQUEST['quantity'] ) ? 1 : wc_stock_amount( wp_unslash( $_REQUEST['quantity'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$variations = array();
$product = wc_get_product( $product_id );
$product = wc_get_product( $product_id );
foreach ( $_REQUEST as $key => $value ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( 'attribute_' !== substr( $key, 0, 10 ) ) {

View File

@ -5,6 +5,7 @@ namespace Automattic\WooCommerce\Blocks\Domain\Services;
use Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry;
use WC_Customer;
use WC_Order;
use WP_Error;
/**
* Service class managing checkout fields and its related extensibility points.
@ -248,12 +249,43 @@ class CheckoutFields {
return array_merge( $keys, array( self::BILLING_FIELDS_KEY, self::SHIPPING_FIELDS_KEY, self::ADDITIONAL_FIELDS_KEY ) );
}
/**
* If a field does not declare a sanitization callback, this is the default sanitization callback.
*
* @param mixed $value Value to sanitize.
* @param array $field Field data.
* @return mixed
*/
public function default_sanitize_callback( $value, $field ) {
return wc_clean( $value );
}
/**
* If a field does not declare a validation callback, this is the default validation callback.
*
* @param mixed $value Value to sanitize.
* @param array $field Field data.
* @return WP_Error|void If there is a validation error, return an WP_Error object.
*/
public function default_validate_callback( $value, $field ) {
if ( ! empty( $field['required'] ) && empty( $value ) ) {
return new WP_Error(
'woocommerce_blocks_checkout_field_required',
sprintf(
// translators: %s is field key.
__( 'The field %s is required.', 'woocommerce' ),
$field['id']
)
);
}
}
/**
* Registers an additional field for Checkout.
*
* @param array $options The field options.
*
* @return \WP_Error|void True if the field was registered, a WP_Error otherwise.
* @return WP_Error|void True if the field was registered, a WP_Error otherwise.
*/
public function register_checkout_field( $options ) {
// Check the options and show warnings if they're not supplied. Return early if an error that would prevent registration is encountered.
@ -278,6 +310,8 @@ class CheckoutFields {
'required' => false,
'attributes' => array(),
'show_in_order_confirmation' => true,
'sanitize_callback' => array( $this, 'default_sanitize_callback' ),
'validate_callback' => array( $this, 'default_validate_callback' ),
)
);
@ -367,6 +401,18 @@ class CheckoutFields {
}
}
if ( ! empty( $options['sanitize_callback'] ) && ! is_callable( $options['sanitize_callback'] ) ) {
$message = sprintf( 'Unable to register field with id: "%s". %s', $id, 'The sanitize_callback must be a valid callback.' );
_doing_it_wrong( 'woocommerce_blocks_register_checkout_field', esc_html( $message ), '8.6.0' );
return false;
}
if ( ! empty( $options['validate_callback'] ) && ! is_callable( $options['validate_callback'] ) ) {
$message = sprintf( 'Unable to register field with id: "%s". %s', $id, 'The validate_callback must be a valid callback.' );
_doing_it_wrong( 'woocommerce_blocks_register_checkout_field', esc_html( $message ), '8.6.0' );
return false;
}
return true;
}
@ -537,73 +583,97 @@ class CheckoutFields {
}
/**
* Validate an additional field against any custom validation rules. The result should be a WP_Error or true.
* Sanitize an additional field against any custom sanitization rules.
*
* @param string $key The key of the field.
* @param mixed $field_value The value of the field.
* @param \WP_REST_Request $request The current API Request.
* @param string|null $address_type The type of address (billing, shipping, or null if the field is a contact/additional field).
* @since 8.7.0
* @param string $field_key The key of the field.
* @param mixed $field_value The value of the field.
* @return mixed
*/
public function sanitize_field( $field_key, $field_value ) {
try {
$field = $this->additional_fields[ $field_key ] ?? null;
if ( $field ) {
$field_value = call_user_func( $field['sanitize_callback'], $field_value, $field );
}
/**
* Allow custom sanitization of an additional field.
*
* @param mixed $field_value The value of the field being sanitized.
* @param string $field_key Key of the field being sanitized.
*
* @since 8.7.0
*/
return apply_filters( 'woocommerce_blocks_sanitize_additional_field', $field_value, $field_key );
} catch ( \Throwable $e ) {
// One of the filters errored so skip it. This allows the checkout process to continue.
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
trigger_error(
sprintf(
'Field sanitization for %s encountered an error. %s',
esc_html( $field_key ),
esc_html( $e->getMessage() )
),
E_USER_WARNING
);
}
return $field_value;
}
/**
* Validate an additional field against any custom validation rules.
*
* @since 8.6.0
*
* @param string $field_key The key of the field.
* @param mixed $field_value The value of the field.
* @return WP_Error
*/
public function validate_field( $key, $field_value, $request, $address_type = null ) {
public function validate_field( $field_key, $field_value ) {
$errors = new WP_Error();
$error = new \WP_Error();
try {
/**
* Filter the result of validating an additional field.
*
* @param \WP_Error $error A WP_Error that extensions may add errors to.
* @param mixed $field_value The value of the field.
* @param \WP_REST_Request $request The current API Request.
* @param string|null $address_type The type of address (billing, shipping, or null if the field is a contact/additional field).
*
* @since 8.6.0
*/
$filtered_result = apply_filters( 'woocommerce_blocks_validate_additional_field_' . $key, $error, $field_value, $request, $address_type );
$field = $this->additional_fields[ $field_key ] ?? null;
if ( $error !== $filtered_result ) {
if ( $field ) {
$validation = call_user_func( $field['validate_callback'], $field_value, $field );
// Different WP_Error was returned. This would remove errors from other filters. Skip filtering and allow the order to place without validating this field.
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
trigger_error(
sprintf(
'The filter %s encountered an error. One of the filters returned a new WP_Error. Filters should use the same WP_Error passed to the filter and use the WP_Error->add function to add errors. The field will not have any custom validation applied to it.',
'woocommerce_blocks_validate_additional_field_' . esc_html( $key ),
),
E_USER_WARNING
);
if ( is_wp_error( $validation ) ) {
$errors->merge_from( $validation );
}
}
/**
* Pass an error object to allow validation of an additional field.
*
* @param WP_Error $errors A WP_Error object that extensions may add errors to.
* @param string $field_key Key of the field being sanitized.
* @param mixed $field_value The value of the field being validated.
*
* @since 8.7.0
*/
do_action( 'woocommerce_blocks_validate_additional_field', $errors, $field_key, $field_value );
} catch ( \Throwable $e ) {
// One of the filters errored so skip them and validate the field. This allows the checkout process to continue.
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
trigger_error(
sprintf(
'The filter %s encountered an error. The field will not have any custom validation applied to it. %s',
'woocommerce_blocks_validate_additional_field_' . esc_html( $key ),
'Field validation for %s encountered an error. %s',
esc_html( $field_key ),
esc_html( $e->getMessage() )
),
E_USER_WARNING
);
return new \WP_Error();
}
if ( is_wp_error( $filtered_result ) ) {
return $filtered_result;
}
// If the filters didn't return a valid value, ignore them and return an empty WP_Error. This allows the checkout process to continue.
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
trigger_error(
sprintf(
'The filter %s did not return a valid value. The field will not have any custom validation applied to it.',
'woocommerce_blocks_validate_additional_field_' . esc_html( $key )
),
E_USER_WARNING
);
return new \WP_Error();
return $errors;
}
/**
@ -648,24 +718,6 @@ class CheckoutFields {
return $this->fields_locations['additional'];
}
/**
* Returns an array of fields definitions only meant for order.
*
* @return array An array of fields definitions.
*/
public function get_order_only_fields() {
// For now, all contact fields are order only fields, along with additional fields.
$order_fields_keys = array_merge( $this->get_contact_fields_keys(), $this->get_additional_fields_keys() );
return array_filter(
$this->get_additional_fields(),
function( $key ) use ( $order_fields_keys ) {
return in_array( $key, $order_fields_keys, true );
},
ARRAY_FILTER_USE_KEY
);
}
/**
* Returns an array of fields for a given group.
*
@ -688,17 +740,60 @@ class CheckoutFields {
}
/**
* Validates a field value for a given group.
* Validates a set of fields for a given location against custom validation rules.
*
* @param array $fields Array of key value pairs of field values to validate.
* @param string $location The location being validated (address|contact|additional).
* @param string $group The group to get the field value for (shipping|billing|'') in which '' refers to the additional group.
* @return WP_Error
*/
public function validate_fields_for_location( $fields, $location, $group = '' ) {
$errors = new WP_Error();
try {
/**
* Pass an error object to allow validation of an additional field.
*
* @param WP_Error $errors A WP_Error object that extensions may add errors to.
* @param mixed $fields List of fields (key value pairs) in this location.
* @param string $group The group of this location (shipping|billing|'').
*
* @since 8.7.0
*/
do_action( 'woocommerce_blocks_validate_location_' . $location . '_fields', $errors, $fields, $group );
} catch ( \Throwable $e ) {
// One of the filters errored so skip them. This allows the checkout process to continue.
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
trigger_error(
sprintf(
'The action %s encountered an error. The field location %s may not have any custom validation applied to it. %s',
esc_html( 'woocommerce_blocks_validate_' . $location . '_fields' ),
esc_html( $location ),
esc_html( $e->getMessage() )
),
E_USER_WARNING
);
}
return $errors;
}
/**
* Validates a field to check it belongs to the given location and is valid according to its registration.
*
* This does not apply any custom validation rules on the value.
*
* @param string $key The field key.
* @param mixed $value The field value.
* @param string $location The location to validate the field for (address|contact|additional).
*
* @return true|\WP_Error True if the field is valid, a WP_Error otherwise.
* @return true|WP_Error True if the field is valid, a WP_Error otherwise.
*/
public function validate_field_for_location( $key, $value, $location ) {
if ( ! $this->is_field( $key ) ) {
return new \WP_Error(
return new WP_Error(
'woocommerce_blocks_checkout_field_invalid',
\sprintf(
// translators: % is field key.
@ -709,7 +804,7 @@ class CheckoutFields {
}
if ( ! in_array( $key, $this->fields_locations[ $location ], true ) ) {
return new \WP_Error(
return new WP_Error(
'woocommerce_blocks_checkout_field_invalid_location',
\sprintf(
// translators: %1$s is field key, %2$s location.
@ -722,7 +817,7 @@ class CheckoutFields {
$field = $this->additional_fields[ $key ];
if ( ! empty( $field['required'] ) && empty( $value ) ) {
return new \WP_Error(
return new WP_Error(
'woocommerce_blocks_checkout_field_required',
\sprintf(
// translators: %s is field key.
@ -986,6 +1081,28 @@ class CheckoutFields {
);
}
/**
* From a set of fields, returns only the ones for a given location.
*
* @param array $fields The fields to filter.
* @param string $location The location to validate the field for (address|contact|additional).
* @return array The filtered fields.
*/
public function filter_fields_for_location( $fields, $location ) {
return array_filter(
$fields,
function( $key ) use ( $location ) {
if ( 0 === strpos( $key, '/billing/' ) ) {
$key = str_replace( '/billing/', '', $key );
} elseif ( 0 === strpos( $key, '/shipping/' ) ) {
$key = str_replace( '/shipping/', '', $key );
}
return $this->is_field( $key ) && $this->get_field_location( $key ) === $location;
},
ARRAY_FILTER_USE_KEY
);
}
/**
* Filter fields for order confirmation.
*

View File

@ -37,12 +37,14 @@ class CheckoutFieldsFrontend {
// Show custom checkout fields on the My Account page.
add_action( 'woocommerce_my_account_after_my_address', array( $this, 'render_address_fields' ), 10, 1 );
// Field editing in my account area.
// Edit account form under my account (for contact details).
add_filter( 'woocommerce_save_account_details_required_fields', array( $this, 'edit_account_form_required_fields' ), 10, 1 );
add_filter( 'woocommerce_edit_account_form_fields', array( $this, 'edit_account_form_fields' ), 10, 1 );
add_action( 'woocommerce_save_account_details', array( $this, 'save_account_form_fields' ), 10, 1 );
// Edit address form under my account.
add_filter( 'woocommerce_address_to_edit', array( $this, 'edit_address_fields' ), 10, 2 );
add_action( 'woocommerce_after_save_address_validation', array( $this, 'save_address_fields' ), 10, 2 );
add_action( 'woocommerce_after_save_address_validation', array( $this, 'save_address_fields' ), 10, 4 );
}
/**
@ -177,20 +179,46 @@ class CheckoutFieldsFrontend {
/**
* Validates and saves additional address fields to the customer object on the My Account page.
*
* Customer is not provided by this hook so we handle save here.
*
* @param integer $user_id User ID.
*/
public function save_account_form_fields( $user_id ) {
$customer = new WC_Customer( $user_id );
$fields = $this->checkout_fields_controller->get_fields_for_location( 'contact' );
// phpcs:disable WordPress.Security.NonceVerification.Missing
foreach ( $fields as $field_key => $field ) {
if ( ! isset( $_POST[ $field_key ] ) ) {
$customer = new WC_Customer( $user_id );
$additional_fields = $this->checkout_fields_controller->get_fields_for_location( 'contact' );
$field_values = array();
foreach ( $additional_fields as $key => $field ) {
if ( ! isset( $_POST[ $key ] ) ) {
continue;
}
$this->checkout_fields_controller->persist_field_for_customer( $field_key, wc_clean( wp_unslash( $_POST[ $field_key ] ) ), $customer );
$field_value = $this->checkout_fields_controller->sanitize_field( $key, wc_clean( wp_unslash( $_POST[ $key ] ) ) );
$validation = $this->checkout_fields_controller->validate_field( $key, $field_value );
if ( is_wp_error( $validation ) && $validation->has_errors() ) {
wc_add_notice( $validation->get_error_message(), 'error' );
continue;
}
$field_values[ $key ] = $field_value;
}
// Persist individual additional fields to customer.
foreach ( $field_values as $key => $value ) {
$this->checkout_fields_controller->persist_field_for_customer( $key, $value, $customer );
}
// Validate all fields for this location.
$location_validation = $this->checkout_fields_controller->validate_fields_for_location( $field_values, 'contact' );
if ( is_wp_error( $location_validation ) && $location_validation->has_errors() ) {
wc_add_notice( $location_validation->get_error_message(), 'error' );
}
// phpcs:enable WordPress.Security.NonceVerification.Missing
$customer->save();
}
/**
@ -205,7 +233,7 @@ class CheckoutFieldsFrontend {
$fields = $this->checkout_fields_controller->get_fields_for_location( 'address' );
foreach ( $fields as $key => $field ) {
$field_key = 'billing' === $address_type ? '/billing/' . $key : '/shipping/' . $key;
$field_key = "/{$address_type}/{$key}";
$address[ $field_key ] = $field;
$address[ $field_key ]['value'] = $this->checkout_fields_controller->get_field_from_customer( $key, $customer, $address_type );
@ -223,33 +251,49 @@ class CheckoutFieldsFrontend {
}
/**
* Validates and saves additional address fields to the customer object on the My Account page.
* For the My Account page, save address fields. This uses the Store API endpoint for saving addresses so
* extensibility hooks are consistent across the codebase.
*
* @param integer $user_id User ID.
* @param string $address_type Type of address (billing or shipping).
* The caller saves the customer object if there are no errors. Nonces are checked before this method executes.
*
* @param integer $user_id User ID.
* @param string $address_type Type of address (billing or shipping).
* @param array $address Address fields.
* @param WC_Customer $customer Customer object.
*/
public function save_address_fields( $user_id, $address_type ) {
$customer = new WC_Customer( $user_id );
$fields = $this->checkout_fields_controller->get_fields_for_location( 'address' );
// Nonces are checked before the action is fired that this function hooks into.
public function save_address_fields( $user_id, $address_type, $address, $customer ) {
// phpcs:disable WordPress.Security.NonceVerification.Missing
foreach ( $fields as $key => $field ) {
$field_key = 'billing' === $address_type ? '/billing/' . $key : '/shipping/' . $key;
$additional_fields = $this->checkout_fields_controller->get_fields_for_location( 'address' );
$field_values = array();
if ( ! isset( $_POST[ $field_key ] ) ) {
foreach ( $additional_fields as $key => $field ) {
$post_key = "/{$address_type}/{$key}";
if ( ! isset( $_POST[ $post_key ] ) ) {
continue;
}
$value = wc_clean( wp_unslash( $_POST[ $field_key ] ) );
$field_value = $this->checkout_fields_controller->sanitize_field( $key, wc_clean( wp_unslash( $_POST[ $post_key ] ) ) );
$validation = $this->checkout_fields_controller->validate_field( $key, $field_value );
if ( ! empty( $field['required'] ) && empty( $value ) ) {
// translators: %s field label.
wc_add_notice( sprintf( __( '%s is a required field.', 'woocommerce' ), $field['label'] ), 'error' );
if ( is_wp_error( $validation ) && $validation->has_errors() ) {
wc_add_notice( $validation->get_error_message(), 'error' );
continue;
}
$this->checkout_fields_controller->persist_field_for_customer( $field_key, $value, $customer );
$field_values[ $key ] = $field_value;
}
// Persist individual additional fields to customer.
foreach ( $field_values as $key => $value ) {
$this->checkout_fields_controller->persist_field_for_customer( "/{$address_type}/{$key}", $value, $customer );
}
// Validate all fields for this location.
$location_validation = $this->checkout_fields_controller->validate_fields_for_location( array_merge( $address, $field_values ), 'address', $address_type );
if ( is_wp_error( $location_validation ) && $location_validation->has_errors() ) {
wc_add_notice( $location_validation->get_error_message(), 'error' );
}
// phpcs:enable WordPress.Security.NonceVerification.Missing
}

View File

@ -424,8 +424,9 @@ class Checkout extends AbstractCartRoute {
// Billing address is a required field.
foreach ( $request['billing_address'] as $key => $value ) {
if ( is_callable( [ $customer, "set_billing_$key" ] ) ) {
$customer->{"set_billing_$key"}( $value );
$callback = "set_billing_$key";
if ( is_callable( [ $customer, $callback ] ) ) {
$customer->$callback( $value );
} elseif ( $this->additional_fields_controller->is_field( $key ) ) {
$this->additional_fields_controller->persist_field_for_customer( "/billing/$key", $value, $customer );
}
@ -435,10 +436,9 @@ class Checkout extends AbstractCartRoute {
$shipping_address_values = $request['shipping_address'] ?? $request['billing_address'];
foreach ( $shipping_address_values as $key => $value ) {
if ( is_callable( [ $customer, "set_shipping_$key" ] ) ) {
$customer->{"set_shipping_$key"}( $value );
} elseif ( 'phone' === $key ) {
$customer->update_meta_data( 'shipping_phone', $value );
$callback = "set_shipping_$key";
if ( is_callable( [ $customer, $callback ] ) ) {
$customer->$callback( $value );
} elseif ( $this->additional_fields_controller->is_field( $key ) ) {
$this->additional_fields_controller->persist_field_for_customer( "/shipping/$key", $value, $customer );
}

View File

@ -7,6 +7,7 @@ use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields;
use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
use Automattic\WooCommerce\StoreApi\SchemaController;
use Automattic\WooCommerce\Blocks\Package;
/**
* AddressSchema class.
*
@ -132,10 +133,12 @@ abstract class AbstractAddressSchema extends AbstractSchema {
$carry[ $key ] = $address['postcode'] ? wc_format_postcode( sanitize_text_field( wp_unslash( $address['postcode'] ) ), $address['country'] ) : '';
break;
default:
$rest_sanitized = rest_sanitize_value_from_schema( wp_unslash( $address[ $key ] ), $field_schema[ $key ], $key );
$carry[ $key ] = $rest_sanitized;
$carry[ $key ] = rest_sanitize_value_from_schema( wp_unslash( $address[ $key ] ), $field_schema[ $key ], $key );
break;
}
if ( $this->additional_fields_controller->is_field( $key ) ) {
$carry[ $key ] = $this->additional_fields_controller->sanitize_field( $key, $carry[ $key ] );
}
return $carry;
},
[]
@ -247,8 +250,7 @@ abstract class AbstractAddressSchema extends AbstractSchema {
// Check if a field is in the list of additional fields then validate the value against the custom validation rules defined for it.
// Skip additional validation if the schema validation failed.
if ( true === $result && in_array( $key, $additional_keys, true ) ) {
$address_type = 'shipping_address' === $this->title ? 'shipping' : 'billing';
$result = $this->additional_fields_controller->validate_field( $key, $address[ $key ], $request, $address_type );
$result = $this->additional_fields_controller->validate_field( $key, $address[ $key ] );
}
if ( is_wp_error( $result ) && $result->has_errors() ) {
@ -256,6 +258,12 @@ abstract class AbstractAddressSchema extends AbstractSchema {
}
}
$result = $this->additional_fields_controller->validate_fields_for_location( $address, 'address', 'billing_address' === $this->title ? 'billing' : 'shipping' );
if ( is_wp_error( $result ) && $result->has_errors() ) {
$errors->merge_from( $result );
}
return $errors->has_errors( $errors ) ? $errors : true;
}

View File

@ -74,6 +74,7 @@ class CheckoutSchema extends AbstractSchema {
* @return array
*/
public function get_properties() {
$additional_field_schema = $this->get_additional_fields_schema();
return [
'order_id' => [
'description' => __( 'The order ID to process during checkout.', 'woocommerce' ),
@ -182,12 +183,12 @@ class CheckoutSchema extends AbstractSchema {
'description' => __( 'Additional fields to be persisted on the order.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'properties' => $this->get_additional_fields_schema(),
'properties' => $additional_field_schema,
'arg_options' => [
'sanitize_callback' => [ $this, 'sanitize_additional_fields' ],
'validate_callback' => [ $this, 'validate_additional_fields' ],
],
'required' => $this->is_additional_fields_required(),
'required' => $this->schema_has_required_property( $additional_field_schema ),
],
self::EXTENDING_KEY => $this->get_extended_schema( self::IDENTIFIER ),
];
@ -282,10 +283,22 @@ class CheckoutSchema extends AbstractSchema {
* @return array
*/
protected function get_additional_fields_schema() {
$order_only_fields = $this->additional_fields_controller->get_order_only_fields();
return $this->generate_additional_fields_schema(
$this->additional_fields_controller->get_fields_for_location( 'contact' ),
$this->additional_fields_controller->get_fields_for_location( 'additional' )
);
}
$schema = [];
foreach ( $order_only_fields as $key => $field ) {
/**
* Generate the schema for additional fields.
*
* @param array[] ...$args One or more arrays of additional fields.
* @return array
*/
protected function generate_additional_fields_schema( ...$args ) {
$additional_fields = array_merge( ...$args );
$schema = [];
foreach ( $additional_fields as $key => $field ) {
$field_schema = [
'description' => $field['label'],
'type' => 'string',
@ -314,10 +327,10 @@ class CheckoutSchema extends AbstractSchema {
/**
* Check if any additional field is required, so that the parent item is required as well.
*
* @param array $additional_fields_schema Additional fields schema.
* @return bool
*/
public function is_additional_fields_required() {
$additional_fields_schema = $this->get_additional_fields_schema();
protected function schema_has_required_property( $additional_fields_schema ) {
return array_reduce(
array_keys( $additional_fields_schema ),
function( $carry, $key ) use ( $additional_fields_schema ) {
@ -336,19 +349,21 @@ class CheckoutSchema extends AbstractSchema {
public function sanitize_additional_fields( $fields ) {
$properties = $this->get_additional_fields_schema();
$sanitization_utils = new SanitizationUtils();
$fields = array_reduce(
array_keys( $fields ),
function( $carry, $key ) use ( $fields, $properties ) {
if ( ! isset( $properties[ $key ] ) ) {
$fields = $sanitization_utils->wp_kses_array(
array_reduce(
array_keys( $fields ),
function( $carry, $key ) use ( $fields, $properties ) {
if ( ! isset( $properties[ $key ] ) ) {
return $carry;
}
$field_schema = $properties[ $key ];
$rest_sanitized = rest_sanitize_value_from_schema( wp_unslash( $fields[ $key ] ), $field_schema, $key );
$rest_sanitized = $this->additional_fields_controller->sanitize_field( $key, $rest_sanitized );
$carry[ $key ] = $rest_sanitized;
return $carry;
}
$field_schema = $properties[ $key ];
$rest_sanitized = rest_sanitize_value_from_schema( wp_unslash( $fields[ $key ] ), $field_schema, $key );
$carry[ $key ] = $rest_sanitized;
return $carry;
},
[]
},
[]
)
);
return $sanitization_utils->wp_kses_array( $fields );
@ -364,32 +379,30 @@ class CheckoutSchema extends AbstractSchema {
* @return true|\WP_Error
*/
public function validate_additional_fields( $fields, $request ) {
$errors = new \WP_Error();
$fields = $this->sanitize_additional_fields( $fields, $request );
$properties = $this->get_additional_fields_schema();
$errors = new \WP_Error();
$fields = $this->sanitize_additional_fields( $fields );
$additional_field_schema = $this->get_additional_fields_schema();
foreach ( array_keys( $properties ) as $key ) {
if ( ! isset( $fields[ $key ] ) && false === $properties[ $key ]['required'] ) {
// Validate individual properties first.
foreach ( $fields as $key => $field_value ) {
if ( ! isset( $additional_field_schema[ $key ] ) ) {
continue;
}
$field_value = isset( $fields[ $key ] ) ? $fields[ $key ] : null;
$result = rest_validate_value_from_schema( $field_value, $properties[ $key ], $key );
$result = rest_validate_value_from_schema( $field_value, $additional_field_schema[ $key ], $key );
// Only allow custom validation on fields that pass the schema validation.
if ( true === $result ) {
$result = $this->additional_fields_controller->validate_field( $key, $field_value, $request );
$result = $this->additional_fields_controller->validate_field( $key, $field_value );
}
if ( is_wp_error( $result ) && $result->has_errors() ) {
$location = $this->additional_fields_controller->get_field_location( $key );
foreach ( $result->get_error_codes() as $code ) {
$result->add_data(
[
array(
'location' => $location,
'key' => $key,
],
),
$code
);
}
@ -397,6 +410,18 @@ class CheckoutSchema extends AbstractSchema {
}
}
// Validate groups of properties per registered location.
$locations = array( 'contact', 'additional' );
foreach ( $locations as $location ) {
$location_fields = $this->additional_fields_controller->filter_fields_for_location( $fields, $location );
$result = $this->additional_fields_controller->validate_fields_for_location( $location_fields, $location );
if ( is_wp_error( $result ) && $result->has_errors() ) {
$errors->merge_from( $result );
}
}
return $errors->has_errors( $errors ) ? $errors : true;
}
}

View File

@ -339,6 +339,7 @@ class OrderController {
$errors_by_code = array();
$error_codes = $errors->get_error_codes();
foreach ( $error_codes as $code ) {
$errors_by_code[ $code ] = $errors->get_error_messages( $code );
}
@ -417,6 +418,16 @@ class OrderController {
$errors->add( $address_type, sprintf( __( '%s is required', 'woocommerce' ), $address_field['label'] ), $address_field_key );
}
}
// Validate additional fields.
$result = $this->additional_fields_controller->validate_fields_for_location( $address, 'address', $address_type );
if ( $result->has_errors() ) {
// Add errors to main error object but ensure they maintain the billing/shipping error code.
foreach ( $result->get_error_codes() as $code ) {
$errors->add( $address_type, $result->get_error_message( $code ), $code );
}
}
}
/**