PluginProbe
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder / 6.2.14
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder v6.2.14
6.2.14 6.2.13 6.2.12 6.2.10 6.2.11 6.2.9 6.2.8 6.2.7 6.2.6 6.2.5 6.2.4 6.2.3 6.2.2 3.6.22 3.6.31 3.6.40 3.6.41 3.6.42 3.6.50 3.6.51 3.6.60 3.6.61 3.6.62 3.6.64 3.6.65 All 196 releases
fluentform / vendor / wpfluent / framework / src / WPFluent / Http / Route.php

Route.php in Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder 6.2.14, at vendor/wpfluent/framework/src/WPFluent/Http/Route.php

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