Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
193 / 193 |
|
100.00% |
15 / 15 |
CRAP | |
100.00% |
1 / 1 |
| Taproot\Micropub\getAccessToken | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
7 | |||
| Taproot\Micropub\normalizeUrlencodedCreateRequest | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
| MicropubAdapter | |
100.00% |
177 / 177 |
|
100.00% |
13 / 13 |
81 | |
100.00% |
1 / 1 |
| verifyAccessTokenCallback | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
| extensionCallback | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| configurationQueryCallback | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| sourceQueryCallback | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| deleteCallback | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| undeleteCallback | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| updateCallback | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| createCallback | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| mediaEndpointCallback | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| mediaEndpointExtensionCallback | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getLogger | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| handleRequest | |
100.00% |
110 / 110 |
|
100.00% |
1 / 1 |
48 | |||
| handleMediaEndpointRequest | |
100.00% |
30 / 30 |
|
100.00% |
1 / 1 |
11 | |||
| toResponse | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
11 | |||
| 1 | <?php declare(strict_types=1); |
| 2 | |
| 3 | namespace Taproot\Micropub; |
| 4 | |
| 5 | use Nyholm\Psr7\Response; |
| 6 | use Psr\Http\Message\RequestInterface; |
| 7 | use Psr\Http\Message\ServerRequestInterface; |
| 8 | use Psr\Http\Message\ResponseInterface; |
| 9 | use Psr\Http\Message\UploadedFileInterface; |
| 10 | use Psr\Log\LoggerInterface; |
| 11 | use Psr\Log\NullLogger; |
| 12 | |
| 13 | const MICROPUB_ERROR_CODES = ['invalid_request', 'unauthorized', 'insufficient_scope', 'forbidden']; |
| 14 | |
| 15 | /** |
| 16 | * Micropub Adapter Abstract Superclass |
| 17 | * |
| 18 | * Subclass this class and implement the various `*Callback()` methods to handle different |
| 19 | * types of micropub request. |
| 20 | * |
| 21 | * Then, handling a micropub request is as simple as |
| 22 | * |
| 23 | * $mp = new YourMicropubAdapter(); |
| 24 | * return $mp->handleRequest($request); |
| 25 | * |
| 26 | * The same goes for a media endpoint: |
| 27 | * |
| 28 | * return $mp->handleMediaEndpointRequest($request); |
| 29 | * |
| 30 | * Subclasses **must** implement the abstract callback method `verifyAccessToken()` in order |
| 31 | * to have a functional micropub endpoint. All other callback methods are optional, and |
| 32 | * their functionality is enabled if a subclass implements them. Feel free to define your own |
| 33 | * constructor, and make any implementation-specific objects available to callbacks by storing |
| 34 | * them as properties. |
| 35 | * |
| 36 | * Each callback is passed data corresponding to the type of micropub request dispatched |
| 37 | * to it, but can also access the original request via `$this->request`. Data about the |
| 38 | * currently authenticated user is available in `$this->user`. |
| 39 | * |
| 40 | * Each callback return data in a format defined by the callback, which will be |
| 41 | * converted into the appropriate HTTP Response. Returning an instance of `ResponseInterface` |
| 42 | * from a callback will cause that response to immediately be returned unchanged. Most callbacks |
| 43 | * will also automatically convert an array return value into a JSON response, and will convert |
| 44 | * the following string error codes into properly formatted micropub error responses: |
| 45 | * |
| 46 | * * `'invalid_request'` |
| 47 | * * `'insufficient_scope'` |
| 48 | * * `'unauthorized'` |
| 49 | * * `'forbidden'` |
| 50 | * |
| 51 | * In practise, you’ll mostly be returning the first two, as the others are handled automatically. |
| 52 | * |
| 53 | * MicropubAdapter **does not handle any authorization or permissions**, as which users and |
| 54 | * scopes have what permissions depends on your implementation. It’s up to you to confirm that |
| 55 | * the current access token has sufficient scope and permissions to carry out any given action |
| 56 | * within your callback, and return `'insufficient_scope'` or your own custom instance of |
| 57 | * `ResponseInterface`. |
| 58 | * |
| 59 | * Most callbacks halt execution, but some are optional. Returning a falsy value from these |
| 60 | * optional callbacks continues execution uninterrupted. This is usually to allow you to pre- |
| 61 | * empt standard micropub handling and implement custom extensions. |
| 62 | * |
| 63 | * MicropubAdapter works with PSR-7 HTTP Interfaces. Specifically, expects an object implementing |
| 64 | * `ServerRequestInterface`, and will return an object implemeting `ResponseInterface`. If you |
| 65 | * want to return responses from your callbacks, you’re free to use any suitable implementation. |
| 66 | * For internally-generated responses, `Nyholm\Psr7\Response` is used. |
| 67 | * |
| 68 | * If you’re not using a framework which works with PSR-7 objects, you’ll have to convert |
| 69 | * whatever request data you have into something implementing PSR-7 `ServerRequestInterface`, |
| 70 | * and convert the returned `ResponseInterface`s to something you can work with. |
| 71 | * |
| 72 | * @link https://micropub.spec.indieweb.org/ |
| 73 | * @link https://indieweb.org/micropub |
| 74 | */ |
| 75 | abstract class MicropubAdapter { |
| 76 | |
| 77 | /** |
| 78 | * @var array $user The validated access_token, made available for use in callback methods. |
| 79 | */ |
| 80 | public $user; |
| 81 | |
| 82 | /** |
| 83 | * @var RequestInterface $request The current request, made available for use in callback methods. |
| 84 | */ |
| 85 | public $request; |
| 86 | |
| 87 | /** |
| 88 | * @var null|LoggerInterface $logger The logger used by MicropubAdaptor for internal logging. |
| 89 | */ |
| 90 | public $logger; |
| 91 | |
| 92 | /** |
| 93 | * @var string[] $errorMessages An array mapping micropub and adapter-specific error codes to human-friendly descriptions. |
| 94 | */ |
| 95 | private $errorMessages = [ |
| 96 | // Built-in micropub error types |
| 97 | 'insufficient_scope' => 'Your access token does not grant the scope required for this action.', |
| 98 | 'forbidden' => 'The authenticated user does not have permission to perform this request.', |
| 99 | 'unauthorized' => 'The request did not provide an access token.', |
| 100 | 'invalid_request' => 'The request was invalid.', |
| 101 | // Custom errors |
| 102 | 'access_token_invalid' => 'The provided access token could not be verified.', |
| 103 | 'missing_url_parameter' => 'The request did not provide the required url parameter.', |
| 104 | 'post_with_given_url_not_found' => 'A post with the given URL could not be found.', |
| 105 | 'not_implemented' => 'This functionality is not implemented.', |
| 106 | ]; |
| 107 | |
| 108 | /** |
| 109 | * Verify Access Token Callback |
| 110 | * |
| 111 | * Given an access token, attempt to verify it. |
| 112 | * |
| 113 | * * If it’s valid, return an array to be stored in `$this->user`, which typically looks |
| 114 | * something like this: |
| 115 | * |
| 116 | * [ |
| 117 | * 'me' => 'https://example.com', |
| 118 | * 'client_id' => 'https://clientapp.example', |
| 119 | * 'scope' => ['array', 'of', 'granted', 'scopes'], |
| 120 | * 'date_issued' => \Datetime |
| 121 | * ] |
| 122 | * * If the toke in invalid, return one of the following: |
| 123 | * * `false`, which will be converted into an appropriate error message. |
| 124 | * * `'forbidden'`, which will be converted into an appropriate error message. |
| 125 | * * An array to be converted into an error response, with the form: |
| 126 | * |
| 127 | * [ |
| 128 | * 'error': 'forbidden' |
| 129 | * 'error_description': 'Your custom error description' |
| 130 | * ] |
| 131 | * * Your own instance of `ResponseInterface` |
| 132 | * |
| 133 | * |
| 134 | * MicropubAdapter treats the data as being opaque, and simply makes it |
| 135 | * available to your callback methods for further processing, so you’re free |
| 136 | * to structure it however you want. |
| 137 | * |
| 138 | * @param string $token The Authentication: Bearer access token. |
| 139 | * @return array|string|false|ResponseInterface |
| 140 | * @link https://micropub.spec.indieweb.org/#authentication-0 |
| 141 | * @api |
| 142 | */ |
| 143 | abstract public function verifyAccessTokenCallback(string $token); |
| 144 | |
| 145 | /** |
| 146 | * Micropub Extension Callback |
| 147 | * |
| 148 | * This callback is called after an access token is verified, but before any micropub- |
| 149 | * specific handling takes place. This is the place to implement support for micropub |
| 150 | * extensions. |
| 151 | * |
| 152 | * If a falsy value is returned, the request continues to be handled as a regular |
| 153 | * micropub request. If it returns a truthy value (either a MP error code, an array to |
| 154 | * be returned as JSON, or a ready-made ResponseInterface), request handling is halted |
| 155 | * and the returned value is converted into a response and returned. |
| 156 | * |
| 157 | * @param ServerRequestInterface $request |
| 158 | * @return false|array|string|ResponseInterface |
| 159 | * @link https://indieweb.org/Micropub-extensions |
| 160 | * @api |
| 161 | */ |
| 162 | public function extensionCallback(ServerRequestInterface $request) { |
| 163 | // Default implementation: no-op; |
| 164 | return false; |
| 165 | } |
| 166 | |
| 167 | /** |
| 168 | * Configuration Query Callback |
| 169 | * |
| 170 | * Handle a GET q=config query. Should return either a custom ResponseInterface, or an |
| 171 | * array structure conforming to the micropub specification, e.g.: |
| 172 | * |
| 173 | * [ |
| 174 | * 'media-endpoint' => 'http://example.com/your-media-endpoint', |
| 175 | * 'syndicate-to' => [[ |
| 176 | * 'uid' => 'https://myfavoritesocialnetwork.example/aaronpk', // Required |
| 177 | * 'name' => 'aaronpk on myfavoritesocialnetwork', // Required |
| 178 | * 'service' => [ // Optional |
| 179 | * 'name' => 'My Favorite Social Network', |
| 180 | * 'url' => 'https://myfavoritesocialnetwork.example/', |
| 181 | * 'photo' => 'https://myfavoritesocialnetwork.example/img/icon.png', |
| 182 | * ], |
| 183 | * 'user' => [ // Optional |
| 184 | * 'name' => 'aaronpk', |
| 185 | * 'photo' => 'https://myfavoritesocialnetwork.example/aaronpk', |
| 186 | * 'url' => 'https://myfavoritesocialnetwork.example/aaronpk/photo.jpg' |
| 187 | * ] |
| 188 | * ]] |
| 189 | * ] |
| 190 | * |
| 191 | * The results from this function are also used to respond to syndicate-to queries. If |
| 192 | * a raw ResponseInterface is returned, that will be used as-is. If an array structure |
| 193 | * is returned, syndicate-to queries will extract the syndicate-to information and |
| 194 | * return just that. |
| 195 | * |
| 196 | * @param array $params The unaltered query string parameters from the request. |
| 197 | * @return array|string|ResponseInterface Return either an array with config data, a micropub error string, or a ResponseInterface to short-circuit |
| 198 | * @link https://micropub.spec.indieweb.org/#configuration |
| 199 | * @api |
| 200 | */ |
| 201 | public function configurationQueryCallback(array $params) { |
| 202 | // Default response: an empty JSON object. |
| 203 | return $this->toResponse(null); |
| 204 | } |
| 205 | |
| 206 | /** |
| 207 | * Source Query Callback |
| 208 | * |
| 209 | * Handle a GET q=source query. |
| 210 | * |
| 211 | * The callback should return a microformats2 canonical JSON representation |
| 212 | * of the post identified by $url, either as an array or as a ready-made ResponseInterface. |
| 213 | * |
| 214 | * If the post identified by $url cannot be found, returning false will return a |
| 215 | * correctly-formatted error response. Alternatively, you can return a string micropub |
| 216 | * error code (e.g. `'invalid_request'`) or your own instance of `ResponseInterface`. |
| 217 | * |
| 218 | * @param string $url The URL of the post for which to return properties. |
| 219 | * @param array|null $properties = null The list of properties to return (all if null) |
| 220 | * @return array|false|string|ResponseInterface Return either an array with canonical mf2 data, false if the post could not be found, a micropub error string, or a ResponseInterface to short-circuit. |
| 221 | * @link https://micropub.spec.indieweb.org/#source-content |
| 222 | * @api |
| 223 | */ |
| 224 | public function sourceQueryCallback(string $url, array $properties = null) { |
| 225 | // Default response: not implemented. |
| 226 | return $this->toResponse([ |
| 227 | 'error' => 'invalid_request', |
| 228 | 'error_description' => $this->errorMessages['not_implemented'] |
| 229 | ]); |
| 230 | } |
| 231 | |
| 232 | /** |
| 233 | * Delete Callback |
| 234 | * |
| 235 | * Handle a POST action=delete request. |
| 236 | * |
| 237 | * * Look for a post identified by the $url parameter. |
| 238 | * * If it doesn’t exist: return `false` or `'invalid_request'` as a shortcut for an |
| 239 | * HTTP 400 invalid_request response. |
| 240 | * * If the current access token scope doesn’t permit deletion, return `'insufficient_scope'`, |
| 241 | * an array with `'error'` and `'error_description'` keys, or your own ResponseInterface. |
| 242 | * * If the post exists and can be deleted or is already deleted, delete it and return true. |
| 243 | * |
| 244 | * @param string $url The URL of the post to be deleted. |
| 245 | * @return string|true|array|ResponseInterface |
| 246 | * @link https://micropub.spec.indieweb.org/#delete |
| 247 | * @api |
| 248 | */ |
| 249 | public function deleteCallback(string $url) { |
| 250 | // Default response: not implemented. |
| 251 | return $this->toResponse([ |
| 252 | 'error' => 'invalid_request', |
| 253 | 'error_description' => $this->errorMessages['not_implemented'] |
| 254 | ]); |
| 255 | } |
| 256 | |
| 257 | /** |
| 258 | * Undelete Callback |
| 259 | * |
| 260 | * Handle a POST action=undelete request. |
| 261 | * |
| 262 | * * Look for a post identified by the $url parameter. |
| 263 | * * If it doesn’t exist: return `false` or `'invalid_request'` as a shortcut for an |
| 264 | * HTTP 400 invalid_request response. |
| 265 | * * If the current access token scope doesn’t permit undeletion, return `'insufficient_scope'`, |
| 266 | * an array with `'error'` and `'error_description'` keys, or your own ResponseInterface. |
| 267 | * * If the post exists and can be undeleted, do so. Return true for success, or a URL if the |
| 268 | * undeletion caused the post’s URL to change. |
| 269 | * |
| 270 | * @param string $url The URL of the post to be undeleted. |
| 271 | * @return string|true|array|ResponseInterface true on basic success, otherwise either an error string, or a URL if the undeletion caused the post’s location to change. |
| 272 | * @link https://micropub.spec.indieweb.org/#delete |
| 273 | * @api |
| 274 | */ |
| 275 | public function undeleteCallback(string $url) { |
| 276 | // Default response: not implemented. |
| 277 | return $this->toResponse([ |
| 278 | 'error' => 'invalid_request', |
| 279 | 'error_description' => $this->errorMessages['not_implemented'] |
| 280 | ]); |
| 281 | } |
| 282 | |
| 283 | /** |
| 284 | * Update Callback |
| 285 | * |
| 286 | * Handles a POST action=update request. |
| 287 | * |
| 288 | * * Look for a post identified by the $url parameter. |
| 289 | * * If it doesn’t exist: return `false` or `'invalid_request'` as a shortcut for an |
| 290 | * HTTP 400 invalid_request response. |
| 291 | * * If the current access token scope doesn’t permit updates, return `'insufficient_scope'`, |
| 292 | * an array with `'error'` and `'error_description'` keys, or your own ResponseInterface. |
| 293 | * * If the post exists and can be updated, do so. Return true for basic success, or a URL if the |
| 294 | * undeletion caused the post’s URL to change. |
| 295 | * |
| 296 | * @param string $url The URL of the post to be updated. |
| 297 | * @param array $actions The parsed body of the request, containing 'replace', 'add' and/or 'delete' keys describing the operations to perfom on the post. |
| 298 | * @return true|string|array|ResponseInterface Return true for a basic success, a micropub error string, an array to be converted to a JSON response, or a ready-made ResponseInterface |
| 299 | * @link https://micropub.spec.indieweb.org/#update |
| 300 | * @api |
| 301 | */ |
| 302 | public function updateCallback(string $url, array $actions) { |
| 303 | // Default response: not implemented. |
| 304 | return $this->toResponse([ |
| 305 | 'error' => 'invalid_request', |
| 306 | 'error_description' => $this->errorMessages['not_implemented'] |
| 307 | ]); |
| 308 | } |
| 309 | |
| 310 | /** |
| 311 | * Create Callback |
| 312 | * |
| 313 | * Handles a create request. JSON parameters are left unchanged, urlencoded |
| 314 | * form parameters are normalized into canonical microformats-2 JSON form. |
| 315 | * |
| 316 | * * If the current access token scope doesn’t permit updates, return either |
| 317 | * `'insufficient_scope'`, an array with `'error'` and `'error_description'` |
| 318 | * keys, or your own ResponseInterface. |
| 319 | * * Create the post. |
| 320 | * * On an error, return either a micropub error code to be upgraded into a |
| 321 | * full error response, or your own ResponseInterface. |
| 322 | * * On success, return either the URL of the created post to be upgraded into |
| 323 | * a HTTP 201 success response, or your own ResponseInterface. |
| 324 | * |
| 325 | * @param array $data The data to create a post with in canonical MF2 structure |
| 326 | * @param array $uploadedFiles an associative array mapping property names to UploadedFileInterface objects, or arrays thereof |
| 327 | * @return string|array|ResponseInterface A URL on success, a micropub error code, an array to be returned as JSON response, or a ready-made ResponseInterface |
| 328 | * @link https://micropub.spec.indieweb.org/#create |
| 329 | * @api |
| 330 | */ |
| 331 | public function createCallback(array $data, array $uploadedFiles) { |
| 332 | // Default response: not implemented. |
| 333 | return $this->toResponse([ |
| 334 | 'error' => 'invalid_request', |
| 335 | 'error_description' => $this->errorMessages['not_implemented'] |
| 336 | ]); |
| 337 | } |
| 338 | |
| 339 | /** |
| 340 | * Media Endpoint Callback |
| 341 | * |
| 342 | * To handle file upload requests: |
| 343 | * |
| 344 | * * If the current access token scope doesn’t permit uploads, return either |
| 345 | * `'insufficient_scope'`, an array with `'error'` and `'error_description'` |
| 346 | * keys, or your own ResponseInterface. |
| 347 | * * Handle the uploaded file. |
| 348 | * * On an error, return either a micropub error code to be upgraded into a |
| 349 | * full error response, or your own ResponseInterface. |
| 350 | * * On success, return either the URL of the created URL to be upgraded into |
| 351 | * a HTTP 201 success response, or your own ResponseInterface. |
| 352 | * |
| 353 | * @param UploadedFileInterface $file The file to upload |
| 354 | * @return string|array|ResponseInterface Return the URL of the uploaded file on success, a micropub error code to be upgraded into an error response, an array for a JSON response, or a ready-made ResponseInterface |
| 355 | * @link https://micropub.spec.indieweb.org/#media-endpoint |
| 356 | * @api |
| 357 | */ |
| 358 | public function mediaEndpointCallback(UploadedFileInterface $file) { |
| 359 | // Default implementation: not implemented. |
| 360 | return $this->toResponse([ |
| 361 | 'error' => 'invalid_request', |
| 362 | 'error_description' => $this->errorMessages['not_implemented'] |
| 363 | ]); |
| 364 | } |
| 365 | |
| 366 | /** |
| 367 | * Micropub Media Endpoint Extension Callback |
| 368 | * |
| 369 | * This callback is called after an access token is verified, but before any media- |
| 370 | * endpoint-specific handling takes place. This is the place to implement support |
| 371 | * for micropub media endpoint extensions. |
| 372 | * |
| 373 | * If a falsy value is returned, the request continues to be handled as a regular |
| 374 | * micropub request. If it returns a truthy value (either a MP error code, an array to |
| 375 | * be returned as JSON, or a ready-made ResponseInterface), request handling is halted |
| 376 | * and the returned value is converted into a response and returned. |
| 377 | * |
| 378 | * @param ServerRequestInterface $request |
| 379 | * @return false|array|string|ResponseInterface |
| 380 | * @link https://indieweb.org/Micropub-extensions |
| 381 | */ |
| 382 | public function mediaEndpointExtensionCallback(ServerRequestInterface $request) { |
| 383 | // Default implementation: no-op. |
| 384 | return false; |
| 385 | } |
| 386 | |
| 387 | /** |
| 388 | * Get Logger |
| 389 | * |
| 390 | * Returns an instance of Psr\LoggerInterface, used for logging. Override to |
| 391 | * provide with your logger of choice. |
| 392 | * |
| 393 | * @return \Psr\Log\LoggerInterface |
| 394 | */ |
| 395 | protected function getLogger(): LoggerInterface { |
| 396 | if (!isset($this->logger)) { |
| 397 | $this->logger = new NullLogger(); |
| 398 | } |
| 399 | return $this->logger; |
| 400 | } |
| 401 | |
| 402 | /** |
| 403 | * Handle Micropub Request |
| 404 | * |
| 405 | * Handle an incoming request to a micropub endpoint, performing error checking and |
| 406 | * handing execution off to the appropriate callback. |
| 407 | * |
| 408 | * `$this->request` is set to the value of the `$request` argument, for use within |
| 409 | * callbacks. If the access token could be verified, `$this->user` is set to the value |
| 410 | * returned from `verifyAccessTokenCallback()` for use within callbacks. |
| 411 | * |
| 412 | * @param ServerRequestInterface $request |
| 413 | * @return ResponseInterface |
| 414 | */ |
| 415 | public function handleRequest(ServerRequestInterface $request) { |
| 416 | // Make $request available to callbacks. |
| 417 | $this->request = $request; |
| 418 | |
| 419 | $logger = $this->getLogger(); |
| 420 | |
| 421 | // Get and verify auth token. |
| 422 | $accessToken = getAccessToken($request); |
| 423 | if ($accessToken === null) { |
| 424 | $logger->warning($this->errorMessages['unauthorized']); |
| 425 | return $this->toResponse('unauthorized'); |
| 426 | } |
| 427 | |
| 428 | $accessTokenResult = $this->verifyAccessTokenCallback($accessToken); |
| 429 | if ($accessTokenResult instanceof ResponseInterface) { |
| 430 | return $accessTokenResult; // Short-circuit. |
| 431 | } elseif (is_array($accessTokenResult)) { |
| 432 | // Log success. |
| 433 | $logger->info('Access token verified successfully.', ['user' => $accessTokenResult]); |
| 434 | $this->user = $accessTokenResult; |
| 435 | } else { |
| 436 | // Log error, return not authorized response. |
| 437 | $logger->error($this->errorMessages['access_token_invalid']); |
| 438 | return $this->toResponse('forbidden'); |
| 439 | } |
| 440 | |
| 441 | // Give subclasses an opportunity to pre-emptively handle any extension cases before moving on to |
| 442 | // standard micropub handling. |
| 443 | $extensionCallbackResult = $this->extensionCallback($request); |
| 444 | if ($extensionCallbackResult) { |
| 445 | return $this->toResponse($extensionCallbackResult); |
| 446 | } |
| 447 | |
| 448 | // Check against method. |
| 449 | if (strtolower($request->getMethod()) == 'get') { |
| 450 | $queryParams = $request->getQueryParams(); |
| 451 | |
| 452 | if (isset($queryParams['q']) and is_string($queryParams['q'])) { |
| 453 | $q = $queryParams['q']; |
| 454 | if ($q == 'config') { |
| 455 | // Handle configuration query. |
| 456 | $logger->info('Handling config query', $queryParams); |
| 457 | return $this->toResponse($this->configurationQueryCallback($queryParams)); |
| 458 | } elseif ($q == 'source') { |
| 459 | // Handle source query. |
| 460 | $logger->info('Handling source query', $queryParams); |
| 461 | |
| 462 | // Normalize properties([]) paramter. |
| 463 | if (isset($queryParams['properties']) and is_array($queryParams['properties'])) { |
| 464 | $sourceProperties = $queryParams['properties']; |
| 465 | } elseif (isset($queryParams['properties']) and is_string($queryParams['properties'])) { |
| 466 | $sourceProperties = [$queryParams['properties']]; |
| 467 | } else { |
| 468 | $sourceProperties = null; |
| 469 | } |
| 470 | |
| 471 | // Check for a valid (string) url parameter. |
| 472 | if (!isset($queryParams['url']) or !is_string($queryParams['url'])) { |
| 473 | $logger->error($this->errorMessages['missing_url_parameter']); |
| 474 | return $this->toResponse(json_encode([ |
| 475 | 'error' => 'invalid_request', |
| 476 | 'error_description' => $this->errorMessages['missing_url_parameter'] |
| 477 | ]), 400); |
| 478 | } |
| 479 | |
| 480 | $sourceQueryResult = $this->sourceQueryCallback($queryParams['url'], $sourceProperties); |
| 481 | if ($sourceQueryResult === false) { |
| 482 | // Returning false is a shortcut for an “invalid URL” error. |
| 483 | $logger->error($this->errorMessages['post_with_given_url_not_found']); |
| 484 | $sourceQueryResult = [ |
| 485 | 'error' => 'invalid_request', |
| 486 | 'error_description' => $this->errorMessages['post_with_given_url_not_found'] |
| 487 | ]; |
| 488 | } |
| 489 | |
| 490 | return $this->toResponse($sourceQueryResult); |
| 491 | } elseif ($q == 'syndicate-to') { |
| 492 | // Handle syndicate-to query via the configuration query callback. |
| 493 | $logger->info('Handling syndicate-to query.', $queryParams); |
| 494 | $configQueryResult = $this->configurationQueryCallback($queryParams); |
| 495 | if ($configQueryResult instanceof ResponseInterface) { |
| 496 | // Short-circuit, assume that the response from q=config will suffice for q=syndicate-to. |
| 497 | return $configQueryResult; |
| 498 | } elseif (is_array($configQueryResult) and array_key_exists('syndicate-to', $configQueryResult)) { |
| 499 | return new Response(200, ['content-type' => 'application/json'], json_encode([ |
| 500 | 'syndicate-to' => $configQueryResult['syndicate-to'] |
| 501 | ])); |
| 502 | } else { |
| 503 | // We don’t have anything to return, so return an empty result. |
| 504 | return new Response(200, ['content-type' => 'application/json'], '{"syndicate-to": []}'); |
| 505 | } |
| 506 | } |
| 507 | } |
| 508 | |
| 509 | // We weren’t able to handle this GET request. |
| 510 | $logger->error('Micropub endpoint was not able to handle GET request', $queryParams); |
| 511 | return $this->toResponse('invalid_request'); |
| 512 | } elseif (strtolower($request->getMethod()) == 'post') { |
| 513 | $contentType = $request->getHeaderLine('content-type'); |
| 514 | $jsonRequest = str_contains($contentType, 'application/json'); |
| 515 | |
| 516 | // Get a parsed body sufficient to determine the nature of the request. |
| 517 | if ($jsonRequest) { |
| 518 | $parsedBody = json_decode((string) $request->getBody(), true); |
| 519 | } else { |
| 520 | $parsedBody = $request->getParsedBody(); |
| 521 | } |
| 522 | |
| 523 | // The rest of the code assumes that parsedBody is an array. If we don’t have an array by now, |
| 524 | // the request is invalid. |
| 525 | if (!is_array($parsedBody)) { |
| 526 | return $this->toResponse('invalid_request'); |
| 527 | } |
| 528 | |
| 529 | // Prevent the access_token from being stored. |
| 530 | unset($parsedBody['access_token']); |
| 531 | |
| 532 | // Check for action. |
| 533 | if (isset($parsedBody['action']) and is_string($parsedBody['action'])) { |
| 534 | $action = $parsedBody['action']; |
| 535 | if ($action == 'delete') { |
| 536 | // Handle delete request. |
| 537 | $logger->info('Handling delete request.', $parsedBody); |
| 538 | if (isset($parsedBody['url']) and is_string($parsedBody['url'])) { |
| 539 | $deleteResult = $this->deleteCallback($parsedBody['url']); |
| 540 | if ($deleteResult === true) { |
| 541 | // If the delete was successful, respond with an empty 204 response. |
| 542 | return $this->toResponse('', 204); |
| 543 | } else { |
| 544 | return $this->toResponse($deleteResult); |
| 545 | } |
| 546 | } else { |
| 547 | $logger->warning($this->errorMessages['missing_url_parameter']); |
| 548 | return new Response(400, ['content-type' => 'application/json'], json_encode([ |
| 549 | 'error' => 'invalid_request', |
| 550 | 'error_description' => $this->errorMessages['missing_url_parameter'] |
| 551 | ])); |
| 552 | } |
| 553 | |
| 554 | } elseif ($action == 'undelete') { |
| 555 | // Handle undelete request. |
| 556 | if (isset($parsedBody['url']) and is_string($parsedBody['url'])) { |
| 557 | $undeleteResult = $this->undeleteCallback($parsedBody['url']); |
| 558 | if ($undeleteResult === true) { |
| 559 | // If the delete was successful, respond with an empty 204 response. |
| 560 | return $this->toResponse('', 204); |
| 561 | } elseif (is_string($undeleteResult) and !in_array($undeleteResult, MICROPUB_ERROR_CODES)) { |
| 562 | // The non-error-code string returned from undelete is the URL of the new location of the |
| 563 | // undeleted content. |
| 564 | return new Response(201, ['location' => $undeleteResult]); |
| 565 | } else { |
| 566 | return $this->toResponse($undeleteResult); |
| 567 | } |
| 568 | } else { |
| 569 | $logger->warning($this->errorMessages['missing_url_parameter']); |
| 570 | return new Response(400, ['content-type' => 'application/json'], json_encode([ |
| 571 | 'error' => 'invalid_request', |
| 572 | 'error_description' => $this->errorMessages['missing_url_parameter'] |
| 573 | ])); |
| 574 | } |
| 575 | } elseif ($action == 'update') { |
| 576 | // Handle update request. |
| 577 | // Check for the required url parameter. |
| 578 | if (!isset($parsedBody['url']) or !is_string($parsedBody['url'])) { |
| 579 | $logger->warning("An update request had a missing or invalid url parameter."); |
| 580 | return new Response(400, ['content-type' => 'application/json'], json_encode([ |
| 581 | 'error' => 'invalid_request', |
| 582 | 'error_description' => $this->errorMessages['missing_url_parameter'] |
| 583 | ])); |
| 584 | } |
| 585 | |
| 586 | // Check that the three possible update action parameters are all arrays. |
| 587 | foreach (['replace', 'add', 'delete'] as $updateAction) { |
| 588 | if (isset($parsedBody[$updateAction]) and !is_array($parsedBody[$updateAction])) { |
| 589 | $logger->warning("An update request had an invalid (non-array) $updateAction", [$updateAction => $parsedBody[$updateAction]]); |
| 590 | return $this->toResponse('invalid_request'); |
| 591 | } |
| 592 | } |
| 593 | |
| 594 | $updateResult = $this->updateCallback($parsedBody['url'], $parsedBody); |
| 595 | if ($updateResult === true) { |
| 596 | // Basic success. |
| 597 | return $this->toResponse('', 204); |
| 598 | } elseif (is_string($updateResult) and !in_array($updateResult, MICROPUB_ERROR_CODES)) { |
| 599 | // The non-error-code string returned from update is the URL of the new location of the |
| 600 | // undeleted content. |
| 601 | return new Response(201, ['location' => $updateResult]); |
| 602 | } else { |
| 603 | return $this->toResponse($updateResult); |
| 604 | } |
| 605 | } |
| 606 | |
| 607 | // An unknown action was provided. Return invalid_request. |
| 608 | $logger->error('An unknown action parameter was provided.', $parsedBody); |
| 609 | return $this->toResponse('invalid_request'); |
| 610 | } |
| 611 | |
| 612 | // Assume that the request is a Create request. |
| 613 | // If we’re dealing with an x-www-form-urlencoded or multipart/form-data request, |
| 614 | // normalise form data to match JSON structure. |
| 615 | if (!$jsonRequest) { |
| 616 | $logger->info('Normalizing URL-encoded data into canonical JSON format.'); |
| 617 | $parsedBody = normalizeUrlencodedCreateRequest($parsedBody); |
| 618 | } |
| 619 | |
| 620 | // Pass data off to create callback. |
| 621 | $createResponse = $this->createCallback($parsedBody, $request->getUploadedFiles()); |
| 622 | if (is_string($createResponse) and !in_array($createResponse, MICROPUB_ERROR_CODES)) { |
| 623 | // Success, return HTTP 201 with Location header. |
| 624 | return new Response(201, ['location' => $createResponse]); |
| 625 | } else { |
| 626 | return $this->toResponse($createResponse); |
| 627 | } |
| 628 | |
| 629 | } |
| 630 | |
| 631 | // Request method was something other than GET or POST. |
| 632 | $logger->error('The request had a method other than POST or GET.', ['method' => $request->getMethod()]); |
| 633 | return $this->toResponse('invalid_request'); |
| 634 | } |
| 635 | |
| 636 | /** |
| 637 | * Handle Media Endpoint Request |
| 638 | * |
| 639 | * Handle a request to a micropub media-endpoint. |
| 640 | * |
| 641 | * As with `handleRequest()`, `$this->request` and `$this->user` are made available |
| 642 | * for use within callbacks. |
| 643 | * |
| 644 | * @param ServerRequestInterface $request |
| 645 | * @return ResponseInterface |
| 646 | */ |
| 647 | public function handleMediaEndpointRequest(ServerRequestInterface $request) { |
| 648 | $logger = $this->getLogger(); |
| 649 | $this->request = $request; |
| 650 | |
| 651 | // Get and verify auth token. |
| 652 | $accessToken = getAccessToken($request); |
| 653 | if ($accessToken === null) { |
| 654 | $logger->warning($this->errorMessages['unauthorized']); |
| 655 | return new Response(401, ['content-type' => 'application/json'], json_encode([ |
| 656 | 'error' => 'unauthorized', |
| 657 | 'error_description' => $this->errorMessages['unauthorized'] |
| 658 | ])); |
| 659 | } |
| 660 | |
| 661 | $accessTokenResult = $this->verifyAccessTokenCallback($accessToken); |
| 662 | if ($accessTokenResult instanceof ResponseInterface) { |
| 663 | return $accessTokenResult; // Short-circuit. |
| 664 | } elseif (is_array($accessTokenResult)) { |
| 665 | // Log success. |
| 666 | $logger->info('Access token verified successfully.', ['user' => $accessTokenResult]); |
| 667 | $this->user = $accessTokenResult; |
| 668 | } else { |
| 669 | // Log error, return not authorized response. |
| 670 | $logger->error($this->errorMessages['access_token_invalid']); |
| 671 | return new Response(403, ['content-type' => 'application/json'], json_encode([ |
| 672 | 'error' => 'forbidden', |
| 673 | 'error_description' => $this->errorMessages['access_token_invalid'] |
| 674 | ])); |
| 675 | } |
| 676 | |
| 677 | // Give implementations a chance to pre-empt regular media endpoint handling, in order |
| 678 | // to implement extensions. |
| 679 | $mediaEndpointExtensionResult = $this->mediaEndpointExtensionCallback($request); |
| 680 | if ($mediaEndpointExtensionResult) { |
| 681 | return $this->toResponse($mediaEndpointExtensionResult); |
| 682 | } |
| 683 | |
| 684 | // Only support POST requests to the media endpoint. |
| 685 | if (strtolower($request->getMethod()) != 'post') { |
| 686 | $logger->error('Got a non-POST request to the media endpoint', ['method' => $request->getMethod()]); |
| 687 | return $this->toResponse('invalid_request'); |
| 688 | } |
| 689 | |
| 690 | // Look for the presence of an uploaded file called 'file' |
| 691 | $uploadedFiles = $request->getUploadedFiles(); |
| 692 | if (isset($uploadedFiles['file']) and $uploadedFiles['file'] instanceof UploadedFileInterface) { |
| 693 | $mediaCallbackResult = $this->mediaEndpointCallback($uploadedFiles['file']); |
| 694 | |
| 695 | if ($mediaCallbackResult) { |
| 696 | if (is_string($mediaCallbackResult) and !in_array($mediaCallbackResult, MICROPUB_ERROR_CODES)) { |
| 697 | // Success! Return an HTTP 201 response with the location header. |
| 698 | return new Response(201, ['location' => $mediaCallbackResult]); |
| 699 | } |
| 700 | |
| 701 | // Otherwise, handle whatever it is we got. |
| 702 | return $this->toResponse($mediaCallbackResult); |
| 703 | } |
| 704 | } |
| 705 | |
| 706 | // Either no file was provided, or mediaEndpointCallback returned a falsy value. |
| 707 | return $this->toResponse('invalid_request'); |
| 708 | } |
| 709 | |
| 710 | /** |
| 711 | * To Response |
| 712 | * |
| 713 | * Intelligently convert various shortcuts into a suitable instance of |
| 714 | * ResponseInterface. Existing ResponseInterfaces are passed through |
| 715 | * without alteration. |
| 716 | * |
| 717 | * @param null|string|array|ResponseInterface $resultOrResponse |
| 718 | * @param int $status=200 |
| 719 | * @return ResponseInterface |
| 720 | */ |
| 721 | private function toResponse($resultOrResponse, int $status=200): ResponseInterface { |
| 722 | if ($resultOrResponse instanceof ResponseInterface) { |
| 723 | return $resultOrResponse; |
| 724 | } |
| 725 | |
| 726 | // Convert micropub error messages into error responses. |
| 727 | if (is_string($resultOrResponse) && in_array($resultOrResponse, MICROPUB_ERROR_CODES)) { |
| 728 | $resultOrResponse = [ |
| 729 | 'error' => $resultOrResponse, |
| 730 | 'error_description' => $this->errorMessages[$resultOrResponse] |
| 731 | ]; |
| 732 | } |
| 733 | |
| 734 | if ($resultOrResponse === null) { |
| 735 | $resultOrResponse = '{}'; // Default to an empty object response if none given. |
| 736 | } elseif (is_array($resultOrResponse)) { |
| 737 | // If this is a known error response, adjust the status accordingly. |
| 738 | if (array_key_exists('error', $resultOrResponse)) { |
| 739 | if ($resultOrResponse['error'] == 'invalid_request') { |
| 740 | $status = 400; |
| 741 | } elseif ($resultOrResponse['error'] == 'unauthorized') { |
| 742 | $status = 401; |
| 743 | } elseif ($resultOrResponse['error'] == 'insufficient_scope') { |
| 744 | $status = 403; |
| 745 | } elseif ($resultOrResponse['error'] == 'forbidden') { |
| 746 | $status = 403; |
| 747 | } |
| 748 | } |
| 749 | $resultOrResponse = json_encode($resultOrResponse); |
| 750 | } |
| 751 | return new Response($status, ['content-type' => 'application/json'], $resultOrResponse); |
| 752 | } |
| 753 | } |
| 754 | |
| 755 | /** |
| 756 | * Get Access Token |
| 757 | * |
| 758 | * Given a request, return the Micropub access token, or null. |
| 759 | * |
| 760 | * @return string|null |
| 761 | */ |
| 762 | function getAccessToken(ServerRequestInterface $request) { |
| 763 | if ($request->hasHeader('authorization')) { |
| 764 | foreach ($request->getHeader('authorization') as $authVal) { |
| 765 | if (strtolower(substr($authVal, 0, 6)) == 'bearer') { |
| 766 | return substr($authVal, 7); |
| 767 | } |
| 768 | } |
| 769 | } |
| 770 | |
| 771 | $parsedBody = $request->getParsedBody(); |
| 772 | if (is_array($parsedBody) and array_key_exists('access_token', $parsedBody) and is_string($parsedBody['access_token'])) { |
| 773 | return $parsedBody['access_token']; |
| 774 | } |
| 775 | |
| 776 | return null; |
| 777 | } |
| 778 | |
| 779 | /** |
| 780 | * Normalize URL-encoded Create Request |
| 781 | * |
| 782 | * Given an array of PHP-parsed form parameters (such as from $_POST), convert |
| 783 | * them into canonical microformats2 format. |
| 784 | * |
| 785 | * @param array $body |
| 786 | * @return array |
| 787 | */ |
| 788 | function normalizeUrlencodedCreateRequest(array $body) { |
| 789 | $result = [ |
| 790 | 'type' => ['h-entry'], |
| 791 | 'properties' => [] |
| 792 | ]; |
| 793 | |
| 794 | foreach ($body as $key => $value) { |
| 795 | if ($key == 'h') { |
| 796 | $result['type'] = ["h-$value"]; |
| 797 | } elseif (is_array($value)) { |
| 798 | $result['properties'][$key] = $value; |
| 799 | } else { |
| 800 | $result['properties'][$key] = [$value]; |
| 801 | } |
| 802 | } |
| 803 | |
| 804 | return $result; |
| 805 | } |