PluginProbe
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution / trunk
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution vtrunk
2.4.0 2.3.0 2.2.5 2.2.0 2.1.2 2.1.1 trunk 1.10.0 1.10.01 1.10.02 1.5.0 1.5.01 1.5.02 1.5.1 1.5.10 1.5.20 1.5.21 1.5.22 1.5.23 1.5.24 1.5.25 1.6.0 1.7.0 1.7.1 1.7.2 All 33 releases
fluent-booking / vendor / wpfluent / framework / src / WPFluent / Http / Route.php

Route.php in Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution trunk, at vendor/wpfluent/framework/src/WPFluent/Http/Route.php

1,771 lines 44.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentBooking\Framework\Http;
4
5 use Closure;
6 use Exception;
7 use Throwable;
8 use WP_Error;
9 use WP_REST_Request;
10 use WP_REST_Response;
11 use ReflectionClass;
12 use BadMethodCallException;
13 use InvalidArgumentException;
14 use FluentBooking\Framework\Support\Arr;
15 use FluentBooking\Framework\Support\Str;
16 use FluentBooking\Framework\Support\Pipeline;
17 use FluentBooking\Framework\Http\Request\Request;
18 use FluentBooking\Framework\Http\Request\WPUserProxy;
19 use FluentBooking\Framework\Http\SubstituteParameters;
20 use FluentBooking\Framework\Http\Middleware\RateLimiter;
21 use FluentBooking\Framework\Validator\ValidationException;
22 use FluentBooking\Framework\Database\Orm\ModelNotFoundException;
23 use FluentBooking\Framework\Foundation\Exceptions\HttpException;
24 use FluentBooking\Framework\Foundation\Exceptions\ExceptionHandler;
25 use FluentBooking\Framework\Http\Response\Response as WPFluentResponse;
26
27 class Route
28 {
29 use SubstituteParameters;
30
31 /**
32 * Application Instance
33 * @var \FluentBooking\Framework\Foundation\Application
34 */
35 protected $app = null;
36
37 /**
38 * Route name
39 * @var string
40 */
41 protected $name = null;
42
43 /**
44 * Rest namespace from config
45 * @var string
46 */
47 protected $restNamespace = null;
48
49 /**
50 * Whether this route should override existing routes at the same URI.
51 * @var bool
52 */
53 protected $shouldOverride = false;
54
55 /**
56 * Full URI
57 * @var string
58 */
59 protected $uri = null;
60
61 /**
62 * Compiled rest endpoint
63 * @var string
64 */
65 protected $compiled = null;
66
67 /**
68 * Route meta data
69 * @var array
70 */
71 protected $meta = [];
72
73 /**
74 * Rest Handler/Callback before parsing
75 * @var string
76 */
77 protected $handler = null;
78
79 /**
80 * Rest Handler/Callback after parsing
81 * @var callable|string
82 */
83 protected $action = null;
84
85 /**
86 * Rest route action info after parsing
87 * @var array
88 */
89 protected $actionInfo = [];
90
91 /**
92 * Policy Handler/Callback after parsing
93 * @var string
94 */
95 protected $permissionHandler = [];
96
97 /**
98 * HTTP Methods
99 * @var string
100 */
101 protected $method = null;
102
103 /**
104 * Rest options
105 * @var array
106 */
107 protected $options = [];
108
109 /**
110 * Route where constraints
111 * @var array
112 */
113 protected $wheres = [];
114
115 /**
116 * Rest namespace
117 * @var string
118 */
119 protected $namespace = null;
120
121 /**
122 * Policy Handler/Callback after parsing
123 * @var callable|string
124 */
125 protected $policyHandler = null;
126
127 /**
128 * Route Middleware
129 * @var array
130 */
131 protected $middleware = [
132 'before' => [],
133 'after' => []
134 ];
135
136 /**
137 * Skips middlewar if true
138 *
139 * @var boolean
140 */
141 protected $skipMiddleware = false;
142
143 /**
144 * Predefined Regex foe where constraints
145 * @var array
146 */
147 protected $predefinedNamedRegx = [
148 'int' => '[0-9]+',
149 'alpha' => '[a-zA-Z]+',
150 'alpha_num' => '[a-zA-Z0-9]+',
151 'alpha_num_dash' => '[a-zA-Z0-9-_]+'
152 ];
153
154 /**
155 * Route parameters
156 * @var null|array
157 */
158 protected $parameters = null;
159
160 /**
161 * Route substituted parameters
162 *
163 * @var null|array
164 */
165 protected $substitutedParameters = [];
166
167 /**
168 * Is route signed
169 *
170 * @var boolean
171 */
172 protected $signed = false;
173
174 /**
175 * Route signature.
176 *
177 * @var array
178 */
179 protected $endpointSignature = [];
180
181 /**
182 * Response instance
183 *
184 * @var \WP_REST_Response
185 */
186 protected $response = null;
187
188 /**
189 * Construct the route instance
190 *
191 * @param \FluentBooking\Framework\Foundation\Application $app
192 * @param string $restNamespace
193 * @param string $uri
194 * @param string $handler
195 * @param string $method
196 */
197 public function __construct($app, $restNamespace, $uri, $handler, $method)
198 {
199 $this->app = $app;
200 $this->restNamespace = $restNamespace;
201 $this->uri = $uri;
202 $this->handler = $handler;
203 $this->method = $method;
204 }
205
206 /**
207 * Map the route to be used in front-end.
208 *
209 * @return self
210 */
211 public function preparefrontendHandlers()
212 {
213 $handler = $this->handler;
214
215 $endpointsUrl = $this->app->config->get('app.slug') . '/__endpoints';
216
217 if (get_option('permalink_structure')) {
218 $url = $this->app->request->url();
219 } else {
220 $url = $this->app->request->query('rest_route');
221 }
222
223 if (
224 !str_contains($url ?? '', $endpointsUrl)
225 || $handler instanceof Closure
226 ) {
227 return $this;
228 }
229
230 [$controller, $cb] = Str::parseCallback($this->parseAction($handler));
231
232 $this->endpointSignature = [$controller, "_{$cb}"];
233
234 $controller = str_replace('\\', '.', $controller);
235
236 // @phpstan-ignore-next-line
237 $endpoints = $this->app->endpoints;
238
239 $endpoints[$controller]["_{$cb}"] = [
240 'uri' => $this->uri,
241 'methods' => explode(',', $this->method),
242 'policy' => $this->getPolicyName()
243 ];
244
245 // @phpstan-ignore-next-line
246 $this->app->endpoints = $endpoints;
247
248 return $this;
249 }
250
251 /**
252 * Get a display name for the route's policy handler.
253 *
254 * @return string|null
255 */
256 protected function getPolicyName()
257 {
258 if (!$this->policyHandler) {
259 return null;
260 }
261
262 if ($this->policyHandler instanceof Closure) {
263 return 'Closure';
264 }
265
266 $name = $this->policyHandler;
267
268 if (is_string($name) && !$this->app->hasNamespace($name)) {
269 $name = $this->app->__namespace__ . '\\App\\Http\\Policies\\' . $name;
270 }
271
272 return $name;
273 }
274
275 /**
276 * Parse the action from the handler.
277 *
278 * @param mixed $handler
279 * @return string
280 */
281 protected function parseAction($handler)
282 {
283 $action = $this->app->parseRestHandler($handler, $this->namespace);
284 $action = trim($action, '\\');
285
286 if (!str_contains($action, '@')) {
287 $action .= '@__invoke';
288 }
289
290 return $action;
291 }
292
293 /**
294 * Alternative constructor
295 *
296 * @param \FluentBooking\Framework\Foundation\Application $app
297 * @param string $namespace
298 * @param string $uri
299 * @param string $handler
300 * @param string $method
301 * @return self
302 */
303 public static function create($app, $namespace, $uri, $handler, $method)
304 {
305 return new static($app, $namespace, $uri, $handler, $method);
306 }
307
308 /**
309 * Set route meta
310 *
311 * @param string $key
312 * @param mixed $value
313 * @return self
314 */
315 public function meta($key, $value = null)
316 {
317 $meta = is_array($key) ? $key : [$key => $value];
318
319 $this->meta = array_merge($this->meta, $meta);
320
321 return $this;
322 }
323
324 /**
325 * Get route meta
326 *
327 * @param string $key
328 * @return mixed
329 */
330 public function getMeta($key = '')
331 {
332 if (isset($this->meta[$key])) {
333 return $this->meta[$key];
334 }
335
336 return $this->meta;
337 }
338
339 /**
340 * Get route options
341 *
342 * @return mixed
343 */
344 public function getOptions()
345 {
346 return $this->getOption();
347 }
348
349 /**
350 * Get route options
351 *
352 * @param string $key
353 * @return mixed
354 */
355 public function getOption($key = null)
356 {
357 return $key ? $this->options[$key] : $this->options;
358 }
359
360 /**
361 * Get route action information
362 * @param string $key
363 * @return mixed
364 */
365 public function getAction($key = '')
366 {
367 if ($key && array_key_exists($key, $this->actionInfo)) {
368 return $this->actionInfo[$key];
369 }
370
371 return $this->actionInfo;
372 }
373
374 /**
375 * Set a where constrain into the route
376 *
377 * @param string $identifier
378 * @param string $value
379 * @return self
380 */
381 public function where($identifier, $value = null)
382 {
383 if (!is_null($value)) {
384 $this->wheres[$identifier] = $this->getValue($value);
385 } else {
386 foreach ($identifier as $key => $value) {
387 $this->wheres[$key] = $this->getValue($value);
388 }
389 }
390
391 return $this;
392 }
393
394 /**
395 * Add an integer type route constraint
396 *
397 * @param string $identifiers
398 * @return self
399 */
400 public function int($identifiers)
401 {
402 $identifiers = is_array($identifiers) ? $identifiers : func_get_args();
403
404 foreach ($identifiers as $identifier) {
405 $this->wheres[$identifier] = '[0-9]+';
406 }
407
408 return $this;
409 }
410
411 /**
412 * Add an alpha type route constraint
413 *
414 * @param string $identifiers
415 * @return self
416 */
417 public function alpha($identifiers)
418 {
419 $identifiers = is_array($identifiers) ? $identifiers : func_get_args();
420
421 foreach ($identifiers as $identifier) {
422 $this->wheres[$identifier] = '[a-zA-Z]+';
423 }
424
425 return $this;
426 }
427
428 /**
429 * Add an alphanum type route constraint
430 *
431 * @param string $identifiers
432 * @return self
433 */
434 public function alphaNum($identifiers)
435 {
436 $identifiers = is_array($identifiers) ? $identifiers : func_get_args();
437
438 foreach ($identifiers as $identifier) {
439 $this->wheres[$identifier] = '[a-zA-Z0-9]+';
440 }
441
442 return $this;
443 }
444
445 /**
446 * Add an alphanumdash type route constraint
447 *
448 * @param string $identifiers
449 * @return self
450 */
451 public function alphaNumDash($identifiers)
452 {
453 $identifiers = is_array($identifiers) ? $identifiers : func_get_args();
454
455 foreach ($identifiers as $identifier) {
456 $this->wheres[$identifier] = '[a-zA-Z0-9-_]+';
457 }
458
459 return $this;
460 }
461
462 /**
463 * Set the route before middleware
464 *
465 * @param array|string $middleware
466 * @return self
467 */
468 public function before(...$middleware)
469 {
470 return $this->middleware('before', ...$middleware);
471 }
472
473 /**
474 * Set the route after middleware
475 *
476 * @param array|string $middleware
477 * @return self
478 */
479 public function after(...$middleware)
480 {
481 return $this->middleware('after', ...$middleware);
482 }
483
484 /**
485 * Set the route middleware
486 * @param array $middleware
487 * @return self
488 */
489 public function middleware($type = 'before', ...$middleware)
490 {
491 if (is_array($middleware[0])) {
492 $middleware = reset($middleware);
493 }
494
495 $this->middleware[$type] = array_merge(
496 $this->middleware[$type], $middleware
497 );
498
499 return $this;
500 }
501
502 /**
503 * Set the default route policy.
504 *
505 * @return self
506 */
507 public function withDefaultPolicy()
508 {
509 return $this->withPolicy(
510 // @phpstan-ignore-next-line
511 $this->app->__namespace__.'\\App\\Http\\Policies\\Policy'
512 );
513 }
514
515 /**
516 * Set the route policy
517 *
518 * @param mixed $handler
519 * @param string|null $method
520 * @return self
521 */
522 public function withPolicy($handler, $method = null)
523 {
524 if (is_array($handler = $method ? func_get_args() : $handler)) {
525 $handler = implode('@', $handler);
526 }
527
528 $this->policyHandler = $handler;
529
530 if (is_string($handler) && !$this->app->hasNamespace($handler)) {
531 $this->setPolicyHandlerWithNamespace(
532 debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 4)
533 );
534 }
535
536 return $this->addRouteInfo($handler);
537 }
538
539 /**
540 * Check if the request is from CLI;
541 *
542 * @return bool
543 */
544 protected function fromCli()
545 {
546 $hash = $this->app->request->header('X-From-CLI');
547
548 $slugHash = md5($this->app->config->get('app.slug'));
549
550 return $hash === $slugHash;
551 }
552
553 /**
554 * Add route information for CLI command.
555 *
556 * @param mixed $handler
557 * @return self
558 */
559 protected function addRouteInfo($handler)
560 {
561 if (!$this->fromCli()) {
562 return $this;
563 }
564
565 if ($handler instanceof Closure) {
566 $policyHandler = 'Closure';
567 } else {
568 $policyHandler = $this->resolvePolicyHandler();
569 if (is_array($policyHandler)) {
570 $policyHandler = implode('@', $policyHandler);
571 }
572 }
573
574 $this->injectProp('policy', $policyHandler);
575
576 return $this;
577 }
578
579 /**
580 * Inject property into route infio.
581 *
582 * @param string $key
583 * @param mixed $value
584 * @return void
585 */
586 public function injectProp($key, $value)
587 {
588 if (!$this->endpointSignature) {
589 return;
590 }
591
592 [$controller, $cbKey] = $this->endpointSignature;
593
594 $controllerKey = str_replace('\\', '.', $controller);
595
596 // @phpstan-ignore-next-line
597 $endpoints = $this->app->endpoints;
598
599 if (isset($endpoints[$controllerKey][$cbKey])) {
600 $endpoints[$controllerKey][$cbKey][$key] = $value;
601 // @phpstan-ignore-next-line
602 $this->app->endpoints = $endpoints;
603 }
604 }
605
606 /**
607 * Resolve and set policy with namespace for add-ons
608 *
609 * @param array $backTrace
610 * @return void
611 */
612 protected function setPolicyHandlerWithNamespace($backTrace)
613 {
614 $last = end($backTrace);
615
616 if (!isset($last['class'])) return;
617
618 $class = $last['class'];
619
620 $namespace = substr(__NAMESPACE__, 0, strpos(__NAMESPACE__, '\\'));
621
622 $calledClassNamespace = substr($class, 0, strpos($class, '\\'));
623
624 if ($namespace != $calledClassNamespace) {
625 $ns = $calledClassNamespace . '\\App\\Http\\Policies\\';
626 $this->policyHandler = $ns . $this->policyHandler;
627 }
628 }
629
630 /**
631 * Set the name for the route.
632 *
633 * @param string $name
634 * @return self
635 */
636 public function name($name)
637 {
638 if (!$this->name) {
639 $this->name = $name;
640 } else {
641 $this->name .= $name;
642 }
643
644 // @phpstan-ignore-next-line
645 return $this->app->router->setNamedRoute($this->name, $this);
646 }
647
648 /**
649 * Set the name for the route.
650 *
651 * @param string $name
652 * @return null
653 */
654 public function withName($name)
655 {
656 $this->name = implode('', $name);
657 }
658
659 /**
660 * Set the namespace for controller/action.
661 *
662 * @param string $ns
663 * @return null
664 */
665 public function withNamespace($ns)
666 {
667 if (is_array($ns)) {
668 $this->namespace = implode('\\', $ns);
669 } else {
670 $this->namespace = trim($ns, '\\');
671 }
672 }
673
674 /**
675 * Sign the route.
676 *
677 * @return $this
678 */
679 public function signed()
680 {
681 $this->signed = true;
682
683 return $this;
684 }
685
686 /**
687 * Apply rate limit to the route.
688 *
689 * @param int $limit Number of allowed requests.
690 * @param int $interval Time interval in seconds.
691 *
692 * @return $this
693 */
694 public function rateLimit($limit, $interval)
695 {
696 // Since the rate limiter is applied twice because
697 // WordPress sends an extra request for every
698 // request, so we need to double the limit.
699
700 $rateLimiter = new RateLimiter($limit * 2, $interval);
701
702 $this->middleware('before', $rateLimiter);
703
704 return $this;
705 }
706
707 /**
708 * Apply a rate limit to the route for per minute.
709 *
710 * @param int $limit The maximum number of requests allowed per minute.
711 * @return $this
712 */
713 public function rateLimitPerMinute($limit)
714 {
715 return $this->rateLimit($limit, MINUTE_IN_SECONDS);
716 }
717
718 /**
719 * Apply an hourly rate limit to the route.
720 *
721 * @param int $limit The maximum number of requests allowed per hour.
722 * @return $this
723 */
724 public function rateLimitHourly($limit)
725 {
726 return $this->rateLimit($limit, HOUR_IN_SECONDS);
727 }
728
729 /**
730 * Apply a daily basis (24 hours) rate limit to the route.
731 *
732 * @param int $limit The maximum number of requests allowed per day.
733 * @return $this
734 */
735 public function rateLimitDaily($limit)
736 {
737 return $this->rateLimit($limit, DAY_IN_SECONDS);
738 }
739
740 /**
741 * Register the rest endpoint
742 *
743 * @return null
744 */
745 public function register()
746 {
747 $this->updateRouteOptions();
748
749 return register_rest_route(
750 $this->restNamespace,
751 $this->getRouteUri(),
752 $this->getOptions(),
753 $this->shouldOverride
754 );
755 }
756
757 /**
758 * Update route options before registering.
759 *
760 * @return void
761 */
762 protected function updateRouteOptions()
763 {
764 $this->setOptions();
765 }
766
767 /**
768 * Get normalized uri for the current route.
769 *
770 * @return string
771 */
772 protected function getRouteUri()
773 {
774 return '/' . trim($this->compileRoute($this->uri), '/');
775 }
776
777 /**
778 * Mark this route to override any existing route at the same URI.
779 *
780 * @return $this
781 */
782 public function override()
783 {
784 $this->shouldOverride = true;
785
786 return $this;
787 }
788
789 /**
790 * Set route options
791 *
792 * @return null
793 */
794 protected function setOptions()
795 {
796 $this->options = array_merge(
797 $this->options, $this->getDefaultOptions()
798 );
799 }
800
801 /**
802 * Get default options.
803 *
804 * @return array
805 */
806 protected function getDefaultOptions()
807 {
808 return [
809 [
810 'methods' => $this->method,
811 'callback' => [$this, 'callback'],
812 'permission_callback' => [$this, 'permissionCallback'],
813 'args' => [],
814 ],
815 ];
816 }
817
818 /**
819 * Generate and return the schema for the route.
820 *
821 * @return self
822 * @see https://developer.wordpress.org/rest-api/extending-the-rest-api/schema
823 */
824 public function schema($schema)
825 {
826 $this->options['schema'] = fn() => $schema;
827
828 return $this;
829 }
830
831 /**
832 * Get item from predefined regex
833 * @param string $value
834 * @return string
835 */
836 protected function getValue($value)
837 {
838 if (array_key_exists($value, $this->predefinedNamedRegx)) {
839 return $this->predefinedNamedRegx[$value];
840 }
841
842 return $value;
843 }
844
845 /**
846 * Compikle the rest route to regex
847 *
848 * @param string $uri
849 * @return string compiled rest endpoint
850 */
851 protected function compileRoute($uri)
852 {
853 $params = [];
854
855 $compiledUri = preg_replace_callback('#/{(.*?)}#', function ($match) use (&$params, $uri) {
856 // Default regx
857 $regx = '[^\s(?!/)]+';
858
859 $param = trim($match[1]);
860
861 if ($isOptional = strpos($param, '?')) {
862 $param = trim($param, '?');
863 }
864
865 if (in_array($param, $params)) {
866 throw new InvalidArgumentException(
867 "Duplicate parameter name '{$param}' found in {$uri}.", 500
868 );
869 }
870
871 $params[] = $param;
872
873 if (isset($this->wheres[$param])) {
874 $regx = $this->wheres[$param];
875 }
876
877 $pattern = "/(?P<" . $param . ">" . $regx . ")";
878
879 if ($isOptional) {
880 $pattern = "(?:" . $pattern . ")?";
881 }
882
883 $this->options['args'][$param]['required'] = !$isOptional;
884
885 return $pattern;
886
887 }, $uri);
888
889 return $this->compiled = $compiledUri;
890 }
891
892 /**
893 * Route handler
894 *
895 * @return \WP_REST_Response
896 */
897 public function callback()
898 {
899 try {
900 $this->response = $this->handleAfterMiddleware(
901 $this->dispatchRouteAction()
902 );
903
904 return $this->handleResponse($this->response);
905
906 } catch (ValidationException $e) {
907 return $this->app->response->sendError(
908 $e->errors(), $e->getCode()
909 );
910 } catch (ModelNotFoundException $e) {
911 return $this->app->response->sendError([
912 'message' => $e->getMessage()
913 ], 404);
914 } catch (HttpException $e) {
915 return $this->renderHttpException($e);
916 } catch (Throwable $e) {
917 $headers = $this->response ? $this->response->get_headers() : [];
918
919 // Consult the plugin's ExceptionHandler registry BEFORE the
920 // production sanitizer. A registered renderable may return
921 // either an HttpException (rendered with full status + safe
922 // message) or a WP_REST_Response (returned verbatim). Null /
923 // no-match falls through to handleUnknownException — the
924 // sanitization default is preserved for any exception not
925 // explicitly opted in.
926 if ($mapped = $this->mapToHandlerResponse($e)) {
927 return $mapped;
928 }
929
930 return $this->handleUnknownException($e, $headers);
931 }
932 }
933
934 /**
935 * Run the bound `ExceptionHandler` over `$e` and convert its result
936 * to a `WP_REST_Response`, or `null` if the handler has nothing for
937 * this exception (in which case the caller falls through to the
938 * sanitizer).
939 *
940 * Returns an `HttpException` result through `renderHttpException()`
941 * so observability + headers + the `{code, message, data}` shape
942 * stay consistent with the dedicated `HttpException` catch arm.
943 * A `WP_REST_Response` is returned verbatim — the renderer claimed
944 * full control over the response shape; we still fire
945 * `fluent_exception` so observability listeners see the original
946 * exception.
947 *
948 * @param \Throwable $e
949 * @return \WP_REST_Response|null
950 */
951 protected function mapToHandlerResponse(Throwable $e)
952 {
953 if (!$this->app->bound(ExceptionHandler::class)) {
954 return null;
955 }
956
957 $handler = $this->app->make(ExceptionHandler::class);
958
959 if (!$handler instanceof ExceptionHandler) {
960 return null;
961 }
962
963 $result = $handler->render($e, $this->app);
964
965 if ($result instanceof HttpException) {
966 return $this->renderHttpException($result);
967 }
968
969 if ($result instanceof WP_REST_Response) {
970 $this->fireExceptionEvent($e);
971 return $result;
972 }
973
974 return null;
975 }
976
977 /**
978 * Handle response from route.
979 *
980 * @param \WP_REST_Response $response
981 * @return \WP_REST_Response
982 */
983 protected function handleResponse($response)
984 {
985 return $response;
986 }
987
988 /**
989 * Throw an exception based on the status code.
990 *
991 * @param string $message
992 * @param int $status
993 * @return null
994 * @throws \Exception
995 */
996 protected function throwException($message, $status)
997 {
998 $class = sprintf(
999 'WpOrg\Requests\Exception\Http\Status%d', $status
1000 );
1001
1002 if (!class_exists($class)) {
1003 $class = 'WpOrg\Requests\Exception\Http';
1004 }
1005
1006 throw new $class($message, $status);
1007 }
1008
1009 /**
1010 * Handle exception and send error response.
1011 *
1012 * @param Throwable $e
1013 * @return \WP_REST_Response
1014 */
1015 protected function handleUnknownException(Throwable $e, $headers = [])
1016 {
1017 $data = [];
1018
1019 $this->fireExceptionEvent($e);
1020
1021 // Production sanitization: client-facing message must not leak
1022 // PDO / HTTP-client / file-system internals. The real message
1023 // ships to fluent_exception listeners (Night Watcher / bridge)
1024 // via fireExceptionEvent above, so observability is preserved.
1025 if ($this->app->isDebugOn()) {
1026 $data = [
1027 'file' => $e->getFile(),
1028 'line' => $e->getLine(),
1029 ];
1030
1031 $message = $e->getMessage();
1032 } else {
1033 $message = 'An internal error occurred.';
1034 }
1035
1036 return $this->app->response->sendError([
1037 'code' => 'plugin_exception',
1038 'data' => $data,
1039 'message' => $message,
1040 ], $e->getCode() ?: 500, $headers);
1041 }
1042
1043 /**
1044 * Render an HttpException to a sanitization-free response.
1045 *
1046 * HttpException is the opt-in contract for "I authored this message,
1047 * it is safe to ship to the client". Bypasses handleUnknownException's
1048 * production sanitization but still fires fluent_exception for
1049 * observability so listeners see every thrown HttpException.
1050 *
1051 * @param HttpException $e
1052 * @return \WP_REST_Response
1053 */
1054 protected function renderHttpException(HttpException $e)
1055 {
1056 $this->fireExceptionEvent($e);
1057
1058 return $this->app->response->sendError([
1059 'code' => $e->getErrorCode(),
1060 'message' => $e->getMessage(),
1061 'data' => $e->getData(),
1062 ], $e->getStatusCode(), $e->getHeaders());
1063 }
1064
1065 /**
1066 * Dispatch the route action.
1067 *
1068 * @return \WP_REST_Response
1069 */
1070 protected function dispatchRouteAction()
1071 {
1072 $response = $this->app->call(
1073 $this->action, $this->getControllerParameters()
1074 );
1075
1076 if ($response instanceof WPFluentResponse) {
1077 $response = $response->toArray();
1078 } elseif (!($response instanceof WP_REST_Response)) {
1079 $response = !is_wp_error($response) ?
1080 $this->app->response->sendSuccess($response) :
1081 $this->app->response->wpErrorToResponse($response);
1082 }
1083
1084 return $response;
1085 }
1086
1087 /**
1088 * Handle after middleware if any.
1089 *
1090 * @param mixed $response
1091 * @return mixed
1092 */
1093 protected function handleAfterMiddleware($response)
1094 {
1095 if (!$this->skipMiddleware) {
1096 $response = $this->app->make(Pipeline::class)
1097 ->send(new WPFluentResponse($response))
1098 ->through($this->collectMiddleWare('after'))
1099 ->then(function($response) {
1100 return $this->normalize($response);
1101 });
1102
1103 if (!$response) {
1104 $response = $this->app->request->abort();
1105 }
1106 }
1107
1108 return $response;
1109 }
1110
1111 /**
1112 * Normalize the response.
1113 *
1114 * @param mixed $response
1115 * @return mixed
1116 */
1117 protected function normalize($response)
1118 {
1119 if ($response instanceof WPFluentResponse) {
1120 $response = $response->toArray();
1121 }
1122
1123 if (!$response instanceof WP_REST_Response) {
1124 return new WP_REST_Response($response);
1125 }
1126
1127 return $response;
1128 }
1129
1130 /**
1131 * Fire exception action hook.
1132 *
1133 * @param Exception $exception
1134 * @return void
1135 */
1136 protected function fireExceptionEvent($exception)
1137 {
1138 // Reentrancy guard: a fluent_exception listener that itself triggers
1139 // an exception path must not re-enter this method and recurse. Reset
1140 // in finally so subsequent (sequential) calls proceed normally.
1141 static $firing = false;
1142
1143 if ($firing) {
1144 return;
1145 }
1146
1147 if ($this->app->isDebugOn() || defined('FLUENT_BRIDGE_SECRET')) {
1148 $message = sprintf(
1149 "%s in %s:%d\nStack trace:\n%s\n",
1150 $exception->getMessage(),
1151 $exception->getFile(),
1152 $exception->getLine(),
1153 $exception->getTraceAsString()
1154 );
1155
1156 error_log($message);
1157 }
1158
1159 $firing = true;
1160
1161 try {
1162 $this->app->doAction('fluent_exception', $exception);
1163 } catch (Throwable $listenerError) {
1164 // Listener-throw isolation: a buggy fluent_exception listener
1165 // (DB down, disk full) must not escape and crash the response.
1166 // Log under the same gate; never re-fire fluent_exception here
1167 // — that would be the cascade we are protecting against.
1168 if ($this->app->isDebugOn() || defined('FLUENT_BRIDGE_SECRET')) {
1169 error_log(
1170 'fluent_exception listener failed: ' . $listenerError->getMessage()
1171 );
1172 }
1173 } finally {
1174 $firing = false;
1175 }
1176 }
1177
1178 /**
1179 * Permission callback for route
1180 * @param \WP_REST_Request $wpRestRequest
1181 * @return mixed
1182 */
1183 public function permissionCallback($wpRestRequest)
1184 {
1185 try {
1186 $this->parameters = null;
1187 $this->substitutedParameters = null;
1188 $this->app->instance('route', $this);
1189 $this->app->instance('wprestrequest', $wpRestRequest);
1190 $this->app->request->mergeInputsFromRestRequest($wpRestRequest);
1191 $this->prepareCallbacks($this->app->request);
1192
1193 if (!$this->isThisValidSignedRoute()) {
1194 throw new Exception('Invalid Signature', 403);
1195 }
1196
1197 $response = $this->app->make(Pipeline::class)
1198 ->send($this->app->request)
1199 ->through($this->collectMiddleWare('before'))
1200 ->then(function ($request) {
1201 if ($request && $request instanceof Request) {
1202 return $this->dispatchPermissionHandler();
1203 }
1204 });
1205
1206 if (is_wp_error($response)) {
1207 throw new Exception(
1208 $response->get_error_message(),
1209 is_int($code = $response->get_error_code()) ? $code : 403
1210 );
1211 }
1212
1213 if ($response instanceof WP_REST_Response) {
1214 $data = $response->get_data();
1215
1216 throw new Exception(
1217 $data['message'] ?? $response->get_status(),
1218 $response->get_status()
1219 );
1220 }
1221
1222 return $response;
1223
1224 } catch (Throwable $e) {
1225 return new WP_Error(
1226 'Permission Callback Error',
1227 $e->getMessage(), [
1228 'status' => $e->getCode() ?: 403
1229 ]
1230 );
1231 }
1232 }
1233
1234 /**
1235 * Checks if the route is signed and needs validation.
1236 *
1237 * @return boolean [description]
1238 */
1239 protected function isThisValidSignedRoute()
1240 {
1241 if (!$this->signed) return true;
1242
1243 $request = $this->app->make('request');
1244
1245 if ($this->app->make('url')->validate($request->getFullUrl())) {
1246 parse_str($this->app->make('encrypter')->decrypt(
1247 $this->app->request->get('_data')
1248 ), $query);
1249
1250 $this->app->request->merge(
1251 Arr::except($query, ['expires_at'])
1252 );
1253
1254 $this->app->request->forget('_data');
1255
1256 return true;
1257 }
1258 }
1259
1260 /**
1261 * Dispatches the permission handler.
1262 *
1263 * @return bool|null
1264 */
1265 protected function dispatchPermissionHandler()
1266 {
1267 if (!$this->permissionHandler) {
1268 return true;
1269 }
1270
1271 $isValid = $this->app->call(
1272 $this->permissionHandler,
1273 $this->getControllerParameters()
1274 );
1275
1276 if (is_object($isValid)) {
1277 if ($this->isUser($isValid)) {
1278 $isValid = $isValid->id();
1279 } else {
1280 $this->throwInvalidPolicy();
1281 }
1282 }
1283
1284 if (!is_bool($isValid) && !is_int($isValid) && !is_null($isValid)) {
1285 $this->throwInvalidPolicy();
1286 }
1287
1288 return (bool) $isValid;
1289 }
1290
1291 /**
1292 * Checks if the user is an instance of WPUserProxy.
1293 *
1294 * @param WPUserProxy $user
1295 * @return bool
1296 */
1297 protected function isUser($user)
1298 {
1299 return $user instanceof WPUserProxy;
1300 }
1301
1302 /**
1303 * Throw invalid policy handling exception.
1304 *
1305 * @return InvalidArgumentException
1306 */
1307 protected function throwInvalidPolicy()
1308 {
1309 throw new InvalidArgumentException(
1310 'The policy must return a boolean, integer, null, or a WPUserProxy instance.', 500
1311 );
1312 }
1313
1314 /**
1315 * Gether route params after substituted the params
1316 *
1317 * @return array
1318 */
1319 protected function getControllerParameters()
1320 {
1321 $routeParameters = [];
1322
1323 if (!$this->substitutedParameters) {
1324 if ($routeParameters = $this->getParameter()) {
1325 $routeParameters = $this->substituteParameters($routeParameters);
1326 }
1327 } else {
1328 $routeParameters = $this->substitutedParameters;
1329 }
1330
1331 return $routeParameters;
1332 }
1333
1334 /**
1335 * Added the ability to add middleware so we can intercept
1336 * the request without modifying the source code again
1337 * and again. The middleware class will implement
1338 * the handle method as given below:
1339 *
1340 * public function handle($request, $next)
1341 *
1342 * And must return $next($request) to handle the request.
1343 * Otherwise return nothing to abort the request.
1344 * Optionally, you may call the abort method:
1345 * return $request->abort(code, message);
1346 *
1347 * @param string $type
1348 * @return array
1349 */
1350 protected function collectMiddleWare($type = 'before')
1351 {
1352 $middleware = $this->app->bound('http.middleware')
1353 ? $this->app['http.middleware']
1354 : [];
1355
1356 $callableMiddleware = Arr::get($middleware, "global.{$type}", []);
1357
1358 $routeArray = [];
1359
1360 if (isset($middleware['route'])) {
1361 $routeArray = $middleware['route'];
1362 if (isset($routeArray[$type])) {
1363 $routeArray = $routeArray[$type];
1364 }
1365 }
1366
1367 foreach ($this->middleware[$type] as $routeMiddleware) {
1368
1369 if (is_object($routeMiddleware)) {
1370 $handler = $routeMiddleware;
1371 } elseif (class_exists($routeMiddleware)) {
1372 $handler = $this->resolveMiddlewareFrom($routeMiddleware);
1373 } else {
1374 $pieces = explode(':', $routeMiddleware);
1375 $handler = Arr::get($routeArray, $key = reset($pieces));
1376 if (isset($pieces[1])) {
1377 $handler = $this->resolveMiddleware($handler, $pieces);
1378 }
1379 }
1380
1381 if (isset($handler)) {
1382 $this->addMiddlewareInTheStack($callableMiddleware, $handler);
1383 } else {
1384 if (isset($key)) {
1385 $mpath = 'app/Http/middleware.php route.' . $type;
1386 $msg = "No middleware is assigned for the key: {$key} in {$mpath} array.";
1387 } else {
1388 $msg = "Could't resolve middleware.";
1389 }
1390
1391 throw new InvalidArgumentException($msg);
1392 }
1393 }
1394
1395 return $callableMiddleware;
1396 }
1397
1398 /**
1399 * Resolve a middleware from a class.
1400 *
1401 * @param string $class
1402 * @return \Closure
1403 */
1404 protected function resolveMiddlewareFrom($class)
1405 {
1406 return static function ($r, $next, ...$params) use ($class) {
1407 return (new $class)->handle($r, $next, ...$params);
1408 };
1409 }
1410
1411 /**
1412 * Resolve the middleware
1413 *
1414 * @param mixed $handler
1415 * @param array $pieces
1416 * @return object
1417 */
1418 protected function resolveMiddleware($handler, $pieces)
1419 {
1420 if (is_object($handler)) {
1421 $handler = $this->wrapMiddleware($handler, $pieces);
1422 } elseif (is_string($handler)) {
1423 $handler = $handler . ':' . str_replace(' ', '', end($pieces));
1424 }
1425
1426 return $handler;
1427 }
1428
1429 /**
1430 * Create a class to wrap the middleware
1431 *
1432 * @param mixed $handler
1433 * @param array $pieces
1434 * @return object
1435 */
1436 protected function wrapMiddleware($handler, $pieces)
1437 {
1438 $params = str_replace(' ', '', end($pieces));
1439
1440 $params = explode(',', $params);
1441
1442 return new class ($handler, $params) {
1443 protected $handler, $params = null;
1444
1445 public function __construct($handler, $params) {
1446 $this->handler = $handler;
1447 $this->params = $params;
1448 }
1449
1450 public function handle($r, $next) {
1451 if (is_callable($this->handler)) {
1452 return ($this->handler)($r, $next, ...$this->params);
1453 } else {
1454 if (!method_exists($this->handler, 'handle')) {
1455 $class = get_class($this->handler);
1456 throw new InvalidArgumentException(
1457 "The {$class} must implement the handle method."
1458 );
1459 }
1460 return $this->handler->handle($r, $next, ...$this->params);
1461 }
1462 }
1463 };
1464 }
1465
1466 /**
1467 * Add the middleware in the stack
1468 *
1469 * @param array &$stack All callable middleware for the route
1470 * @param string $middleware
1471 * @return void
1472 */
1473 protected function addMiddlewareInTheStack(&$stack, $middleware)
1474 {
1475 if (!in_array($middleware, $stack)) {
1476 $stack[] = $middleware;
1477 }
1478 }
1479
1480 /**
1481 * Resolve the policy handler
1482 *
1483 * @param string $policyHandler
1484 * @return mixed
1485 */
1486 protected function getPolicyHandler($policyHandler)
1487 {
1488 if (!$policyHandler) {
1489 return [$this, 'defaultPolicyHandler'];
1490 }
1491
1492 if (is_callable($policyHandler)) {
1493 return $policyHandler;
1494 }
1495
1496 if (is_string($policyHandler)) {
1497
1498 if (function_exists($policyHandler)) {
1499 return $policyHandler;
1500 }
1501
1502 $policyHandlerFunction = substr(
1503 $policyHandler, strrpos($policyHandler, '\\') + 1
1504 );
1505
1506 if (function_exists($policyHandlerFunction)) {
1507 return $policyHandlerFunction;
1508 }
1509 }
1510
1511 if ($this->isPolicyHandlerParseable($policyHandler)) {
1512 return $policyHandler;
1513 }
1514
1515 if (is_string($policyHandler) && $this->handler instanceof Closure) {
1516
1517 if (class_exists($policyHandler)) {
1518
1519 $reflection = new ReflectionClass($policyHandler);
1520
1521 if ($reflection->hasMethod('verifyRequest')) {
1522 return $policyHandler . '@' . 'verifyRequest';
1523 }
1524 } elseif (function_exists($policyHandler)) {
1525 return $policyHandler;
1526 }
1527
1528 throw new InvalidArgumentException(
1529 'Explicit policy handler is required while using a closure as route callback.'
1530 );
1531 }
1532
1533 if ($policyHandler && !function_exists($policyHandler)) {
1534 [$_, $method] = is_array($this->handler)
1535 ? [$this->handler[0], $this->handler[1] ?? '__invoke']
1536 : Str::parseCallback($this->handler, '__invoke');
1537
1538 $policyHandler .= '@' . $method;
1539 }
1540
1541 return $policyHandler ?: [$this, 'defaultPolicyHandler'];
1542 }
1543
1544 /**
1545 * Check if the policy handler is parseable.
1546 *
1547 * @param string $policyHandler
1548 * @return boolean
1549 */
1550 protected function isPolicyHandlerParseable($policyHandler)
1551 {
1552 return (strpos($policyHandler, '@') !== false
1553 || strpos($policyHandler, '::') !== false);
1554 }
1555
1556 /**
1557 * Default/Fallback policy handler for the route
1558 *
1559 * @return bool
1560 */
1561 public function defaultPolicyHandler()
1562 {
1563 return true;
1564 }
1565
1566 /**
1567 * Parse the rest and permission/policy handlers
1568 *
1569 * @param \WP_REST_Request $request
1570 * @return null
1571 * @throws \BadMethodCallException
1572 */
1573 public function prepareCallbacks($request)
1574 {
1575 $handler = $this->app->parseRestHandler($this->handler, $this->namespace);
1576
1577 [$action, $controller] = $this->resolveHandlerDetails($handler);
1578
1579 $policyHandler = $this->resolvePolicyHandler();
1580
1581 $this->actionInfo = [
1582 'handler' => is_object($handler) ? $action : $handler,
1583 'controller' => $controller,
1584 'method' => $this->getMethodName($action, $handler),
1585 'path' => $this->uri,
1586 'http_method' => $request->get_method(),
1587 'full_uri' => $request->get_route(),
1588 'permission_callback' => $policyHandler,
1589 'compiled_url' => $this->compiled
1590 ];
1591
1592 $this->action = $handler;
1593
1594 if ($routeParameters = $this->getParameter()) {
1595 $this->substitutedParameters = $this->substituteParameters($routeParameters);
1596 }
1597
1598 return $this->action;
1599 }
1600
1601 /**
1602 * Get the method name to build action info.
1603 *
1604 * @param mixed $action
1605 * @param mixed $handler
1606 * @return string|null
1607 */
1608 protected function getMethodName($action, $handler)
1609 {
1610 $method = is_array($action) ? $action[1] ?? '__invoke' : null;
1611
1612 if (is_null($method) && is_object($handler)) {
1613 $method = '__invoke';
1614 }
1615
1616 return $method;
1617 }
1618
1619 /**
1620 * Resolve the handler details.
1621 *
1622 * @param mixed $handler
1623 * @return array
1624 */
1625 protected function resolveHandlerDetails($handler)
1626 {
1627 if ($handler instanceof Closure) {
1628 return ['Closure', null];
1629 }
1630
1631 if (is_object($handler)) {
1632 $class = get_class($handler);
1633 return [$class, $class];
1634 }
1635
1636 $handler = trim($handler, '\\');
1637 [$controller, $method] = Str::parseCallback($handler, '__invoke');
1638 $controllerName = $this->extractControllerName($controller);
1639
1640 return [[$controller, $method], $controllerName];
1641 }
1642
1643 /**
1644 * Extract the controller name from the FQCN.
1645 *
1646 * @param string $fqcn
1647 * @return string
1648 */
1649 protected function extractControllerName($fqcn)
1650 {
1651 $parts = explode('\\', $fqcn);
1652 return end($parts);
1653 }
1654
1655 /**
1656 * Parse and validate the policy handler.
1657 *
1658 * @return array
1659 */
1660 protected function resolvePolicyHandler()
1661 {
1662 try {
1663 $policyHandler = $this->app->parsePolicyHandler(
1664 $this->getPolicyHandler($this->policyHandler)
1665 );
1666
1667 if ($policyHandler) {
1668 $this->permissionHandler = $policyHandler;
1669
1670 // Adjust method if explicitly given in string policy handler
1671 if (is_string($this->policyHandler) && is_array($policyHandler) && isset($policyHandler[1])) {
1672 $pieces = explode('@', $this->policyHandler);
1673 if (isset($pieces[1])) {
1674 $this->permissionHandler[1] = $pieces[1];
1675 }
1676 }
1677
1678 if (!is_callable($this->permissionHandler)) {
1679 throw new Exception;
1680 }
1681 }
1682
1683 } catch (Exception $e) {
1684 throw $this->invalidPolicyHandlerException();
1685 }
1686
1687 // Convert object controller to class string for endpoint metadata
1688 if (is_array($policyHandler) && is_object($policyHandler[0])) {
1689 $policyHandler[0] = get_class($policyHandler[0]);
1690 }
1691
1692 return $policyHandler;
1693 }
1694
1695 /**
1696 * Build and throw an exception for invalid policy handlers.
1697 *
1698 * @throws \BadMethodCallException
1699 */
1700 protected function invalidPolicyHandlerException()
1701 {
1702 $pHandler = $this->policyHandler;
1703
1704 if (is_array($this->permissionHandler) && $this->permissionHandler) {
1705 $pHandler = is_object($this->permissionHandler[0])
1706 ? get_class($this->permissionHandler[0]) . ':' . $this->permissionHandler[1]
1707 : $this->permissionHandler[0] . ':' . $this->permissionHandler[1];
1708 }
1709
1710 return new BadMethodCallException(
1711 "The permission callback {$pHandler} is invalid or not callable."
1712 );
1713 }
1714
1715 /**
1716 * Get one or more route parameters
1717 * @param string $key
1718 *
1719 * @return mixed
1720 */
1721 public function getParameter($key = null)
1722 {
1723 if (is_null($this->parameters)) {
1724 $this->parameters = $this->app->request->get_url_params();
1725 }
1726
1727 return $key ? $this->parameters[$key] : $this->parameters;
1728 }
1729
1730 /**
1731 * Get the name of the route.
1732 *
1733 * @return string
1734 */
1735 public function getName()
1736 {
1737 return $this->name;
1738 }
1739
1740 /**
1741 * Get the url of the route.
1742 *
1743 * @return string
1744 */
1745 public function getUrl()
1746 {
1747 return $this->uri;
1748 }
1749
1750 /**
1751 * Get the url of the route.
1752 *
1753 * @return string
1754 */
1755 public function uri()
1756 {
1757 return $this->getUrl();
1758 }
1759
1760 /**
1761 * Dynamically access a route parameter.
1762 *
1763 * @param string $key
1764 * @return mixed
1765 */
1766 public function __get($key)
1767 {
1768 return $this->getParameter($key);
1769 }
1770 }
1771