|
| 1 | +<?php |
| 2 | + |
| 3 | +declare(strict_types=1); |
| 4 | + |
| 5 | +namespace Clockwork\Support\Psr; |
| 6 | + |
| 7 | +use Clockwork\Clockwork; |
| 8 | +use Clockwork\DataSource\PsrMessageDataSource; |
| 9 | +use Clockwork\Request\IncomingRequest; |
| 10 | +use Clockwork\Storage\Search; |
| 11 | +use Clockwork\Support\Vanilla\Clockwork as VanillaClockwork; |
| 12 | +use Clockwork\Web\Web; |
| 13 | +use Psr\Http\Message\ResponseFactoryInterface; |
| 14 | +use Psr\Http\Message\ResponseInterface; |
| 15 | +use Psr\Http\Message\ServerRequestInterface; |
| 16 | +use Psr\Http\Message\StreamFactoryInterface; |
| 17 | +use Psr\Http\Server\MiddlewareInterface; |
| 18 | +use Psr\Http\Server\RequestHandlerInterface; |
| 19 | + |
| 20 | +/** |
| 21 | + * middleware based on PHP's PSR specifications |
| 22 | + * |
| 23 | + * Many frameworks are built on the PSR specifications, which makes |
| 24 | + * this implementation interoperable with them. |
| 25 | + * |
| 26 | + * TODO: |
| 27 | + * - It would be preferable to detach the `VanillaClockwork` from its |
| 28 | + * contained `Clockwork` instance. The former is currently only used |
| 29 | + * to access the configuration. |
| 30 | + * |
| 31 | + * References: |
| 32 | + * - https://www.php-fig.org/psr/psr-7/ -- request/response interfaces |
| 33 | + * - https://www.php-fig.org/psr/psr-15/ -- middleware interface |
| 34 | + * - https://www.php-fig.org/psr/psr-17/ -- response factory interface |
| 35 | + */ |
| 36 | +class Middleware implements MiddlewareInterface |
| 37 | +{ |
| 38 | + private VanillaClockwork $clockwork; |
| 39 | + private ResponseFactoryInterface $responseFactory; |
| 40 | + private StreamFactoryInterface $streamFactory; |
| 41 | + |
| 42 | + public function __construct( |
| 43 | + VanillaClockwork $clockwork, |
| 44 | + ResponseFactoryInterface $responseFactory, |
| 45 | + StreamFactoryInterface $streamFactory |
| 46 | + ) { |
| 47 | + $this->clockwork = $clockwork; |
| 48 | + $this->responseFactory = $responseFactory; |
| 49 | + $this->streamFactory = $streamFactory; |
| 50 | + } |
| 51 | + |
| 52 | + /** |
| 53 | + * this methods implements the PSR-15 MiddlewareInterface |
| 54 | + */ |
| 55 | + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface |
| 56 | + { |
| 57 | + if (!$this->clockwork->isEnabled()) { |
| 58 | + return $handler->handle($request); |
| 59 | + } |
| 60 | + |
| 61 | + if ($this->clockwork->isWebEnabled() && $this->isWebRequest($request)) { |
| 62 | + $response = $this->handleWebRequest($request); |
| 63 | + } elseif ($this->isApiRequest($request)) { |
| 64 | + $response = $this->handleApiRequest($request); |
| 65 | + } else { |
| 66 | + // Inject clockwork instance into the request, useful to log new events or timeline |
| 67 | + $response = $handler->handle( |
| 68 | + $request->withAttribute('Clockwork', $this->clockwork->getClockwork()) |
| 69 | + ); |
| 70 | + |
| 71 | + $uri = $request->getUri(); |
| 72 | + parse_str($uri->getQuery(), $input); |
| 73 | + $rx = new IncomingRequest([ |
| 74 | + 'method' => $request->getMethod(), |
| 75 | + 'uri' => $uri->__toString(), |
| 76 | + 'input' => $input, |
| 77 | + 'cookies' => $request->getCookieParams(), |
| 78 | + ]); |
| 79 | + |
| 80 | + if (!$this->clockwork->getClockwork()->shouldCollect()->filter($rx)) { |
| 81 | + return $response; |
| 82 | + } |
| 83 | + if (!$this->clockwork->getClockwork()->shouldRecord()->filter($this->clockwork->request())) { |
| 84 | + return $response; |
| 85 | + } |
| 86 | + |
| 87 | + $this->clockwork->getClockwork()->addDataSource(new PsrMessageDataSource($request, $response)); |
| 88 | + $this->clockwork->getClockwork()->resolveRequest()->storeRequest(); |
| 89 | + } |
| 90 | + |
| 91 | + // enrich response with clockwork data |
| 92 | + $clockworkRequest = $this->clockwork->request(); |
| 93 | + $response = $response->withHeader('X-Clockwork-Id', $clockworkRequest->id); |
| 94 | + $response = $response->withHeader('X-Clockwork-Version', Clockwork::VERSION); |
| 95 | + $response = $response->withHeader('X-Clockwork-Path', $this->clockwork->getApiPath()); |
| 96 | + |
| 97 | + // TODO: reactivate/reimplement |
| 98 | + // foreach ($this->config['headers'] as $headerName => $headerValue) { |
| 99 | + // $this->setHeader("X-Clockwork-Header-{$headerName}", $headerValue); |
| 100 | + // } |
| 101 | + |
| 102 | + // TODO: reactivate/reimplement |
| 103 | + // if ($this->config['features']['performance']['client_metrics'] || $this->config['toolbar']) { |
| 104 | + // $this->setCookie('x-clockwork', $this->getCookiePayload(), time() + 60); |
| 105 | + // } |
| 106 | + |
| 107 | + // TODO: reactivate/reimplement |
| 108 | + // if (($eventsCount = $this->config['server_timing']) !== false) { |
| 109 | + // $this->setHeader('Server-Timing', ServerTiming::fromRequest($this->clockwork->request(), $eventsCount)->value()); |
| 110 | + // } |
| 111 | + |
| 112 | + return $response; |
| 113 | + } |
| 114 | + |
| 115 | + private function isWebRequest(ServerRequestInterface $request): bool |
| 116 | + { |
| 117 | + $requestPath = $request->getUri()->getPath(); |
| 118 | + $webPath = $this->clockwork->getWebPath(); |
| 119 | + // handle "/web" case |
| 120 | + if ($requestPath === $webPath) { |
| 121 | + return true; |
| 122 | + } |
| 123 | + // Handle other "/web/something" cases. Note that we don't want |
| 124 | + // e.g. "/webcam". |
| 125 | + return str_starts_with($requestPath, $webPath . '/'); |
| 126 | + } |
| 127 | + |
| 128 | + private function isApiRequest(ServerRequestInterface $request): bool |
| 129 | + { |
| 130 | + $requestPath = $request->getUri()->getPath(); |
| 131 | + $apiPath = rtrim($this->clockwork->getApiPath(), '/'); |
| 132 | + return str_starts_with($requestPath, $apiPath . '/'); |
| 133 | + } |
| 134 | + |
| 135 | + private function handleWebRequest(ServerRequestInterface $request): ResponseInterface |
| 136 | + { |
| 137 | + $requestPath = $request->getUri()->getPath(); |
| 138 | + $webPath = $this->clockwork->getWebPath(); |
| 139 | + |
| 140 | + $relativePath = ltrim(substr($requestPath, strlen($webPath)), '/'); |
| 141 | + |
| 142 | + // handle "/web" and "/web/" cases |
| 143 | + if ($relativePath === '') { |
| 144 | + return $this->responseFactory->createResponse(302) |
| 145 | + ->withHeader( |
| 146 | + 'location', |
| 147 | + $request->getUri()->withPath($webPath . '/index.html')->__toString() |
| 148 | + ); |
| 149 | + } |
| 150 | + |
| 151 | + $web = new Web(); |
| 152 | + $asset = $web->asset($relativePath); |
| 153 | + if ($asset === null) { |
| 154 | + return $this->responseFactory->createResponse(404); |
| 155 | + } |
| 156 | + return $this->responseFactory->createResponse() |
| 157 | + ->withAddedHeader('Content-type', $asset['mime']) |
| 158 | + ->withBody($this->streamFactory->createStreamFromFile($asset['path'])); |
| 159 | + } |
| 160 | + |
| 161 | + private function handleApiRequest(ServerRequestInterface $request): ResponseInterface |
| 162 | + { |
| 163 | + $apiPath = rtrim($this->clockwork->getApiPath(), '/'); |
| 164 | + |
| 165 | + // auth API is a single endpoint |
| 166 | + // Note that this must be handled before checking authentication, |
| 167 | + // lest we end up with a circular dependency. |
| 168 | + $requestPath = $request->getUri()->getPath(); |
| 169 | + if ($requestPath === $apiPath . '/auth') { |
| 170 | + $username = $request->getHeader('username')[0] ?? ''; |
| 171 | + $password = $request->getHeader('password')[0] ?? ''; |
| 172 | + |
| 173 | + $token = $this->clockwork->getClockwork()->authenticator()->attempt([ |
| 174 | + 'username' => $username, |
| 175 | + 'password' => $password, |
| 176 | + ]); |
| 177 | + |
| 178 | + return $this->responseFactory->createResponse($token ? 200 : 403) |
| 179 | + ->withAddedHeader('Content-type', 'application/json') |
| 180 | + ->withBody($this->streamFactory->createStream( |
| 181 | + json_encode( |
| 182 | + ['token' => $token], |
| 183 | + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE |
| 184 | + ) |
| 185 | + )); |
| 186 | + } |
| 187 | + |
| 188 | + // check request authentication |
| 189 | + $authHeaders = $request->getHeader('HTTP_X_CLOCKWORK_AUTH'); |
| 190 | + $authenticator = $this->clockwork->getClockwork()->authenticator(); |
| 191 | + if (!$authenticator->check($authHeaders[0] ?? '')) { |
| 192 | + return $this->responseFactory->createResponse(403) |
| 193 | + ->withAddedHeader('Content-type', 'application/json') |
| 194 | + ->withBody($this->streamFactory->createStream( |
| 195 | + json_encode( |
| 196 | + [ 'requires' => $authenticator->requires() ], |
| 197 | + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE |
| 198 | + ) |
| 199 | + )); |
| 200 | + } |
| 201 | + |
| 202 | + // load metadata |
| 203 | + if (!preg_match( |
| 204 | + "#$apiPath/(?<id>[0-9-]+|latest)(?:/(?<direction>next|previous))?(?:/(?<count>\d+))?#", |
| 205 | + $request->getUri()->__toString(), |
| 206 | + $matches |
| 207 | + )) { |
| 208 | + return $this->responseFactory->createResponse(404); |
| 209 | + } |
| 210 | + $id = isset($matches['id']) ? $matches['id'] : null; |
| 211 | + $direction = isset($matches['direction']) ? $matches['direction'] : null; |
| 212 | + $count = isset($matches['count']) ? $matches['count'] : null; |
| 213 | + $storage = $this->clockwork->getClockwork()->storage(); |
| 214 | + if ($direction == 'previous') { |
| 215 | + $data = $storage->previous($id, $count, Search::fromRequest($_GET)); |
| 216 | + } elseif ($direction == 'next') { |
| 217 | + $data = $storage->next($id, $count, Search::fromRequest($_GET)); |
| 218 | + } elseif ($id == 'latest') { |
| 219 | + $data = $storage->latest(Search::fromRequest($_GET)); |
| 220 | + } else { |
| 221 | + $data = $storage->find($id); |
| 222 | + } |
| 223 | + if ($data === null) { |
| 224 | + return $this->responseFactory->createResponse(404); |
| 225 | + } |
| 226 | + |
| 227 | + // load extended metadata if requested |
| 228 | + if (preg_match("#$apiPath/(?<id>[0-9-]+|latest)/extended#", $request->getUri()->__toString())) { |
| 229 | + $this->clockwork->getClockwork()->extendRequest($data); |
| 230 | + } |
| 231 | + |
| 232 | + $body = is_array($data) |
| 233 | + ? array_map(static function ($item) { return $item->toArray(); }, $data) |
| 234 | + : $data->toArray(); |
| 235 | + return $this->responseFactory->createResponse() |
| 236 | + ->withAddedHeader('Content-type', 'application/json') |
| 237 | + ->withBody($this->streamFactory->createStream( |
| 238 | + json_encode( |
| 239 | + $body, |
| 240 | + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE |
| 241 | + ) |
| 242 | + )); |
| 243 | + } |
| 244 | +} |
0 commit comments