| 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 |
|