WP_REST_Server::READABLE, 'callback' => fn() => new WP_REST_Response( $this->registry->schema() + array( 'roles' => wp_roles()->get_names(), 'currency' => html_entity_decode( get_woocommerce_currency_symbol(), ENT_QUOTES, 'UTF-8' ), // WooCommerce returns $ for $. ) ), 'permission_callback' => $permission, ) ); register_rest_route( self::NAMESPACE, '/rules', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => fn() => new WP_REST_Response( array_map( static fn( Rule $r ) => $r->to_array(), $this->rules->all() ) ), 'permission_callback' => $permission, ), array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'save' ), 'permission_callback' => $permission, ), ) ); register_rest_route( self::NAMESPACE, '/rules/(?P\d+)', array( array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'save' ), 'permission_callback' => $permission, ), array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'delete' ), 'permission_callback' => $permission, ), ) ); } /** * Only shop managers and admins can change rules. */ public function can_manage(): bool { return current_user_can( 'manage_woocommerce' ); } /** * Create or update a rule. * * @param WP_REST_Request $request Request. * @phpstan-param WP_REST_Request> $request * @return WP_REST_Response|WP_Error */ public function save( WP_REST_Request $request ) { $data = $this->sanitise( (array) $request->get_json_params() ); $data['id'] = (int) ( $request['id'] ?? 0 ); $rule = Rule::from_array( $data ); if ( '' === trim( $rule->name ) ) { return new WP_Error( 'joinery_rule_name', __( 'Give the rule a name.', 'joinery-core' ), array( 'status' => 400 ) ); } foreach ( $rule->conditions as $part ) { if ( null === $this->registry->condition( $part['type'] ) ) { /* translators: %s: condition id */ return new WP_Error( 'joinery_rule_condition', sprintf( __( 'Unknown condition: %s', 'joinery-core' ), $part['type'] ), array( 'status' => 400 ) ); } } foreach ( $rule->actions as $part ) { if ( null === $this->registry->action( $part['type'] ) ) { /* translators: %s: action id */ return new WP_Error( 'joinery_rule_action', sprintf( __( 'Unknown action: %s', 'joinery-core' ), $part['type'] ), array( 'status' => 400 ) ); } } $id = $this->rules->save( $rule ); return new WP_REST_Response( $this->rules->find( $id )?->to_array(), $data['id'] ? 200 : 201 ); } /** * Clean everything a rule stores: plain-text name, key-like part types, and * settings values reduced to sanitised scalars. Caps the number of parts so one * request can't store an enormous rule. * * @param array $data Raw JSON body. * @return array */ private function sanitise( array $data ): array { $data['name'] = mb_substr( sanitize_text_field( (string) ( $data['name'] ?? '' ) ), 0, 190 ); foreach ( array( 'conditions', 'actions' ) as $key ) { $parts = is_array( $data[ $key ] ?? null ) ? array_slice( $data[ $key ], 0, 20 ) : array(); foreach ( $parts as $index => $part ) { if ( ! is_array( $part ) ) { unset( $parts[ $index ] ); continue; } $parts[ $index ] = array( 'type' => sanitize_key( (string) ( $part['type'] ?? '' ) ), 'settings' => is_array( $part['settings'] ?? null ) ? $this->sanitise_settings( $part['settings'] ) : array(), ); } $data[ $key ] = array_values( $parts ); } return $data; } /** * Settings are free-form per condition/action; keep scalars and nested lists only. * * @param array $settings Raw settings. * @param int $depth Current nesting depth. * @return array */ private function sanitise_settings( array $settings, int $depth = 0 ): array { $clean = array(); foreach ( array_slice( $settings, 0, 100, true ) as $key => $value ) { $key = is_int( $key ) ? $key : sanitize_key( (string) $key ); if ( is_array( $value ) && $depth < 3 ) { $clean[ $key ] = $this->sanitise_settings( $value, $depth + 1 ); } elseif ( is_bool( $value ) || is_int( $value ) || is_float( $value ) ) { $clean[ $key ] = $value; } elseif ( is_string( $value ) ) { $clean[ $key ] = mb_substr( sanitize_text_field( $value ), 0, 500 ); } } return $clean; } /** * Delete a rule. * * @param WP_REST_Request $request Request. * @phpstan-param WP_REST_Request> $request */ public function delete( WP_REST_Request $request ): WP_REST_Response { $this->rules->delete( (int) $request['id'] ); return new WP_REST_Response( null, 204 ); } }