PluginProbe
Metricool – Social media and site statistics / 2.1.1
Metricool – Social media and site statistics v2.1.1
2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.27 trunk
← All changes | app/Managers/EndpointManager.php +299 -0 2.0.0 → 2.1.1 View file →
@@ -1,0 +1,299 @@
1 +<?php
2 +
3 +declare(strict_types=1);
4 +
5 +namespace Metricool\Managers;
6 +
7 +use Metricool\Bootstrap\App;
8 +use Metricool\Exceptions\RestDataException;
9 +use Metricool\Interfaces\MiddlewareInterface;
10 +use Metricool\Interfaces\MultiEndpointInterface;
11 +use Metricool\Interfaces\SingleEndpointInterface;
12 +use Metricool\Support\Helpers\Storages\EnvironmentConfig;
13 +use Metricool\Support\Helpers\Storages\MiddlewareConfig;
14 +use Metricool\Traits\HasAllowlistControl;
15 +use Metricool\Traits\HasNonces;
16 +use Metricool\Traits\HasRestAccess;
17 +use Throwable;
18 +
19 +final class EndpointManager extends AbstractManager
20 +{
21 + use HasNonces;
22 + use HasAllowlistControl;
23 + use HasRestAccess;
24 +
25 + private EnvironmentConfig $env;
26 +
27 + private array $routes = [];
28 + private array $defaultMiddleware;
29 + private array $aliases;
30 +
31 + public function __construct(MiddlewareConfig $middleware, EnvironmentConfig $env)
32 + {
33 + $this->env = $env;
34 + $this->aliases = $middleware->get('aliases', []);
35 + $this->defaultMiddleware = $middleware->get('default_middleware', []);
36 + }
37 +
38 + /**
39 + * @inheritDoc
40 + */
41 + public function isRegistrable(object $class): bool
42 + {
43 + return ($class instanceof SingleEndpointInterface
44 + || $class instanceof MultiEndpointInterface
45 + );
46 + }
47 +
48 + /**
49 + * @inheritDoc
50 + */
51 + public function registerClass(object $class): void
52 + {
53 + if ($class instanceof SingleEndpointInterface) {
54 + $this->registerSingleEndpointRoute($class);
55 + }
56 +
57 + if ($class instanceof MultiEndpointInterface) {
58 + $this->registerMultiEndpointRoute($class);
59 + }
60 + }
61 +
62 + /**
63 + * @inheritDoc
64 + */
65 + public function afterRegister(): void
66 + {
67 + $this->registerWordPressRestRoutes();
68 + do_action('metricool_endpoints_loaded');
69 + }
70 +
71 + /**
72 + * Register a plugin route for and endpoint instance that implements the
73 + * {@see SingleEndpointInterface}
74 + */
75 + private function registerSingleEndpointRoute(SingleEndpointInterface $endpoint): void
76 + {
77 + if ($endpoint->enabled() === false) {
78 + return;
79 + }
80 +
81 + $this->routes[$endpoint->registerRoute()] = $endpoint->registerArguments();
82 + }
83 +
84 + /**
85 + * Register plugin routes for an endpoint instance that implements the
86 + * {@see MultiEndpointInterface}
87 + */
88 + private function registerMultiEndpointRoute(MultiEndpointInterface $endpoint): void
89 + {
90 + if ($endpoint->enabled() === false) {
91 + return;
92 + }
93 +
94 + $routeEndpoints = $endpoint->registerRoutes();
95 + foreach ($routeEndpoints as $route => $arguments) {
96 + $this->routes[$route] = $arguments;
97 + }
98 + }
99 +
100 + /**
101 + * This method provides a way to register custom REST routes via the
102 + * metricool_rest_routes filter. A controller or feature should be
103 + * instantiated before this manager is called and the controller should
104 + * hook into the metricool_rest_routes filter to add its own routes.
105 + *
106 + * public function registerArguments(): array
107 + * {
108 + * return [
109 + * 'methods' => \WP_REST_Server::READABLE,
110 + * 'callback' => [$this, 'callback'],
111 + * 'permission_callback' => [$this, 'permissionCallback'],
112 + * 'middleware' => [
113 + * 'metricool:auth', // alias in config/middleware.php
114 + * ExampleMiddleware::class, // MiddlewareInterface class
115 + * ],
116 + * 'apply_default_middleware' => true, // optional, default is true
117 + * 'version' => 'v1', // optional, default is the value config/env.php
118 + * 'args' => [], // optional, args passed to register_rest_route
119 + * ];
120 + * }
121 + *
122 + * @uses apply_filters metricool_rest_routes
123 + * @throws \InvalidArgumentException
124 + * @throws \ReflectionException
125 + */
126 + public function registerWordPressRestRoutes(): void
127 + {
128 + $routes = $this->getPluginRestRoutes();
129 +
130 + foreach ($routes as $route => $data) {
131 + $methods = ($data['methods'] ?? 'GET');
132 + $callback = ($data['callback'] ?? null);
133 + $permissionCallback = ($data['permission_callback'] ?? $this->defaultPermissionCallback());
134 + $middleware = ($data['middleware'] ?? []);
135 + $applyDefaultMiddleware = ($data['apply_default_middleware'] ?? true);
136 + $version = ($data['version'] ?? $this->env->getString('http.version'));
137 + $args = ($data['args'] ?? null);
138 +
139 + if (!is_callable($callback)) {
140 + throw new \InvalidArgumentException(
141 + esc_html(sprintf('The callback for the route: %s is not callable.', $route))
142 + );
143 + }
144 +
145 + if ($applyDefaultMiddleware === true) {
146 + $middleware = array_merge($this->defaultMiddleware, $middleware);
147 + }
148 +
149 + $arguments = [
150 + 'methods' => $this->normalizeMethods($methods),
151 + 'callback' => $this->applyMiddleware($callback, $middleware),
152 + 'permission_callback' => $permissionCallback,
153 + ];
154 +
155 + if ($args !== null) {
156 + $arguments['args'] = $args;
157 + }
158 +
159 + register_rest_route($this->env->getString('http.namespace') . '/' . $version, $route, $arguments);
160 + }
161 + }
162 +
163 + /**
164 + * The default permission callback applied to routes that do not define
165 + * their own. Default is ```metricool_manage``` capability check.
166 + */
167 + private function defaultPermissionCallback(): callable
168 + {
169 + return function (): bool {
170 + return $this->userCanManage();
171 + };
172 + }
173 +
174 + /**
175 + * Get the plugins REST routes
176 + * @uses apply_filters metricool_rest_routes
177 + */
178 + private function getPluginRestRoutes(): array
179 + {
180 + /**
181 + * Filter: metricool_rest_routes
182 + * Can be used to add or modify the REST routes
183 + *
184 + * @param array $routes
185 + * @return array
186 + * @example [
187 + * 'route' => [ // key is the route name
188 + * 'methods' => 'GET', // required
189 + * 'callback' => 'callback_function', // required
190 + * 'permission_callback' => 'permission_callback_function', // optional to override the default permission callback
191 + * 'version' => 'v1' // optional to override the default version
192 + * ]
193 + * ]
194 + */
195 + return apply_filters('metricool_rest_routes', $this->routes);
196 + }
197 +
198 + /**
199 + * Wrap the endpoint callback with middleware. Provided middleware can either be an alias ```auth:metricool``` or
200 + * a FQCN```MetricoolAuthenticated::class```
201 + *
202 + * @param callable $callback The endpoint's callback
203 + * @param array $middlewares The middleware to apply
204 + * @return callable The wrapped callback
205 + *
206 + * @throws \ReflectionException
207 + */
208 + public function applyMiddleware(callable $callback, array $middlewares = []): callable
209 + {
210 + $instances = $this->resolveMiddleware($middlewares);
211 + $pipeline = $this->buildPipeline($callback, $instances);
212 +
213 + return function (\WP_REST_Request $request) use ($pipeline) {
214 + try {
215 + return $pipeline($request);
216 + } catch (RestDataException $e) {
217 + return $this->sendHttpErrorResponse($e->getMessage(), $e->getData(), $e->getResponseCode());
218 + } catch (Throwable $e) {
219 + return $this->sendHttpErrorResponse($e->getMessage(), null, $e->getCode() ?: 500);
220 + }
221 + };
222 + }
223 +
224 + /**
225 + * Return a pipeline of middleware into a single callback.
226 + */
227 + private function buildPipeline(callable $callback, array $middleware): callable
228 + {
229 + return array_reduce(
230 + array_reverse($middleware),
231 + static function (callable $next, MiddlewareInterface $middleware): callable {
232 + return static function (\WP_REST_Request $request) use ($middleware, $next) {
233 + return $middleware->handle($request, $next);
234 + };
235 + },
236 + static function (\WP_REST_Request $request) use ($callback) {
237 + return $callback($request);
238 + }
239 + );
240 + }
241 +
242 + /**
243 + * Resolve middleware entries to MiddlewareInterface instances.
244 + *
245 + * @param string[] $middleware
246 + * @return MiddlewareInterface[]
247 + * @throws \ReflectionException
248 + */
249 + private function resolveMiddleware(array $middleware): array
250 + {
251 + $resolved = [];
252 +
253 + foreach ($middleware as $entry) {
254 + $class = $this->aliases[$entry] ?? $entry;
255 +
256 + if (!is_string($class) || !class_exists($class)) {
257 + throw new \InvalidArgumentException(
258 + esc_html(sprintf("Middleware: %s could not be resolved to a valid class.", $entry))
259 + );
260 + }
261 +
262 + $instance = App::getInstance()->get($class);
263 +
264 + if (!$instance instanceof MiddlewareInterface) {
265 + throw new \InvalidArgumentException(
266 + esc_html(sprintf('Middleware: %s must implement MiddlewareInterface.', $entry))
267 + );
268 + }
269 +
270 + $resolved[] = $instance;
271 + }
272 +
273 + return $resolved;
274 + }
275 +
276 + /**
277 + * Process the given methods and compare them to the allowed
278 + * {@see \WP_REST_Server::ALLMETHODS} methods. Remove unwanted entries and
279 + * cleanup method usage from, for example, "get " to "GET".
280 + *
281 + * @return string From "get, POSt, fake" to "GET,POST"
282 + */
283 + private function normalizeMethods(string $methods): string
284 + {
285 + // Split into array, trim whitespace and uppercase entries
286 + $methodsArray = array_map('trim', explode(',', $methods));
287 + $methodsArray = array_map('strtoupper', $methodsArray);
288 +
289 + // Split allowed entries into array and trim whitespaces
290 + $allowedMethodsArray = array_map('trim', explode(',', \WP_REST_Server::ALLMETHODS));
291 +
292 + // Keep only allowed methods
293 + $methodsArray = array_intersect($methodsArray, $allowedMethodsArray);
294 + $methodsArray = array_values(array_unique($methodsArray));
295 +
296 + // Convert back to CSV format for register_rest_route usage
297 + return implode(',', $methodsArray);
298 + }
299 +}