PickupLocationsRestController.php
<?php
declare( strict_types = 1 );
namespace Automattic\WooCommerce\Blocks\Shipping;
/**
* REST controller for Local Pickup location settings.
*
* Exposes /wc/v3/pickup-locations so users with the manage_woocommerce
* capability (e.g. Shop Managers) can save Local Pickup settings without
* requiring the manage_options capability needed by /wp/v2/settings.
*
* @since 11.0.0
*/
class PickupLocationsRestController extends \WP_REST_Controller {
/**
* REST API namespace.
*
* @var string
*/
protected $namespace = 'wc/v3';
/**
* REST API resource base.
*
* @var string
*/
protected $rest_base = 'pickup-locations';
/**
* Register routes.
*
* @return void
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'update_settings' ),
'permission_callback' => array( $this, 'update_settings_permissions_check' ),
'args' => array(
'pickup_location_settings' => array(
'description' => __( 'Local pickup method settings.', 'woocommerce' ),
'type' => 'object',
),
'pickup_locations' => array(
'description' => __( 'List of local pickup locations.', 'woocommerce' ),
'type' => 'array',
),
),
),
)
);
}
/**
* Check whether the current user can update pickup location settings.
*
* @param \WP_REST_Request<array<string, mixed>> $request Request object.
* @return true|\WP_Error
*/
public function update_settings_permissions_check( $request ) {
if ( ! wc_rest_check_manager_permissions( 'settings', 'edit' ) ) {
return new \WP_Error(
'woocommerce_rest_cannot_edit',
__( 'Sorry, you cannot edit this resource.', 'woocommerce' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
/**
* Save pickup location settings and return the saved values.
*
* @param \WP_REST_Request<array<string, mixed>> $request Request object.
* @return \WP_REST_Response|\WP_Error
*/
public function update_settings( $request ) {
$settings = $request->get_param( 'pickup_location_settings' );
$locations = $request->get_param( 'pickup_locations' );
if ( is_array( $settings ) ) {
$settings = $this->sanitize_pickup_location_settings( $settings );
update_option( 'woocommerce_pickup_location_settings', $settings );
}
if ( is_array( $locations ) ) {
$locations = $this->sanitize_pickup_locations( $locations );
update_option( 'pickup_location_pickup_locations', $locations );
}
// The settings UI always saves both arrays together; a Tracks snapshot is
// only meaningful with both present, so skip partial (non-UI) updates.
if ( is_array( $settings ) && is_array( $locations ) ) {
$this->record_save_event( $settings, $locations );
}
return rest_ensure_response(
array(
'pickup_location_settings' => $settings,
'pickup_locations' => $locations,
)
);
}
/**
* Record a Tracks event summarising a Local Pickup settings save.
*
* @param array $settings Sanitized method settings.
* @param array $locations Sanitized list of pickup locations.
* @return void
*/
private function record_save_event( array $settings, array $locations ): void {
$cost = $settings['cost'] ?? '';
\WC_Tracks::record_event(
'local_pickup_save_changes',
array(
'local_pickup_enabled' => 'yes' === ( $settings['enabled'] ?? '' ),
'title' => __( 'Pickup', 'woocommerce' ) === ( $settings['title'] ?? '' ),
'price' => '' === $cost,
'cost' => '' === $cost ? 0 : $cost,
'taxes' => $settings['tax_status'] ?? '',
'total_pickup_locations' => count( $locations ),
'pickup_locations_enabled' => count(
array_filter(
$locations,
function ( $location ) {
return ! empty( $location['enabled'] );
}
)
),
)
);
}
/**
* Sanitize the pickup_location_settings payload before persisting.
*
* The WP REST dispatcher only auto-sanitizes top-level args, so nested
* object properties need to be cleaned here as defense in depth against
* stored HTML/JS in admin surfaces.
*
* @param array $settings Raw settings payload.
* @return array Sanitized settings payload.
*/
private function sanitize_pickup_location_settings( array $settings ): array {
$sanitized = array();
if ( isset( $settings['enabled'] ) ) {
$sanitized['enabled'] = in_array( $settings['enabled'], array( 'yes', 'no' ), true )
? $settings['enabled']
: 'no';
}
if ( isset( $settings['title'] ) ) {
$sanitized['title'] = sanitize_text_field( (string) $settings['title'] );
}
if ( isset( $settings['tax_status'] ) ) {
$sanitized['tax_status'] = in_array( $settings['tax_status'], array( 'taxable', 'none' ), true )
? $settings['tax_status']
: 'none';
}
if ( isset( $settings['cost'] ) ) {
// Cost may be a math expression (e.g. "5 + 1.50"), so strip HTML
// without coercing to float — floatval would break formula syntax.
$sanitized['cost'] = wp_strip_all_tags( (string) $settings['cost'] );
}
return $sanitized;
}
/**
* Sanitize the pickup_locations payload before persisting.
*
* @param array $locations Raw list of pickup locations.
* @return array Sanitized list of pickup locations.
*/
private function sanitize_pickup_locations( array $locations ): array {
$sanitized = array();
foreach ( $locations as $location ) {
if ( ! is_array( $location ) ) {
continue;
}
$name = isset( $location['name'] ) ? sanitize_text_field( (string) $location['name'] ) : '';
// A pickup location with no name is unusable, and incomplete entries
// would later trigger undefined-index notices in
// ShippingController::hydrate_client_settings(), which reads these
// fields unconditionally. Skip nameless entries and always emit every
// key with a safe default for the ones we keep.
if ( '' === $name ) {
continue;
}
// Always emit every address key with a safe default. Downstream
// readers such as ShippingController::filter_taxable_address() access
// state/postcode/city unconditionally once country is set, so a
// partial address (e.g. only country) would trigger undefined-index
// notices.
$address = array();
if ( isset( $location['address'] ) && is_array( $location['address'] ) ) {
foreach ( array( 'address_1', 'city', 'state', 'postcode', 'country' ) as $field ) {
$address[ $field ] = isset( $location['address'][ $field ] )
? sanitize_text_field( (string) $location['address'][ $field ] )
: '';
}
}
$enabled = isset( $location['enabled'] ) ? rest_sanitize_boolean( (string) $location['enabled'] ) : false;
$sanitized[] = array(
'name' => $name,
'address' => $address,
// Details may contain limited HTML. Match the rendering side
// in ShippingController::show_local_pickup_details() which uses
// wp_kses_post().
'details' => isset( $location['details'] ) ? wp_kses_post( (string) $location['details'] ) : '',
'enabled' => $enabled,
);
}
return $sanitized;
}
}