PluginProbe
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More / 2.2.0
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More v2.2.0
2.3.0 2.2.0 2.1.1 2.1.0 2.0.0 1.10.0 1.9.1 1.9.0 1.2.1 1.2.2 1.3.0 1.3.1 1.3.2 1.3.3 1.4.0 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.6.0 All 59 releases
storeengine / includes / classes / abstract-request-handler.php

abstract-request-handler.php in StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More 2.2.0, at includes/classes/abstract-request-handler.php

1,111 lines 28.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace StoreEngine\Classes;
4
5 defined( 'ABSPATH' ) || exit;
6
7 /**
8 * AbstractRequestHandler
9 *
10 * Base request handler for StoreEngine actions.
11 *
12 * This class provides a unified, secure, and extensible way to:
13 * - Register and dispatch WordPress actions (AJAX / admin_post)
14 * - Verify nonces
15 * - Enforce permission checks
16 * - Sanitize and validate request payloads using declarative schemas
17 * - Return consistent success/error responses
18 *
19 * ## Supported request types
20 * - wp_ajax_*
21 * - wp_ajax_nopriv_*
22 * - admin_post_*
23 * - admin_post_nopriv_*
24 *
25 * ## Payload schema features
26 * - Scalar sanitization (string, int, float, bool, post, etc.)
27 * - Nested objects
28 * - Repeated fields (arrays of objects)
29 * - Advanced validation & behavior:
30 * - enum
31 * - min / max (length or numeric)
32 * - regex
33 * - required
34 * - nullable
35 * - default values
36 * - cast-only (skip validation)
37 * - custom labels & per-field error messages
38 * - min_items / max_items (arrays)
39 * - custom sanitizer callbacks
40 * - custom validator callbacks
41 * - OpenAPI schema export
42 *
43 * ## Example schema
44 * ```
45 * 'fields' => [
46 * 'id' => [
47 * 'label' => 'Product ID',
48 * 'rules' => 'absint|required',
49 * ],
50 * 'title' => [
51 * 'label' => 'Product Title',
52 * 'rules' => 'string|min:3|max:100|required',
53 * 'messages' => [
54 * 'required' => '%s cannot be empty.',
55 * 'min' => '%s must be at least 3 characters.',
56 * 'max' => '%s must be shorter than 100 characters.',
57 * ],
58 * ],
59 * 'status' => [
60 * 'rules' => 'string|enum:draft,published',
61 * 'default' => 'draft',
62 * ],
63 * 'price' => [
64 * 'rules' => 'float|min:0',
65 * 'nullable' => true,
66 * ],
67 * 'features' => [
68 * 'min_items' => 1,
69 * 'max_items' => 5,
70 * [
71 * 'title' => [
72 * 'rules' => 'string|required',
73 * ],
74 * 'cost' => [
75 * 'rules' => 'float|min:0',
76 * 'default' => 0,
77 * ],
78 * ]
79 * ],
80 * ]
81 * ```
82 *
83 * Concrete handlers should extend this class and:
84 * - Define `$actions`
85 * - Implement `dispatch_actions()`
86 * - Implement callback methods
87 *
88 * @package StoreEngine\Classes
89 */
90
91 use Exception;
92 use stdClass;
93 use StoreEngine\Classes\Exceptions\StoreEngineException;
94 use StoreEngine\Utils\Caching;
95 use StoreEngine\Utils\Formatting;
96 use StoreEngine\Utils\Helper;
97 use Throwable;
98 use WP_Error;
99
100 if ( ! defined( 'ABSPATH' ) ) {
101 exit;
102 }
103
104 /**
105 * Base abstract request handler.
106 *
107 * Each extending class represents a logical request group
108 * (e.g. products, licenses, subscriptions).
109 *
110 * This class is intentionally opinionated:
111 * - Declarative input schema
112 * - Centralized sanitization & validation
113 * - WordPress-native error handling
114 *
115 * Extend this class instead of directly using wp_ajax_* callbacks.
116 */
117 abstract class AbstractRequestHandler {
118
119 public const ABSINT = 'absint';
120 public const ID = 'id';
121 public const INT = 'int';
122 public const INTEGER = 'integer';
123 public const ABS_INTEGER = 'absint';
124 public const ID_ARR = 'array-id';
125 public const IDS = 'ids';
126 public const DOUBLE = 'double';
127 public const FLOAT = 'float';
128 public const ABS_DOUBLE = 'abs-double';
129 public const ABS_FLOAT = 'abs-float';
130 public const URL = 'url';
131 public const BOOLEAN = 'bool';
132 public const POST = 'post';
133 public const HTML = 'post';
134 public const SLUG = 'slug';
135 public const EMAIL = 'email';
136 public const USER = 'user';
137 public const USERNAME = 'username';
138 public const TEXTAREA = 'textarea';
139 public const TEXT = 'text';
140 public const STRING = 'string';
141 public const SAFE_TEXT = 'safe_text';
142 public const SAFE_STR = 'safe_text';
143 public const SAFE_HTML = 'safe_text';
144 public const STR_ARR = 'array-string';
145 public const STRINGS = 'strings';
146 public const PASSWORD = 'password';
147 public const COLOR = 'color';
148 public const HEX_COLOR = 'hex_color';
149 public const HEX_COLOR_NO_HASH = 'hex_color_no_hash';
150 public const KEY = 'key';
151
152 /**
153 * Default Nonce Action.
154 *
155 * @var string
156 */
157 protected string $nonce_action = 'storeengine_nonce';
158
159 /**
160 * Request namespace.
161 *
162 * @var string
163 */
164 protected string $namespace = STOREENGINE_PLUGIN_SLUG;
165
166 /**
167 * Action registry.
168 *
169 * Maps action names to permission, schema, and callback definitions.
170 *
171 * Structure:
172 * [
173 * 'action_name' => [
174 * 'capability' => 'manage_options',
175 * 'allow_visitor_action' => false,
176 * 'callback' => [ $this, 'method_name' ],
177 * 'fields' => [ ...schema... ],
178 * ],
179 * ]
180 *
181 * @var array<string, array>
182 */
183 protected array $actions = array();
184
185 protected static string $current_wp_action;
186
187 protected ?bool $is_ajax = null;
188
189 protected ?bool $is_admin_post = null;
190
191 protected ?bool $is_unauthenticated = null;
192
193 protected ?bool $is_visitor_action = null;
194
195 private array $safe_text_kses_rules = array(
196 'u' => true,
197 'i' => true,
198 'b' => true,
199 'br' => true,
200 'hr' => true,
201 'img' => [
202 'alt' => true,
203 'class' => true,
204 'src' => true,
205 'title' => true,
206 ],
207 'p' => [
208 'class' => true,
209 ],
210 'ul' => [
211 'class' => true,
212 ],
213 'li' => [
214 'class' => true,
215 ],
216 'span' => [
217 'class' => true,
218 'title' => true,
219 ],
220 'a' => [
221 'class' => true,
222 'title' => true,
223 'href' => true,
224 'target' => true,
225 'rel' => true,
226 'download' => true,
227 ],
228 );
229
230 abstract public function __construct();
231
232 /**
233 * Register WordPress hooks for the defined actions.
234 *
235 * Implementations should bind `handle_request()` to the appropriate
236 * WordPress action hooks (wp_ajax_*, admin_post_*).
237 *
238 * Example:
239 * ```
240 * add_action( 'wp_ajax_storeengine/update_data', [ $this, 'handle_request' ] );
241 * ```
242 *
243 * @return void
244 */
245 abstract public function dispatch_actions();
246
247 protected function is_ajax_request(): bool {
248 if ( null === $this->is_ajax ) {
249 $this->is_ajax = str_starts_with( static::$current_wp_action, 'wp_ajax_' );
250 }
251
252 return $this->is_ajax;
253 }
254
255 protected function is_admin_post_request(): bool {
256 if ( null === $this->is_admin_post ) {
257 $this->is_admin_post = str_starts_with( static::$current_wp_action, 'admin_post_' );
258 }
259
260 return $this->is_admin_post;
261 }
262
263 protected function is_unauthenticated_request(): bool {
264 if ( null === $this->is_unauthenticated ) {
265 $this->is_unauthenticated =
266 ( $this->is_ajax_request() || $this->is_admin_post_request() ) &&
267 str_contains( static::$current_wp_action, '_nopriv_' );
268 }
269
270 return $this->is_unauthenticated;
271 }
272
273 /**
274 * Main request entry point.
275 *
276 * This method:
277 * - Detects current WordPress action
278 * - Disables caching
279 * - Prepares and validates payload
280 * - Executes the mapped callback
281 * - Sends success or error response
282 *
283 * All exceptions are normalized into WP_Error responses.
284 *
285 * @return void
286 */
287 public function handle_request() {
288 try {
289 static::$current_wp_action = wp_unslash( current_action() );
290 // No caching Please.
291 Caching::nocache_headers();
292
293 $response = $this->prepare_response();
294
295 if ( $response && is_wp_error( $response ) ) {
296 $this->respond_error( $response );
297 }
298
299 $this->respond_success( $response );
300 } catch ( StoreEngineException $e ) {
301 $this->respond_error( $e->toWpError() );
302 } catch ( Throwable $e ) {
303 Helper::log_error( $e );
304 $this->respond_error(
305 new WP_Error(
306 'something-went-wrong',
307 sprintf(
308 // translators: %s. Exception (error) message.
309 __( 'Something went wrong. Error: %s', 'storeengine' ),
310 wp_strip_all_tags( $e->getMessage() )
311 ),
312 [
313 'code' => $e->getCode(),
314 'line' => $e->getLine(),
315 'file' => $e->getFile(),
316 ]
317 )
318 );
319 }
320 }
321
322 /**
323 * Prepare error response.
324 *
325 * @param WP_Error $response
326 *
327 * @return void
328 */
329 protected function respond_error( WP_Error $response ) {
330 if ( $this->is_ajax_request() ) {
331 $data = $response->get_error_data();
332 wp_send_json_error( $response, $data['code'] ?? 400 );
333 } else {
334 wp_die( $response ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
335 }
336 }
337
338 /**
339 * Prepare success response.
340 *
341 * @param $response
342 *
343 * @return void
344 */
345 protected function respond_success( $response ) {
346 if ( $response ) {
347 if ( $this->is_ajax_request() ) {
348 wp_send_json_success( $response );
349 } elseif ( is_string( $response ) && Helper::is_url( $response ) && Helper::is_valid_site_url( $response ) ) {
350 wp_safe_redirect( $response );
351 die(); // don't use wp_die...
352 } else {
353 // @XXX maybe another handler or just void.
354 wp_die( '', '', [ 'response' => 200 ] );
355 }
356 }
357 }
358
359 /**
360 * Prepare response for the request.
361 *
362 * @return WP_Error|array|stdClass|string
363 * @throws StoreEngineException
364 */
365 protected function prepare_response() {
366 $action = isset( $_REQUEST['action'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['action'] ) ) : '';
367 $action = explode( $this->namespace . '/', $action )[1];
368
369 if ( ! isset( $this->actions[ $action ] ) ) {
370 return new WP_Error(
371 'invalid_action',
372 __( 'Invalid action.', 'storeengine' ),
373 [
374 'status' => 400,
375 'title' => __( 'Invalid action.', 'storeengine' ),
376 ]
377 );
378 }
379
380 $details = $this->actions[ $action ];
381 $nonce = isset( $_REQUEST['security'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['security'] ) ) : '';
382
383 if ( empty( $nonce ) && isset( $_REQUEST['_wpnonce'] ) ) {
384 $nonce = sanitize_text_field( wp_unslash( $_REQUEST['_wpnonce'] ) );
385 }
386
387 if ( ! $nonce || ! wp_verify_nonce( $nonce, $this->nonce_action ) ) {
388 return new WP_Error(
389 'invalid_nonce',
390 __( 'Invalid nonce.', 'storeengine' ),
391 [
392 'status' => rest_authorization_required_code(),
393 'title' => __( 'Invalid nonce.', 'storeengine' ),
394 ]
395 );
396 }
397
398 $user_cap = ! empty( $details['capability'] ) ? (string) $details['capability'] : '';
399 $this->is_visitor_action = isset( $details['allow_visitor_action'] ) && $details['allow_visitor_action'];
400 $has_permission = $this->check_permission( $user_cap, $this->is_visitor_action );
401
402 if ( is_wp_error( $has_permission ) ) {
403 return $has_permission;
404 }
405
406 /**
407 * Secondary authorization gate.
408 *
409 * Runs AFTER the base capability + nonce checks pass. A handler declares a
410 * single coarse `capability` (usually `manage_options`) per action, which
411 * cannot express per-user, per-action policy. This filter lets addons apply
412 * that finer authorization — notably the Role & Permission addon mapping a
413 * staff user's granted permissions onto individual actions so a view-only
414 * user can't invoke writes (mark-as-paid, status change, refunds, …).
415 *
416 * The full handler instance is passed so listeners can disambiguate
417 * identically named actions across different handler classes (e.g. `import`,
418 * `settings`, `delete` exist in several). Return a WP_Error to deny.
419 *
420 * @param true|WP_Error $authorized Current decision (true = allowed).
421 * @param array $context {
422 * @type string $action Action name (namespace-stripped).
423 * @type AbstractRequestHandler $handler The dispatching handler instance.
424 * @type string $namespace Handler namespace.
425 * @type string $capability Declared capability for the action.
426 * @type bool $is_visitor Whether the action allows visitors.
427 * @type array $details Full action definition.
428 * }
429 */
430 $authorized = apply_filters( 'storeengine/request/authorize', true, [
431 'action' => $action,
432 'handler' => $this,
433 'namespace' => $this->namespace,
434 'capability' => $user_cap,
435 'is_visitor' => $this->is_visitor_action,
436 'details' => $details,
437 ] );
438
439 if ( is_wp_error( $authorized ) ) {
440 return $authorized;
441 }
442
443 if ( empty( $details['callback'] ) || ! is_callable( $details['callback'] ) ) {
444 return new WP_Error(
445 'not_implemented',
446 __( 'Requested method not implemented.', 'storeengine' ),
447 [
448 'status' => 501,
449 'title' => __( 'Not implemented!', 'storeengine' ),
450 ]
451 );
452 }
453
454 return $this->respond( $details['callback'], $this->prepare_payload( $details['fields'] ?? null ) );
455 }
456
457 /**
458 * Prepare and sanitize request payload using a declarative schema.
459 *
460 * This method:
461 * - Iterates over defined fields
462 * - Recursively sanitizes input
463 * - Applies validation rules
464 * - Supports nested and repeated fields
465 *
466 * Invalid values are silently discarded (returned as null).
467 *
468 * @param array|null $fields Schema definition from `$actions`.
469 *
470 * @return array Sanitized payload ready for callback consumption.
471 * @throws StoreEngineException
472 */
473 protected function prepare_payload( ?array $fields = null ): array {
474 $payload = [];
475
476 // phpcs:disable WordPress.Security.NonceVerification.Recommended -- nonce verified before this function call.
477 if ( ! is_array( $fields ) || empty( $fields ) ) {
478 return $payload;
479 }
480
481 foreach ( $fields as $key => $schema ) {
482 if ( ! isset( $_REQUEST[ $key ] ) ) {
483 continue;
484 }
485
486 $payload[ $key ] = $this->sanitize_by_schema( wp_unslash( $_REQUEST[ $key ] ), $schema, $key ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Input sanitized inside this function.
487 }
488
489 // phpcs:enable WordPress.Security.NonceVerification.Recommended
490
491 return $payload;
492 }
493
494 /**
495 * Recursively sanitize data based on schema definition.
496 *
497 * Supported schema shapes:
498 *
499 * 1. Scalar:
500 * 'title' => 'string|min:3|max:100'
501 *
502 * 2. Nested object:
503 * 'meta' => [
504 * 'color' => 'hex_color',
505 * 'size' => 'int|min:1',
506 * ]
507 *
508 * 3. Repeated fields:
509 * 'features' => [
510 * [
511 * 'title' => 'string',
512 * 'cost' => 'float',
513 * ]
514 * ]
515 *
516 * @param mixed $value Raw request value.
517 * @param mixed $schema Field schema definition.
518 *
519 * @return mixed Sanitized value.
520 * @throws StoreEngineException
521 */
522 protected function sanitize_by_schema( $value, $schema, string $path = '' ) {
523 // Repeated fields (numeric array schema)
524 if ( is_array( $schema ) && array_is_list( $schema ) ) {
525
526 $item_schema = $schema[0] ?? null;
527
528 if ( ! is_array( $value ) ) {
529 return [];
530 }
531
532 $minItems = $schema['min_items'] ?? null;
533 $maxItems = $schema['max_items'] ?? null;
534
535 if ( null !== $minItems && count( $value ) < (int) $minItems ) {
536 throw new StoreEngineException(
537 esc_html(
538 sprintf(
539 // translators: %1$s: Field name/path. %2$d. Minimum (count) item required.
540 __( '%1$s must contain at least %2$d items.', 'storeengine' ),
541 $path,
542 $minItems
543 )
544 ),
545 'min_items_failed',
546 400
547 );
548 }
549
550 if ( null !== $maxItems && count( $value ) > (int) $maxItems ) {
551 throw new StoreEngineException(
552 esc_html(
553 sprintf(
554 // translators: %1$s: Field name/path. %2$d. Max (count) item allowed.
555 __( '%1$s must not exceed %2$d items.', 'storeengine' ),
556 $path,
557 $maxItems
558 )
559 ),
560 'max_items_failed',
561 400
562 );
563 }
564
565 $result = [];
566 foreach ( $value as $index => $item ) {
567 $result[ $index ] = $this->sanitize_by_schema( $item, $item_schema, $path . '[' . $index . ']' );
568 }
569
570 return $result;
571 }
572
573 // Support schema array with 'rules' and optional 'label'
574 if ( is_string( $schema ) ) {
575 return $this->sanitize_scalar( $value, $schema, $path );
576 }
577
578 if ( is_array( $schema ) && isset( $schema['rules'] ) ) {
579
580 $label = $schema['label'] ?? $path;
581 $rules = (string) $schema['rules'];
582 $nullable = ! empty( $schema['nullable'] );
583 $required = str_contains( $rules, 'required' );
584 $castOnly = ! empty( $schema['cast_only'] );
585 $message = $schema['message'] ?? null; // fallback
586 $messages = $schema['messages'] ?? [];
587
588 // Custom sanitizer callback
589 if ( isset( $schema['sanitize'] ) && is_callable( $schema['sanitize'] ) ) {
590 $value = call_user_func( $schema['sanitize'], $value, $path );
591 }
592
593 // Handle missing value
594 if ( $value === null || $value === '' ) {
595 if ( array_key_exists( 'default', $schema ) ) {
596 return $schema['default'];
597 }
598
599 if ( $nullable ) {
600 return null;
601 }
602
603 if ( $required ) {
604 throw new StoreEngineException(
605 esc_html(
606 $messages['required'] ??
607 $message ??
608 sprintf(
609 // translators: %s. Field name/label.
610 __( '%s is required.', 'storeengine' ),
611 $label
612 )
613 ),
614 'required_field_missing',
615 400
616 );
617 }
618
619 return null;
620 }
621
622 $sanitized = $this->sanitize_scalar(
623 $value,
624 $rules,
625 $path,
626 $label,
627 $message,
628 $castOnly,
629 $messages
630 );
631
632 // Custom validator callback
633 if ( isset( $schema['validate'] ) && is_callable( $schema['validate'] ) ) {
634 $result = call_user_func( $schema['validate'], $sanitized, $path );
635 if ( $result !== true ) {
636 throw new StoreEngineException(
637 esc_html( $message ?: ( is_string( $result ) ? $result : __( 'Validation failed.', 'storeengine' ) ) ),
638 'custom_validation_failed',
639 400
640 );
641 }
642 }
643
644 return $sanitized;
645 }
646
647 // Nested object
648 if ( is_array( $schema ) && is_array( $value ) ) {
649 $result = [];
650 foreach ( $schema as $field => $field_schema ) {
651 if ( isset( $value[ $field ] ) ) {
652 $result[ $field ] = $this->sanitize_by_schema( $value[ $field ], $field_schema, $path ? $path . '.' . $field : $field );
653 }
654 }
655
656 return $result;
657 }
658
659 return null;
660 }
661
662 /**
663 * Sanitize and validate scalar values.
664 *
665 * Supports extended rule syntax using pipe separators.
666 *
667 * Supported behaviors:
668 * - required → field must be present and non-empty
669 * - nullable → allows null values
670 * - default → applied when value is missing
671 * - cast-only → sanitize & cast without validation
672 *
673 * Examples:
674 * - string|min:3|max:50|required
675 * - float|min:0
676 * - string|enum:draft,published
677 * - slug|regex:/^[a-z0-9-]+$/
678 *
679 * @param mixed $value
680 * @param string $type
681 * @param string $field_path
682 * @param string|null $custom_label
683 * @param string|null $custom_message
684 * @param bool $cast_only
685 * @param array $rule_messages
686 *
687 * @return mixed
688 * @throws StoreEngineException
689 */
690 protected function sanitize_scalar(
691 $value,
692 string $type,
693 string $field_path,
694 ?string $custom_label = null,
695 ?string $custom_message = null,
696 bool $cast_only = false,
697 array $rule_messages = []
698 ) {
699 if ( is_callable( $type ) ) {
700 return call_user_func_array( $type, [ $value, $custom_label, $custom_message, $cast_only ] );
701 }
702
703 $rules = array_map( 'trim', explode( '|', $type ) );
704 $base = strtolower( array_shift( $rules ) );
705
706 // @TODO add support for file.
707
708 // --- Sanitize first ---
709 switch ( $base ) {
710 case self::ID:
711 case self::ABSINT:
712 case self::ABS_INTEGER:
713 case 'absint':
714 case 'id':
715 $value = absint( sanitize_text_field( $value ) );
716 break;
717
718 case self::ID_ARR:
719 case self::IDS:
720 case 'array-id':
721 case 'id-array':
722 case 'ids':
723 $value = is_string( $value ) ? explode( ',', $value ) : $value;
724 $value = array_map( fn( $id ) => is_scalar( $id ) ? absint( $id ) : null, $value );
725 $value = array_unique( array_filter( $value ) );
726 break;
727
728 case self::INT:
729 case self::INTEGER:
730 case 'int':
731 case 'integer':
732 $value = intval( sanitize_text_field( $value ) );
733 break;
734
735 case self::DOUBLE:
736 case self::FLOAT:
737 case 'double':
738 case 'float':
739 $value = floatval( sanitize_text_field( $value ) );
740 break;
741
742 case self::ABS_DOUBLE:
743 case self::ABS_FLOAT:
744 case 'abs-double':
745 case 'abs-float':
746 $value = abs( floatval( sanitize_text_field( $value ) ) );
747 break;
748
749 case self::URL:
750 case 'url':
751 $value = sanitize_url( $value );
752 break;
753
754 case self::BOOLEAN:
755 case 'bool':
756 case 'boolean':
757 $value = Formatting::string_to_bool( $value );
758 break;
759
760 case self::POST:
761 case self::HTML:
762 case 'post':
763 $value = wp_kses_post( $value );
764 break;
765
766 case self::SLUG:
767 case 'slug':
768 $value = sanitize_title( $value );
769 break;
770
771 case self::EMAIL:
772 case 'email':
773 $value = sanitize_email( $value );
774 break;
775
776 case self::USER:
777 case self::USERNAME:
778 case 'user':
779 $value = sanitize_user( $value, str_contains( $type, 'strict' ) );
780 break;
781
782 case self::TEXTAREA:
783 case 'textarea':
784 $value = sanitize_textarea_field( $value );
785 break;
786
787 case self::TEXT:
788 case self::STRING:
789 case 'text':
790 case 'string':
791 $value = sanitize_text_field( $value );
792 break;
793
794 case self::SAFE_TEXT:
795 case self::SAFE_STR:
796 case self::SAFE_HTML:
797 case 'safe_text':
798 $value = wp_kses( force_balance_tags( stripslashes( $value ) ), $this->safe_text_kses_rules );
799 break;
800
801 case self::KEY:
802 case 'key':
803 $value = sanitize_key( $value );
804 break;
805
806 case self::STR_ARR:
807 case self::STRINGS:
808 case 'array-string':
809 case 'strings':
810 $value = is_string( $value ) ? explode( ',', $value ) : $value;
811 $value = array_map( fn( $s ) => is_scalar( $s ) ? trim( sanitize_text_field( $s ) ) : null, $value );
812 $value = array_unique( array_filter( $value ) );
813 break;
814
815 case self::PASSWORD:
816 case 'password':
817 // Do not apply sanitizer or strip-slashes as passwords can contain special characters.
818 $value = trim( $value );
819 break;
820
821 case self::COLOR:
822 case self::HEX_COLOR:
823 case 'color':
824 case 'hex_color':
825 $value = sanitize_hex_color( $value );
826 break;
827
828 case self::HEX_COLOR_NO_HASH:
829 case 'hex_color_no_hash':
830 $value = sanitize_hex_color_no_hash( $value );
831 break;
832
833 default:
834 if ( is_callable( $base ) ) {
835 $value = call_user_func( $base, $value );
836 } else {
837 $value = is_array( $value ) ? wp_kses_post_deep( map_deep( $value, 'trim' ) ) : wp_kses_post( trim( $value ) );
838 }
839 break;
840 }
841
842 // --- Apply validation rules ---
843 if ( ! $cast_only ) {
844 $field_label = $custom_label ?: ucwords( str_replace( [ '.', '_' ], ' ', $field_path ) );
845
846 $get_message = function ( string $rule_key, ?string $fallback = null ) use ( $rule_messages, $custom_message, $field_label ) {
847 if ( isset( $rule_messages[ $rule_key ] ) ) {
848 return sprintf( $rule_messages[ $rule_key ], $field_label );
849 }
850
851 if ( $custom_message ) {
852 return $custom_message;
853 }
854
855 return $fallback;
856 };
857
858 foreach ( $rules as $rule ) {
859 // enum:a,b,c
860 if ( str_starts_with( $rule, 'enum:' ) ) {
861 $allowed = array_map( 'trim', explode( ',', substr( $rule, 5 ) ) );
862 if ( ! in_array( (string) $value, $allowed, true ) ) {
863 throw new StoreEngineException(
864 esc_html(
865 $get_message(
866 'enum',
867 sprintf(
868 // translators: %1$s: Field label, %2$s: allowed items.
869 __( '%1$s must be one of: %2$s', 'storeengine' ),
870 $field_label,
871 Helper::implode_with( $allowed, 'or' )
872 )
873 )
874 ),
875 'invalid_enum',
876 400
877 );
878 }
879 }
880
881 // min:3
882 if ( str_starts_with( $rule, 'min:' ) ) {
883 $min = (int) substr( $rule, 4 );
884 if (
885 ( is_string( $value ) && mb_strlen( $value ) < $min ) ||
886 ( is_numeric( $value ) && $value < $min )
887 ) {
888 throw new StoreEngineException(
889 esc_html(
890 $get_message(
891 'min',
892 sprintf(
893 // translators: %1$s: Field label, %2$s: Min required characters.
894 __( '%1$s must be at least %2$d characters.', 'storeengine' ),
895 $field_label,
896 $min
897 )
898 )
899 ),
900 'min_validation_failed',
901 400
902 );
903 }
904 }
905
906 // max:20
907 if ( str_starts_with( $rule, 'max:' ) ) {
908 $max = (int) substr( $rule, 4 );
909 if (
910 ( is_string( $value ) && mb_strlen( $value ) > $max ) ||
911 ( is_numeric( $value ) && $value > $max )
912 ) {
913 throw new StoreEngineException(
914 esc_html(
915 $get_message(
916 'max',
917 sprintf(
918 // translators: %1$s: Field label, %2$s: Max allowed characters.
919 __( '%1$s must not exceed %2$d', 'storeengine' ),
920 $field_label,
921 $max
922 )
923 )
924 ),
925 'max_validation_failed',
926 400
927 );
928 }
929 }
930
931 // regex:/pattern/
932 if ( str_starts_with( $rule, 'regex:' ) ) {
933 $pattern = substr( $rule, 6 );
934 if ( is_string( $value ) && @preg_match( $pattern, $value ) !== 1 ) {
935 throw new StoreEngineException(
936 esc_html(
937 $get_message(
938 'regex',
939 sprintf(
940 // translators: %s: Field label.
941 __( '%s format is invalid.', 'storeengine' ),
942 $field_label
943 )
944 )
945 ),
946 'regex_validation_failed',
947 400
948 );
949 }
950 }
951 }
952 }
953
954 return $value;
955 }
956
957 /**
958 * Execute action callback safely.
959 *
960 * Any thrown exception is converted into a StoreEngineException
961 * and later returned as a WP_Error response.
962 *
963 * @param callable|string|array $callback Action callback.
964 * @param array $payload Sanitized payload.
965 *
966 * @return WP_Error|array|stdClass|string
967 *
968 * @throws StoreEngineException
969 */
970 final protected function respond( $callback, array $payload ) {
971 try {
972 return call_user_func( $callback, $payload );
973 } catch ( StoreEngineException $e ) {
974 throw $e;
975 } catch ( Throwable $e ) {
976 throw StoreEngineException::convert_exception( $e );// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- escaped inside called function.
977 }
978 }
979
980 /**
981 * Check user permission for the current action.
982 *
983 * Visitor actions may bypass login checks when explicitly allowed.
984 *
985 * @param string $capability Required capability.
986 * @param bool $allow_visitors Whether unauthenticated users are allowed.
987 *
988 * @return true|WP_Error True on success, WP_Error otherwise.
989 */
990 protected function check_permission( string $capability, bool $allow_visitors = false ) {
991 if ( ( ! is_user_logged_in() && ! $allow_visitors ) || ( is_user_logged_in() && $capability && ! current_user_can( $capability ) ) ) {
992 return new WP_Error(
993 'forbidden_action',
994 __( 'You do not have permission to access this page.', 'storeengine' ),
995 [
996 'status' => rest_authorization_required_code(),
997 'title' => __( 'Insufficient permission!', 'storeengine' ),
998 ]
999 );
1000 }
1001
1002 return true;
1003 }
1004
1005 /**
1006 * Export action schema to OpenAPI-compatible structure.
1007 * Generate Schema for frontend devs to use.
1008 *
1009 * @param string $action
1010 *
1011 * @return array|null
1012 */
1013 public function export_openapi_schema( ?string $action = null ): ?array {
1014 if ( ! $action ) {
1015 $actions = array_keys( $this->actions );
1016
1017 return [
1018 'type' => 'object',
1019 'namespace' => get_class( $this ),
1020 'properties' => array_combine(
1021 array_map( fn( $a ) => $this->namespace . '/' . $a, $actions ),
1022 array_map( [ $this, 'export_openapi_schema' ], $actions )
1023 ),
1024 ];
1025 }
1026
1027 $fields = $this->actions[ $action ]['fields'] ?? null;
1028
1029 return [
1030 'type' => 'object',
1031 'methods' => [ 'POSTS' ],
1032 'is_public' => isset( $this->actions[ $action ]['allow_visitor_action'] ) && $this->actions[ $action ]['allow_visitor_action'],
1033 'capability' => $this->actions[ $action ]['capability'] ?? 'all',
1034 'namespace' => $this->namespace . '/' . $action,
1035 'properties' => $fields ? $this->build_openapi_properties( $fields ) : null,
1036 ];
1037 }
1038
1039 /**
1040 * Build OpenAPI properties recursively.
1041 *
1042 * @param array $fields
1043 *
1044 * @return array
1045 */
1046 protected function build_openapi_properties( array $fields ): array {
1047 return array_map( fn( $schema ) => $this->convert_schema_to_openapi( $schema ), $fields );
1048 }
1049
1050 /**
1051 * Convert internal schema to OpenAPI format.
1052 *
1053 * @param mixed $schema
1054 *
1055 * @return array
1056 */
1057 protected function convert_schema_to_openapi( $schema ): array {
1058
1059 // Repeated field
1060 if ( is_array( $schema ) && array_is_list( $schema ) ) {
1061 return [
1062 'type' => 'array',
1063 'items' => $this->convert_schema_to_openapi( $schema[0] ),
1064 'minItems' => $schema['min_items'] ?? null,
1065 'maxItems' => $schema['max_items'] ?? null,
1066 ];
1067 }
1068
1069 // Scalar with rules
1070 if ( is_array( $schema ) && isset( $schema['rules'] ) ) {
1071 $rules = explode( '|', $schema['rules'] );
1072 $type = array_shift( $rules );
1073
1074 $openapi = [
1075 'type' => in_array( $type, [ 'int', 'absint', 'float', 'double' ], true ) ? 'number' : 'string',
1076 'description' => $schema['label'] ?? '',
1077 ];
1078
1079 foreach ( $rules as $rule ) {
1080 if ( str_starts_with( $rule, 'enum:' ) ) {
1081 $openapi['enum'] = explode( ',', substr( $rule, 5 ) );
1082 }
1083 if ( str_starts_with( $rule, 'min:' ) ) {
1084 $openapi['minLength'] = (int) substr( $rule, 4 );
1085 }
1086 if ( str_starts_with( $rule, 'max:' ) ) {
1087 $openapi['maxLength'] = (int) substr( $rule, 4 );
1088 }
1089 }
1090
1091 if ( array_key_exists( 'default', $schema ) ) {
1092 $openapi['default'] = $schema['default'];
1093 }
1094
1095 return $openapi;
1096 }
1097
1098 // Nested object
1099 if ( is_array( $schema ) ) {
1100 return [
1101 'type' => 'object',
1102 'properties' => $this->build_openapi_properties( $schema ),
1103 ];
1104 }
1105
1106 return [ 'type' => 'string' ];
1107 }
1108 }
1109
1110 // End of file abstract-request-handler.php.
1111